diff --git a/changelog.md b/changelog.md index 17cc24b..420d6a4 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-02-11 - 5.0.0 - BREAKING CHANGE(mta) +migrate internal MTA to @push.rocks/smartmta and remove legacy mail/deliverability implementation + +- Replace ~27k LOC custom MTA (ts/mail/, ts/deliverability/) with @push.rocks/smartmta v5.2.1 (TypeScript+Rust hybrid) +- Remove many SMTP client/server test suites and test helpers; testing approach and fixtures changed/removed +- Upgrade dependencies: @push.rocks/smartproxy -> 23.1.2, @push.rocks/smartdns -> 7.8.0, add @push.rocks/smartmta@5.2.1; bump other minor deps +- API differences: updateEmailRoutes() replaces updateRoutes(); UnifiedEmailServer exposes dkimCreator publicly; bounce/suppression APIs moved to emailServer.* helpers; Email class and IAttachment types moved into @push.rocks/smartmta exports +- SmartProxy route validation stricter: forward actions must use targets (array) instead of target (singular) — tests/configs updated accordingly +- DKIM generation/serving moved to smartmta (dcrouter no longer manages DKIM keys directly) + ## 2026-02-10 - 4.1.1 - fix(smartproxy) upgrade @push.rocks/smartproxy to ^23.1.0 and adapt code/tests for its async getStatistics() API diff --git a/package.json b/package.json index a55be28..d03efe4 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,8 @@ "@git.zone/tsbundle": "^2.8.3", "@git.zone/tsrun": "^2.0.1", "@git.zone/tstest": "^3.1.8", - "@git.zone/tswatch": "^3.0.1", - "@types/node": "^25.2.0", - "node-forge": "^1.3.3" + "@git.zone/tswatch": "^3.1.0", + "@types/node": "^25.2.3" }, "dependencies": { "@api.global/typedrequest": "^3.2.5", @@ -39,31 +38,26 @@ "@push.rocks/qenv": "^6.1.3", "@push.rocks/smartacme": "^8.0.0", "@push.rocks/smartdata": "^7.0.15", - "@push.rocks/smartdns": "^7.6.1", + "@push.rocks/smartdns": "^7.8.0", "@push.rocks/smartfile": "^13.1.2", "@push.rocks/smartguard": "^3.1.0", "@push.rocks/smartjwt": "^2.2.1", "@push.rocks/smartlog": "^3.1.10", - "@push.rocks/smartmail": "^2.2.0", "@push.rocks/smartmetrics": "^2.0.10", "@push.rocks/smartmongo": "^5.1.0", + "@push.rocks/smartmta": "^5.2.1", "@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpromise": "^4.2.3", - "@push.rocks/smartproxy": "^23.1.0", + "@push.rocks/smartproxy": "^23.1.2", "@push.rocks/smartradius": "^1.1.0", "@push.rocks/smartrequest": "^5.0.1", - "@push.rocks/smartrule": "^2.0.1", "@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartstate": "^2.0.30", "@push.rocks/smartunique": "^3.0.9", "@serve.zone/interfaces": "^5.3.0", "@tsclass/tsclass": "^9.3.0", - "@types/mailparser": "^3.4.6", - "ip": "^2.0.1", - "lru-cache": "^11.2.5", - "mailauth": "^4.12.1", - "mailparser": "^3.9.3", + "lru-cache": "^11.2.6", "uuid": "^13.0.0" }, "keywords": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 306fc82..ee269e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: ^7.0.15 version: 7.0.15(socks@2.8.7) '@push.rocks/smartdns': - specifier: ^7.6.1 - version: 7.6.1 + specifier: ^7.8.0 + version: 7.8.0 '@push.rocks/smartfile': specifier: ^13.1.2 version: 13.1.2 @@ -56,15 +56,15 @@ importers: '@push.rocks/smartlog': specifier: ^3.1.10 version: 3.1.10 - '@push.rocks/smartmail': - specifier: ^2.2.0 - version: 2.2.0 '@push.rocks/smartmetrics': specifier: ^2.0.10 version: 2.0.10 '@push.rocks/smartmongo': specifier: ^5.1.0 version: 5.1.0(socks@2.8.7) + '@push.rocks/smartmta': + specifier: ^5.2.1 + version: 5.2.1 '@push.rocks/smartnetwork': specifier: ^4.4.0 version: 4.4.0 @@ -75,17 +75,14 @@ importers: specifier: ^4.2.3 version: 4.2.3 '@push.rocks/smartproxy': - specifier: ^23.1.0 - version: 23.1.0(socks@2.8.7) + specifier: ^23.1.2 + version: 23.1.2(socks@2.8.7) '@push.rocks/smartradius': specifier: ^1.1.0 version: 1.1.0 '@push.rocks/smartrequest': specifier: ^5.0.1 version: 5.0.1 - '@push.rocks/smartrule': - specifier: ^2.0.1 - version: 2.0.1 '@push.rocks/smartrx': specifier: ^3.0.10 version: 3.0.10 @@ -101,21 +98,9 @@ importers: '@tsclass/tsclass': specifier: ^9.3.0 version: 9.3.0 - '@types/mailparser': - specifier: ^3.4.6 - version: 3.4.6 - ip: - specifier: ^2.0.1 - version: 2.0.1 lru-cache: - specifier: ^11.2.5 - version: 11.2.5 - mailauth: - specifier: ^4.12.1 - version: 4.12.1 - mailparser: - specifier: ^3.9.3 - version: 3.9.3 + specifier: ^11.2.6 + version: 11.2.6 uuid: specifier: ^13.0.0 version: 13.0.0 @@ -133,14 +118,11 @@ importers: specifier: ^3.1.8 version: 3.1.8(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3) '@git.zone/tswatch': - specifier: ^3.0.1 - version: 3.0.1(@tiptap/pm@2.27.2) + specifier: ^3.1.0 + version: 3.1.0(@tiptap/pm@2.27.2) '@types/node': - specifier: ^25.2.0 - version: 25.2.0 - node-forge: - specifier: ^1.3.3 - version: 1.3.3 + specifier: ^25.2.3 + version: 25.2.3 packages: @@ -201,52 +183,52 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-s3@3.980.0': - resolution: {integrity: sha512-ch8QqKehyn1WOYbd8LyDbWjv84Z9OEj9qUxz8q3IOCU3ftAVkVR0wAuN96a1xCHnpOJcQZo3rOB08RlyKdkGxQ==} + '@aws-sdk/client-s3@3.987.0': + resolution: {integrity: sha512-9nLbDIjqdiDkJk8hrAW8jP51bRXjD0+2J3lnCAy+N2G4BDoQuN09+iQF2chF/9BJ/hTk5Ldm2beaO8G2PM1cyw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-sso@3.980.0': - resolution: {integrity: sha512-AhNXQaJ46C1I+lQ+6Kj+L24il5K9lqqIanJd8lMszPmP7bLnmX0wTKK0dxywcvrLdij3zhWttjAKEBNgLtS8/A==} + '@aws-sdk/client-sso@3.985.0': + resolution: {integrity: sha512-81J8iE8MuXhdbMfIz4sWFj64Pe41bFi/uqqmqOC5SlGv+kwoyLsyKS/rH2tW2t5buih4vTUxskRjxlqikTD4oQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.973.5': - resolution: {integrity: sha512-IMM7xGfLGW6lMvubsA4j6BHU5FPgGAxoQ/NA63KqNLMwTS+PeMBcx8DPHL12Vg6yqOZnqok9Mu4H2BdQyq7gSA==} + '@aws-sdk/core@3.973.7': + resolution: {integrity: sha512-wNZZQQNlJ+hzD49cKdo+PY6rsTDElO8yDImnrI69p2PLBa7QomeUKAJWYp9xnaR38nlHqWhMHZuYLCQ3oSX+xg==} engines: {node: '>=20.0.0'} '@aws-sdk/crc64-nvme@3.972.0': resolution: {integrity: sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.3': - resolution: {integrity: sha512-OBYNY4xQPq7Rx+oOhtyuyO0AQvdJSpXRg7JuPNBJH4a1XXIzJQl4UHQTPKZKwfJXmYLpv4+OkcFen4LYmDPd3g==} + '@aws-sdk/credential-provider-env@3.972.5': + resolution: {integrity: sha512-LxJ9PEO4gKPXzkufvIESUysykPIdrV7+Ocb9yAhbhJLE4TiAYqbCVUE+VuKP1leGR1bBfjWjYgSV5MxprlX3mQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.5': - resolution: {integrity: sha512-GpvBgEmSZPvlDekd26Zi+XsI27Qz7y0utUx0g2fSTSiDzhnd1FSa1owuodxR0BcUKNL7U2cOVhhDxgZ4iSoPVg==} + '@aws-sdk/credential-provider-http@3.972.7': + resolution: {integrity: sha512-L2uOGtvp2x3bTcxFTpSM+GkwFIPd8pHfGWO1764icMbo7e5xJh0nfhx1UwkXLnwvocTNEf8A7jISZLYjUSNaTg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.3': - resolution: {integrity: sha512-rMQAIxstP7cLgYfsRGrGOlpyMl0l8JL2mcke3dsIPLWke05zKOFyR7yoJzWCsI/QiIxjRbxpvPiAeKEA6CoYkg==} + '@aws-sdk/credential-provider-ini@3.972.5': + resolution: {integrity: sha512-SdDTYE6jkARzOeL7+kudMIM4DaFnP5dZVeatzw849k4bSXDdErDS188bgeNzc/RA2WGrlEpsqHUKP6G7sVXhZg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.3': - resolution: {integrity: sha512-Gc3O91iVvA47kp2CLIXOwuo5ffo1cIpmmyIewcYjAcvurdFHQ8YdcBe1KHidnbbBO4/ZtywGBACsAX5vr3UdoA==} + '@aws-sdk/credential-provider-login@3.972.5': + resolution: {integrity: sha512-uYq1ILyTSI6ZDCMY5+vUsRM0SOCVI7kaW4wBrehVVkhAxC6y+e9rvGtnoZqCOWL1gKjTMouvsf4Ilhc5NCg1Aw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.4': - resolution: {integrity: sha512-UwerdzosMSY7V5oIZm3NsMDZPv2aSVzSkZxYxIOWHBeKTZlUqW7XpHtJMZ4PZpJ+HMRhgP+MDGQx4THndgqJfQ==} + '@aws-sdk/credential-provider-node@3.972.6': + resolution: {integrity: sha512-DZ3CnAAtSVtVz+G+ogqecaErMLgzph4JH5nYbHoBMgBkwTUV+SUcjsjOJwdBJTHu3Dm6l5LBYekZoU2nDqQk2A==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.3': - resolution: {integrity: sha512-xkSY7zjRqeVc6TXK2xr3z1bTLm0wD8cj3lAkproRGaO4Ku7dPlKy843YKnHrUOUzOnMezdZ4xtmFc0eKIDTo2w==} + '@aws-sdk/credential-provider-process@3.972.5': + resolution: {integrity: sha512-HDKF3mVbLnuqGg6dMnzBf1VUOywE12/N286msI9YaK9mEIzdsGCtLTvrDhe3Up0R9/hGFbB+9l21/TwF5L1C6g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.3': - resolution: {integrity: sha512-8Ww3F5Ngk8dZ6JPL/V5LhCU1BwMfQd3tLdoEuzaewX8FdnT633tPr+KTHySz9FK7fFPcz5qG3R5edVEhWQD4AA==} + '@aws-sdk/credential-provider-sso@3.972.5': + resolution: {integrity: sha512-8urj3AoeNeQisjMmMBhFeiY2gxt6/7wQQbEGun0YV/OaOOiXrIudTIEYF8ZfD+NQI6X1FY5AkRsx6O/CaGiybA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.3': - resolution: {integrity: sha512-62VufdcH5rRfiRKZRcf1wVbbt/1jAntMj1+J0qAd+r5pQRg2t0/P9/Rz16B1o5/0Se9lVL506LRjrhIJAhYBfA==} + '@aws-sdk/credential-provider-web-identity@3.972.5': + resolution: {integrity: sha512-OK3cULuJl6c+RcDZfPpaK5o3deTOnKZbxm7pzhFNGA3fI2hF9yDih17fGRazJzGGWaDVlR9ejZrpDef4DJCEsw==} engines: {node: '>=20.0.0'} '@aws-sdk/middleware-bucket-endpoint@3.972.3': @@ -257,8 +239,8 @@ packages: resolution: {integrity: sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.972.3': - resolution: {integrity: sha512-MkNGJ6qB9kpsLwL18kC/ZXppsJbftHVGCisqpEVbTQsum8CLYDX1Bmp/IvhRGNxsqCO2w9/4PwhDKBjG3Uvr4Q==} + '@aws-sdk/middleware-flexible-checksums@3.972.5': + resolution: {integrity: sha512-SF/1MYWx67OyCrLA4icIpWUfCkdlOi8Y1KecQ9xYxkL10GMjVdPTGPnYhAg0dw5U43Y9PVUWhAV2ezOaG+0BLg==} engines: {node: '>=20.0.0'} '@aws-sdk/middleware-host-header@3.972.3': @@ -277,32 +259,32 @@ packages: resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.5': - resolution: {integrity: sha512-3IgeIDiQ15tmMBFIdJ1cTy3A9rXHGo+b9p22V38vA3MozeMyVC8VmCYdDLA0iMWo4VHA9LDJTgCM0+xU3wjBOg==} + '@aws-sdk/middleware-sdk-s3@3.972.7': + resolution: {integrity: sha512-VtZ7tMIw18VzjG+I6D6rh2eLkJfTtByiFoCIauGDtTTPBEUMQUiGaJ/zZrPlCY6BsvLLeFKz3+E5mntgiOWmIg==} engines: {node: '>=20.0.0'} '@aws-sdk/middleware-ssec@3.972.3': resolution: {integrity: sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.972.5': - resolution: {integrity: sha512-TVZQ6PWPwQbahUI8V+Er+gS41ctIawcI/uMNmQtQ7RMcg3JYn6gyKAFKUb3HFYx2OjYlx1u11sETSwwEUxVHTg==} + '@aws-sdk/middleware-user-agent@3.972.7': + resolution: {integrity: sha512-HUD+geASjXSCyL/DHPQc/Ua7JhldTcIglVAoCV8kiVm99IaFSlAbTvEnyhZwdE6bdFyTL+uIaWLaCFSRsglZBQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.980.0': - resolution: {integrity: sha512-/dONY5xc5/CCKzOqHZCTidtAR4lJXWkGefXvTRKdSKMGaYbbKsxDckisd6GfnvPSLxWtvQzwgRGRutMRoYUApQ==} + '@aws-sdk/nested-clients@3.985.0': + resolution: {integrity: sha512-TsWwKzb/2WHafAY0CE7uXgLj0FmnkBTgfioG9HO+7z/zCPcl1+YU+i7dW4o0y+aFxFgxTMG+ExBQpqT/k2ao8g==} engines: {node: '>=20.0.0'} '@aws-sdk/region-config-resolver@3.972.3': resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.980.0': - resolution: {integrity: sha512-tO2jBj+ZIVM0nEgi1SyxWtaYGpuAJdsrugmWcI3/U2MPWCYsrvKasUo0026NvJJao38wyUq9B8XTG8Xu53j/VA==} + '@aws-sdk/signature-v4-multi-region@3.987.0': + resolution: {integrity: sha512-5kVC6x6+2NO+/NIXWJwN68+8cvqREsoE+tFOMyZWj2fg3EWzCnTGVIFd7hSJZJT2WiP5LqcrdEoFyXtfDta1hg==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.980.0': - resolution: {integrity: sha512-1nFileg1wAgDmieRoj9dOawgr2hhlh7xdvcH57b1NnqfPaVlcqVJyPc6k3TLDUFPY69eEwNxdGue/0wIz58vjA==} + '@aws-sdk/token-providers@3.985.0': + resolution: {integrity: sha512-+hwpHZyEq8k+9JL2PkE60V93v2kNhUIv7STFt+EAez1UJsJOQDhc5LpzEX66pNjclI5OTwBROs/DhJjC/BtMjQ==} engines: {node: '>=20.0.0'} '@aws-sdk/types@3.973.1': @@ -313,8 +295,12 @@ packages: resolution: {integrity: sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-endpoints@3.980.0': - resolution: {integrity: sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw==} + '@aws-sdk/util-endpoints@3.985.0': + resolution: {integrity: sha512-vth7UfGSUR3ljvaq8V4Rc62FsM7GUTH/myxPWkaEgOrprz1/Pc72EgTXxj+cPPPDAfHFIpjhkB7T7Td0RJx+BA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.987.0': + resolution: {integrity: sha512-rZnZwDq7Pn+TnL0nyS6ryAhpqTZtLtHbJaqfxuHlDX3v/bq0M7Ch/V3qF9dZWaGgsJ2H9xn7/vFOxlnL4fBMcQ==} engines: {node: '>=20.0.0'} '@aws-sdk/util-locate-window@3.965.4': @@ -324,8 +310,8 @@ packages: '@aws-sdk/util-user-agent-browser@3.972.3': resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} - '@aws-sdk/util-user-agent-node@3.972.3': - resolution: {integrity: sha512-gqG+02/lXQtO0j3US6EVnxtwwoXQC5l2qkhLCrqUrqdtcQxV7FDMbm9wLjKqoronSHyELGTjbFKK/xV5q1bZNA==} + '@aws-sdk/util-user-agent-node@3.972.5': + resolution: {integrity: sha512-GsUDF+rXyxDZkkJxUsDxnA67FG+kc5W1dnloCFLl6fWzceevsCYzJpASBzT+BPjwUgREE6FngfJYYYMQUY5fZQ==} engines: {node: '>=20.0.0'} peerDependencies: aws-crt: '>=1.0.0' @@ -333,8 +319,8 @@ packages: aws-crt: optional: true - '@aws-sdk/xml-builder@3.972.2': - resolution: {integrity: sha512-jGOOV/bV1DhkkUhHiZ3/1GZ67cZyOXaDb7d1rYD6ZiXf5V9tBNOcgqXwRRPvrCbYaFRa1pPMFb3ZjqjWpR3YfA==} + '@aws-sdk/xml-builder@3.972.4': + resolution: {integrity: sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.2.3': @@ -359,8 +345,8 @@ packages: '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} - '@cloudflare/workers-types@4.20260131.0': - resolution: {integrity: sha512-ELgvb2mp68Al50p+FmpgCO2hgU5o4tmz8pi7kShN+cRXc0UZoEdxpDIikR0CeT7b3tV7wlnEnsUzd0UoJLS0oQ==} + '@cloudflare/workers-types@4.20260210.0': + resolution: {integrity: sha512-zHaF0RZVYUQwNCJCECnNAJdMur72Lk3FMiD6wU78Dx3Bv7DQRcuXNmPNuJmsGnosVZCcWintHlPTQ/4BEiDG5w==} '@configvault.io/interfaces@1.0.17': resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==} @@ -389,180 +375,180 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@esbuild/aix-ppc64@0.27.2': - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@fortawesome/fontawesome-common-types@7.1.0': - resolution: {integrity: sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==} + '@fortawesome/fontawesome-common-types@7.2.0': + resolution: {integrity: sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==} engines: {node: '>=6'} - '@fortawesome/fontawesome-svg-core@7.1.0': - resolution: {integrity: sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==} + '@fortawesome/fontawesome-svg-core@7.2.0': + resolution: {integrity: sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==} engines: {node: '>=6'} - '@fortawesome/free-brands-svg-icons@7.1.0': - resolution: {integrity: sha512-9byUd9bgNfthsZAjBl6GxOu1VPHgBuRUP9juI7ZoM98h8xNPTCTagfwUFyYscdZq4Hr7gD1azMfM9s5tIWKZZA==} + '@fortawesome/free-brands-svg-icons@7.2.0': + resolution: {integrity: sha512-VNG8xqOip1JuJcC3zsVsKRQ60oXG9+oYNDCosjoU/H9pgYmLTEwWw8pE0jhPz/JWdHeUuK6+NQ3qsM4gIbdbYQ==} engines: {node: '>=6'} - '@fortawesome/free-regular-svg-icons@7.1.0': - resolution: {integrity: sha512-0e2fdEyB4AR+e6kU4yxwA/MonnYcw/CsMEP9lH82ORFi9svA6/RhDyhxIv5mlJaldmaHLLYVTb+3iEr+PDSZuQ==} + '@fortawesome/free-regular-svg-icons@7.2.0': + resolution: {integrity: sha512-iycmlN51EULlQ4D/UU9WZnHiN0CvjJ2TuuCrAh+1MVdzD+4ViKYH2deNAll4XAAYlZa8WAefHR5taSK8hYmSMw==} engines: {node: '>=6'} - '@fortawesome/free-solid-svg-icons@7.1.0': - resolution: {integrity: sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==} + '@fortawesome/free-solid-svg-icons@7.2.0': + resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==} engines: {node: '>=6'} '@git.zone/tsbuild@4.1.2': @@ -585,30 +571,10 @@ packages: resolution: {integrity: sha512-nmiLGeOkKMkLDyIk5BUBLx5ExskFbKHKlPdrWCARPVFkU4cAAiuIyJWVfLwISoS0TO/zSInLqArPwIc76yvaNw==} hasBin: true - '@git.zone/tswatch@3.0.1': - resolution: {integrity: sha512-vrAkKM5ff/e1BLNkrIRXnTIkMyjl/uW49c1cYaw2nYGloM6/wT1FSwYjwh6BcDkHIYMnzS30SOy9jSYRptW/iw==} + '@git.zone/tswatch@3.1.0': + resolution: {integrity: sha512-R2ZI+j1OKVgd0zTbtGtJjyt7r2kF0Z4nl8neolHuQL+jpr16i2NHVfVK92uIeeZDnJSqo5vf7Syt0XeQ4rz2HA==} hasBin: true - '@hapi/address@5.1.1': - resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} - engines: {node: '>=14.0.0'} - - '@hapi/formula@3.0.2': - resolution: {integrity: sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==} - - '@hapi/hoek@11.0.7': - resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} - - '@hapi/pinpoint@2.0.1': - resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} - - '@hapi/tlds@1.1.4': - resolution: {integrity: sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA==} - engines: {node: '>=14.0.0'} - - '@hapi/topo@6.0.2': - resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} - '@happy-dom/global-registrator@15.11.7': resolution: {integrity: sha512-mfOoUlIw8VBiJYPrl5RZfMzkXC/z7gbSpi2ecycrj/gRWLq2CMV+Q+0G+JPjeOmuNFgg0skEIzkVFzVYFP6URw==} engines: {node: '>=18.0.0'} @@ -673,10 +639,6 @@ packages: resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} - engines: {node: 20 || >=22} - '@isaacs/brace-expansion@5.0.1': resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} engines: {node: 20 || >=22} @@ -685,6 +647,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} @@ -715,77 +681,77 @@ packages: '@module-federation/webpack-bundler-runtime@0.22.0': resolution: {integrity: sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==} - '@mongodb-js/saslprep@1.4.5': - resolution: {integrity: sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==} + '@mongodb-js/saslprep@1.4.6': + resolution: {integrity: sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==} - '@napi-rs/canvas-android-arm64@0.1.89': - resolution: {integrity: sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ==} + '@napi-rs/canvas-android-arm64@0.1.91': + resolution: {integrity: sha512-SLLzXXgSnfct4zy/BVAfweZQkYkPJsNsJ2e5DOE8DFEHC6PufyUrwb12yqeu2So2IOIDpWJJaDAxKY/xpy6MYQ==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@napi-rs/canvas-darwin-arm64@0.1.89': - resolution: {integrity: sha512-k29cR/Zl20WLYM7M8YePevRu2VQRaKcRedYr1V/8FFHkyIQ8kShEV+MPoPGi+znvmd17Eqjy2Pk2F2kpM2umVg==} + '@napi-rs/canvas-darwin-arm64@0.1.91': + resolution: {integrity: sha512-bzdbCjIjw3iRuVFL+uxdSoMra/l09ydGNX9gsBxO/zg+5nlppscIpj6gg+nL6VNG85zwUarDleIrUJ+FWHvmuA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@napi-rs/canvas-darwin-x64@0.1.89': - resolution: {integrity: sha512-iUragqhBrA5FqU13pkhYBDbUD1WEAIlT8R2+fj6xHICY2nemzwMUI8OENDhRh7zuL06YDcRwENbjAVxOmaX9jg==} + '@napi-rs/canvas-darwin-x64@0.1.91': + resolution: {integrity: sha512-q3qpkpw0IsG9fAS/dmcGIhCVoNxj8ojbexZKWwz3HwxlEWsLncEQRl4arnxrwbpLc2nTNTyj4WwDn7QR5NDAaA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.89': - resolution: {integrity: sha512-y3SM9sfDWasY58ftoaI09YBFm35Ig8tosZqgahLJ2WGqawCusGNPV9P0/4PsrLOCZqGg629WxexQMY25n7zcvA==} + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.91': + resolution: {integrity: sha512-Io3g8wJZVhK8G+Fpg1363BE90pIPqg+ZbeehYNxPWDSzbgwU3xV0l8r/JBzODwC7XHi1RpFEk+xyUTMa2POj6w==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@napi-rs/canvas-linux-arm64-gnu@0.1.89': - resolution: {integrity: sha512-NEoF9y8xq5fX8HG8aZunBom1ILdTwt7ayBzSBIwrmitk7snj4W6Fz/yN/ZOmlM1iyzHDNX5Xn0n+VgWCF8BEdA==} + '@napi-rs/canvas-linux-arm64-gnu@0.1.91': + resolution: {integrity: sha512-HBnto+0rxx1bQSl8bCWA9PyBKtlk2z/AI32r3cu4kcNO+M/5SD4b0v1MWBWZyqMQyxFjWgy3ECyDjDKMC6tY1A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@napi-rs/canvas-linux-arm64-musl@0.1.89': - resolution: {integrity: sha512-UQQkIEzV12/l60j1ziMjZ+mtodICNUbrd205uAhbyTw0t60CrC/EsKb5/aJWGq1wM0agvcgZV72JJCKfLS6+4w==} + '@napi-rs/canvas-linux-arm64-musl@0.1.91': + resolution: {integrity: sha512-/eJtVe2Xw9A86I4kwXpxxoNagdGclu12/NSMsfoL8q05QmeRCbfjhg1PJS7ENAuAvaiUiALGrbVfeY1KU1gztQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@napi-rs/canvas-linux-riscv64-gnu@0.1.89': - resolution: {integrity: sha512-1/VmEoFaIO6ONeeEMGoWF17wOYZOl5hxDC1ios2Bkz/oQjbJJ8DY/X22vWTmvuUKWWhBVlo63pxLGZbjJU/heA==} + '@napi-rs/canvas-linux-riscv64-gnu@0.1.91': + resolution: {integrity: sha512-floNK9wQuRWevUhhXRcuis7h0zirdytVxPgkonWO+kQlbvxV7gEUHGUFQyq4n55UHYFwgck1SAfJ1HuXv/+ppQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - '@napi-rs/canvas-linux-x64-gnu@0.1.89': - resolution: {integrity: sha512-ebLuqkCuaPIkKgKH9q4+pqWi1tkPOfiTk5PM1LKR1tB9iO9sFNVSIgwEp+SJreTSbA2DK5rW8lQXiN78SjtcvA==} + '@napi-rs/canvas-linux-x64-gnu@0.1.91': + resolution: {integrity: sha512-c3YDqBdf7KETuZy2AxsHFMsBBX1dWT43yFfWUq+j1IELdgesWtxf/6N7csi3VPf6VA3PmnT9EhMyb+M1wfGtqw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@napi-rs/canvas-linux-x64-musl@0.1.89': - resolution: {integrity: sha512-w+5qxHzplvA4BkHhCaizNMLLXiI+CfP84YhpHm/PqMub4u8J0uOAv+aaGv40rYEYra5hHRWr9LUd6cfW32o9/A==} + '@napi-rs/canvas-linux-x64-musl@0.1.91': + resolution: {integrity: sha512-RpZ3RPIwgEcNBHSHSX98adm+4VP8SMT5FN6250s5jQbWpX/XNUX5aLMfAVJS/YnDjS1QlsCgQxFOPU0aCCWgag==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@napi-rs/canvas-win32-arm64-msvc@0.1.89': - resolution: {integrity: sha512-DmyXa5lJHcjOsDC78BM3bnEECqbK3xASVMrKfvtT/7S7Z8NGQOugvu+L7b41V6cexCd34mBWgMOsjoEBceeB1Q==} + '@napi-rs/canvas-win32-arm64-msvc@0.1.91': + resolution: {integrity: sha512-gF8MBp4X134AgVurxqlCdDA2qO0WaDdi9o6Sd5rWRVXRhWhYQ6wkdEzXNLIrmmros0Tsp2J0hQzx4ej/9O8trQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@napi-rs/canvas-win32-x64-msvc@0.1.89': - resolution: {integrity: sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA==} + '@napi-rs/canvas-win32-x64-msvc@0.1.91': + resolution: {integrity: sha512-++gtW9EV/neKI8TshD8WFxzBYALSPag2kFRahIJV+LYsyt5kBn21b1dBhEUDHf7O+wiZmuFCeUa7QKGHnYRZBA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@napi-rs/canvas@0.1.89': - resolution: {integrity: sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg==} + '@napi-rs/canvas@0.1.91': + resolution: {integrity: sha512-eeIe1GoB74P1B0Nkw6pV8BCQ3hfCfvyYr4BntzlCsnFXzVJiPMDnLeIx3gVB0xQMblHYnjK/0nCLvirEhOjr5g==} engines: {node: '>= 10'} '@napi-rs/wasm-runtime@1.0.7': @@ -828,21 +794,12 @@ packages: '@peculiar/asn1-rsa@2.6.0': resolution: {integrity: sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==} - '@peculiar/asn1-schema@2.3.15': - resolution: {integrity: sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w==} - '@peculiar/asn1-schema@2.6.0': resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==} '@peculiar/asn1-x509-attr@2.6.0': resolution: {integrity: sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==} - '@peculiar/asn1-x509-logotype@2.3.15': - resolution: {integrity: sha512-b01fyuT9r08W5XyPN1qyYO6O82ERbxbdJizd0XIuwQ4C8MGb2phKF0Lvhd9+9y1TuH9ijuYcnu85IabJlyiQOg==} - - '@peculiar/asn1-x509@2.3.15': - resolution: {integrity: sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg==} - '@peculiar/asn1-x509@2.6.0': resolution: {integrity: sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==} @@ -866,11 +823,8 @@ packages: resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} engines: {node: '>=12'} - '@postalsys/vmc@1.1.2': - resolution: {integrity: sha512-yjJ4XMVXMP4q7QgfyncVC042+ev2yJFnVBuVZmFubzM92NAu2gzJjWdpjR3sCw35eaxVGPE9pmEoyN6sffCqYw==} - - '@puppeteer/browsers@2.11.2': - resolution: {integrity: sha512-GBY0+2lI9fDrjgb5dFL9+enKXqyOPok9PXg/69NVkjW3bikbK9RQrNrI3qccQXmDNN7ln4j/yL89Qgvj/tfqrw==} + '@puppeteer/browsers@2.12.0': + resolution: {integrity: sha512-Xuq42yxcQJ54ti8ZHNzF5snFvtpgXzNToJ1bXUGQRaiO8t+B6UM8sTUJfvV+AJnqtkJU/7hdy6nbKyA12aHtRw==} engines: {node: '>=18'} hasBin: true @@ -950,8 +904,8 @@ packages: '@push.rocks/smartdns@6.2.2': resolution: {integrity: sha512-MhJcHujbyIuwIIFdnXb2OScGtRjNsliLUS8GoAurFsKtcCOaA0ytfP+PNzkukyBufjb1nMiJF3rjhswXdHakAQ==} - '@push.rocks/smartdns@7.6.1': - resolution: {integrity: sha512-nnP5+A2GOt0WsHrYhtKERmjdEHUchc+QbCCBEqlyeQTn+mNfx2WZvKVI1DFRJt8lamvzxP6Hr/BSe3WHdh4Snw==} + '@push.rocks/smartdns@7.8.0': + resolution: {integrity: sha512-5FX74AAgQSqWPZkpTsI/BbUKBQpZKSvs+UdX9IZpwcuPldI+K7D1WeE02mMAGd1Ncd/sYAMor5CTlhnG6L+QhQ==} '@push.rocks/smartenv@5.0.13': resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==} @@ -1046,6 +1000,13 @@ packages: '@push.rocks/smartmongo@5.1.0': resolution: {integrity: sha512-2tpKf8K+SMdLHOEpafgKPIN+ypWTLwHc33hCUDNMQ1KaL7vokkavA44+fHxQydOGPMtDi22tSMFeVMCcUSzs4w==} + '@push.rocks/smartmta@5.2.1': + resolution: {integrity: sha512-ITgu1kIJxWgiU6q3YDxAp1HoMmC8ECJhEAFbDtUDRIBcg8Flvbmgasjnqew67nFcXq2fKYh3rGECloS62MBQgw==} + engines: {node: '>=14.0.0'} + cpu: [x64, arm64] + os: [darwin, linux, win32] + hasBin: true + '@push.rocks/smartmustache@3.0.2': resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==} @@ -1079,8 +1040,8 @@ packages: '@push.rocks/smartpromise@4.2.3': resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} - '@push.rocks/smartproxy@23.1.0': - resolution: {integrity: sha512-2EhMFeQytDwnqooK9BNkLw9oz8M1LUFuMEg6271xRnwf8gUkDq5WT0brrmLdOmpqkU/3h/wDeZUrn65zq3VAcA==} + '@push.rocks/smartproxy@23.1.2': + resolution: {integrity: sha512-4uOSPp4ymIBLhn0xocmY+6wPWlEBIB//vaOIPM9wTyoyhWdhMSV2J1V7NcXGNAGiZG9OO4zB1yW3pbs/4Wc2NA==} '@push.rocks/smartpuppeteer@2.0.5': resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==} @@ -1100,11 +1061,8 @@ packages: '@push.rocks/smartrouter@1.3.3': resolution: {integrity: sha512-1+xZEnWlhzqLWAaJ1zFNhQ0zgbfCWQl1DBT72LygLxTs+P0K8AwJKgqo/IX6CT55kGCFnPAZIYSbVJlGsgrB0w==} - '@push.rocks/smartrule@2.0.1': - resolution: {integrity: sha512-8oYEnS9z+NgCAcUtXPMguYyZpHqA/ROp0bxVQwUaHDwa3YzzA8jHIXvA94hk3sxvkk0xmIpp4UhBEelzIwwJow==} - - '@push.rocks/smartrust@1.1.1': - resolution: {integrity: sha512-NtfTOrVpw0K+z/jW24OmunvZBqkJHfe1tJhTMPFYUb4a5Yt5mtTc3oUvlX+bHarn94Jq0oh0HCLh8xcPQ2Sd7w==} + '@push.rocks/smartrust@1.2.0': + resolution: {integrity: sha512-JlaALselIHoP6C3ceQbrvz424G21cND/QsH/KI3E/JrO4XphJiGZwM6f4yJWrijdPYR/YYMoaIiYN7ybZp0C4w==} '@push.rocks/smartrx@3.0.10': resolution: {integrity: sha512-USjIYcsSfzn14cwOsxgq/bBmWDTTzy3ouWAnW5NdMyRRzEbmeNrvmy6TRqNeDlJ2PsYNTt1rr/zGUqvIy72ITg==} @@ -1306,60 +1264,60 @@ packages: '@rolldown/pluginutils@1.0.0-beta.52': resolution: {integrity: sha512-/L0htLJZbaZFL1g9OHOblTxbCYIGefErJjtYOwgl9ZqNx27P3L0SDfjhhHIss32gu5NWgnxuT2a2Hnnv6QGHKA==} - '@rspack/binding-darwin-arm64@1.7.4': - resolution: {integrity: sha512-d4FTW/TkqvU9R1PsaK2tbLG1uY0gAlxy3rEiQYrFRAOVTMOFkPasypmvhwD5iWrPIhkjIi79IkgrSzRJaP2ZwA==} + '@rspack/binding-darwin-arm64@1.7.6': + resolution: {integrity: sha512-NZ9AWtB1COLUX1tA9HQQvWpTy07NSFfKBU8A6ylWd5KH8AePZztpNgLLAVPTuNO4CZXYpwcoclf8jG/luJcQdQ==} cpu: [arm64] os: [darwin] - '@rspack/binding-darwin-x64@1.7.4': - resolution: {integrity: sha512-Oq65S5szs3+In9hVWfPksdL6EUu1+SFZK3oQINP3kMJ5zPzrdyiue+L5ClpTU/VMKVxfQTdCBsI6OVJNnaLBiA==} + '@rspack/binding-darwin-x64@1.7.6': + resolution: {integrity: sha512-J2g6xk8ZS7uc024dNTGTHxoFzFovAZIRixUG7PiciLKTMP78svbSSWrmW6N8oAsAkzYfJWwQpVgWfFNRHvYxSw==} cpu: [x64] os: [darwin] - '@rspack/binding-linux-arm64-gnu@1.7.4': - resolution: {integrity: sha512-sTpfCraAtYZBhdw9Xx5a19OgJ/mBELTi61utZzrO3bV6BFEulvOdmnNjpgb0xv1KATtNI8YxECohUzekk1WsOA==} + '@rspack/binding-linux-arm64-gnu@1.7.6': + resolution: {integrity: sha512-eQfcsaxhFrv5FmtaA7+O1F9/2yFDNIoPZzV/ZvqvFz5bBXVc4FAm/1fVpBg8Po/kX1h0chBc7Xkpry3cabFW8w==} cpu: [arm64] os: [linux] - '@rspack/binding-linux-arm64-musl@1.7.4': - resolution: {integrity: sha512-sw8jZbUe13Ry0/tnUt1pSdwkaPtSzKuveq+b6/CUT26I3DKfJQoG0uJbjj2quMe4ks3jDmoGlxuRe4D/fWUoSg==} + '@rspack/binding-linux-arm64-musl@1.7.6': + resolution: {integrity: sha512-DfQXKiyPIl7i1yECHy4eAkSmlUzzsSAbOjgMuKn7pudsWf483jg0UUYutNgXSlBjc/QSUp7906Cg8oty9OfwPA==} cpu: [arm64] os: [linux] - '@rspack/binding-linux-x64-gnu@1.7.4': - resolution: {integrity: sha512-1W6LU0wR/TxB+8pogt0pn0WRwbQmKfu9839p/VBuSkNdWR4aljAhYO6RxsLQLCLrDAqEyrpeYWsWJBvAJ4T/pA==} + '@rspack/binding-linux-x64-gnu@1.7.6': + resolution: {integrity: sha512-NdA+2X3lk2GGrMMnTGyYTzM3pn+zNjaqXqlgKmFBXvjfZqzSsKq3pdD1KHZCd5QHN+Fwvoszj0JFsquEVhE1og==} cpu: [x64] os: [linux] - '@rspack/binding-linux-x64-musl@1.7.4': - resolution: {integrity: sha512-rkmu8qLnm/q8J14ZQZ04SnPNzdRNgzAoKJCTbnhCzcuL5k5e20LUFfGuS6j7Io1/UdVMOjz/u7R6b9h/qA1Scw==} + '@rspack/binding-linux-x64-musl@1.7.6': + resolution: {integrity: sha512-rEy6MHKob02t/77YNgr6dREyJ0e0tv1X6Xsg8Z5E7rPXead06zefUbfazj4RELYySWnM38ovZyJAkPx/gOn3VA==} cpu: [x64] os: [linux] - '@rspack/binding-wasm32-wasi@1.7.4': - resolution: {integrity: sha512-6BQvLbDtUVkTN5o1QYLYKAYuXavC4ER5Vn/amJEoecbM9F25MNAv28inrXs7BQ4cHSU4WW/F4yZPGnA+jUZLyw==} + '@rspack/binding-wasm32-wasi@1.7.6': + resolution: {integrity: sha512-YupOrz0daSG+YBbCIgpDgzfMM38YpChv+afZpaxx5Ml7xPeAZIIdgWmLHnQ2rts73N2M1NspAiBwV00Xx0N4Vg==} cpu: [wasm32] - '@rspack/binding-win32-arm64-msvc@1.7.4': - resolution: {integrity: sha512-kipggu7xVPhnAkAV7koSDVbBuuMDMA4hX60DNJKTS6fId3XNHcZqWKIsWGOt0yQ6KV7I3JRRBDotKLx6uYaRWw==} + '@rspack/binding-win32-arm64-msvc@1.7.6': + resolution: {integrity: sha512-INj7aVXjBvlZ84kEhSK4kJ484ub0i+BzgnjDWOWM1K+eFYDZjLdAsQSS3fGGXwVc3qKbPIssFfnftATDMTEJHQ==} cpu: [arm64] os: [win32] - '@rspack/binding-win32-ia32-msvc@1.7.4': - resolution: {integrity: sha512-9Zdozc13AUQHqagDDHxHml1FnZZWuSj/uP+SxtlTlQaiIE9GDH3n0cUio1GUq+cBKbcXeiE3dJMGJxhiFaUsxA==} + '@rspack/binding-win32-ia32-msvc@1.7.6': + resolution: {integrity: sha512-lXGvC+z67UMcw58In12h8zCa9IyYRmuptUBMItQJzu+M278aMuD1nETyGLL7e4+OZ2lvrnnBIcjXN1hfw2yRzw==} cpu: [ia32] os: [win32] - '@rspack/binding-win32-x64-msvc@1.7.4': - resolution: {integrity: sha512-3a/jZTUrvU340IuRcxul+ccsDtdrMaGq/vi4HNcWalL0H2xeOeuieBAV8AZqaRjmxMu8OyRcpcSrkHtN1ol/eA==} + '@rspack/binding-win32-x64-msvc@1.7.6': + resolution: {integrity: sha512-zeUxEc0ZaPpmaYlCeWcjSJUPuRRySiSHN23oJ2Xyw0jsQ01Qm4OScPdr0RhEOFuK/UE+ANyRtDo4zJsY52Hadw==} cpu: [x64] os: [win32] - '@rspack/binding@1.7.4': - resolution: {integrity: sha512-BOACDXd9aTrdJgqa88KGxnTGdUdVLAClTCLhSvdNvQZIcaVLOB1qtW0TvqjZ19MxuQB/Cba5u/ILc5DNXxuDhg==} + '@rspack/binding@1.7.6': + resolution: {integrity: sha512-/NrEcfo8Gx22hLGysanrV6gHMuqZSxToSci/3M4kzEQtF5cPjfOv5pqeLK/+B6cr56ul/OmE96cCdWcXeVnFjQ==} - '@rspack/core@1.7.4': - resolution: {integrity: sha512-6QNqcsRSy1WbAGvjA2DAEx4yyAzwrvT6vd24Kv4xdZHdvF6FmcUbr5J+mLJ1jSOXvpNhZ+RzN37JQ8fSmytEtw==} + '@rspack/core@1.7.6': + resolution: {integrity: sha512-Iax6UhrfZqJajA778c1d5DBFbSIqPOSrI34kpNIiNpWd8Jq7mFIa+Z60SQb5ZQDZuUxcCZikjz5BxinFjTkg7Q==} engines: {node: '>=18.12.0'} peerDependencies: '@swc/helpers': '>=0.5.1' @@ -1399,8 +1357,8 @@ packages: resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} engines: {node: '>=18.0.0'} - '@smithy/core@3.22.0': - resolution: {integrity: sha512-6vjCHD6vaY8KubeNw2Fg3EK0KLGQYdldG4fYgQmA0xSW0dJ8G2xFhSOdrlUakWVoP5JuWHtFODg3PNd/DN3FDA==} + '@smithy/core@3.23.0': + resolution: {integrity: sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==} engines: {node: '>=18.0.0'} '@smithy/credential-provider-imds@4.2.8': @@ -1463,12 +1421,12 @@ packages: resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.12': - resolution: {integrity: sha512-9JMKHVJtW9RysTNjcBZQHDwB0p3iTP6B1IfQV4m+uCevkVd/VuLgwfqk5cnI4RHcp4cPwoIvxQqN4B1sxeHo8Q==} + '@smithy/middleware-endpoint@4.4.14': + resolution: {integrity: sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.29': - resolution: {integrity: sha512-bmTn75a4tmKRkC5w61yYQLb3DmxNzB8qSVu9SbTYqW6GAL0WXO2bDZuMAn/GJSbOdHEdjZvWxe+9Kk015bw6Cg==} + '@smithy/middleware-retry@4.4.31': + resolution: {integrity: sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==} engines: {node: '>=18.0.0'} '@smithy/middleware-serde@4.2.9': @@ -1483,8 +1441,8 @@ packages: resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.4.8': - resolution: {integrity: sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==} + '@smithy/node-http-handler@4.4.10': + resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} engines: {node: '>=18.0.0'} '@smithy/property-provider@4.2.8': @@ -1515,8 +1473,8 @@ packages: resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.11.1': - resolution: {integrity: sha512-SERgNg5Z1U+jfR6/2xPYjSEHY1t3pyTHC/Ma3YQl6qWtmiL42bvNId3W/oMUWIwu7ekL2FMPdqAmwbQegM7HeQ==} + '@smithy/smithy-client@4.11.3': + resolution: {integrity: sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==} engines: {node: '>=18.0.0'} '@smithy/types@4.12.0': @@ -1551,12 +1509,12 @@ packages: resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.28': - resolution: {integrity: sha512-/9zcatsCao9h6g18p/9vH9NIi5PSqhCkxQ/tb7pMgRFnqYp9XUOyOlGPDMHzr8n5ih6yYgwJEY2MLEobUgi47w==} + '@smithy/util-defaults-mode-browser@4.3.30': + resolution: {integrity: sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.31': - resolution: {integrity: sha512-JTvoApUXA5kbpceI2vuqQzRjeTbLpx1eoa5R/YEZbTgtxvIB7AQZxFJ0SEyfCpgPCyVV9IT7we+ytSeIB3CyWA==} + '@smithy/util-defaults-mode-node@4.2.33': + resolution: {integrity: sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==} engines: {node: '>=18.0.0'} '@smithy/util-endpoints@3.2.8': @@ -1575,8 +1533,8 @@ packages: resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.10': - resolution: {integrity: sha512-jbqemy51UFSZSp2y0ZmRfckmrzuKww95zT9BYMmuJ8v3altGcqjwoV1tzpOwuHaKrwQrCjIzOib499ymr2f98g==} + '@smithy/util-stream@4.5.12': + resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} engines: {node: '>=18.0.0'} '@smithy/util-uri-escape@4.2.0': @@ -1602,9 +1560,6 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@svgdotjs/svg.draggable.js@3.0.6': resolution: {integrity: sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==} peerDependencies: @@ -1877,9 +1832,6 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} - '@types/mailparser@3.4.6': - resolution: {integrity: sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==} - '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} @@ -1914,11 +1866,11 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@22.19.7': - resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} + '@types/node@22.19.11': + resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} - '@types/node@25.2.0': - resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} + '@types/node@25.2.3': + resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} '@types/pidusage@2.0.5': resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==} @@ -2072,8 +2024,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha1-x57Zf380y48robyXkLzDZkdLS3k=} - axios@1.13.4: - resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} + axios@1.13.5: + resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} b4a@1.7.3: resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} @@ -2148,8 +2100,8 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} - bowser@2.13.1: - resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2167,8 +2119,8 @@ packages: resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} engines: {node: '>=16.20.1'} - bson@7.1.1: - resolution: {integrity: sha512-TtJgBB+QyOlWjrbM+8bRgH84VM/xrDjyBFgSgGrfZF4xvt6gbEDtcswm27Tn9F9TWsjQybxT8b8VpCP/oJK4Dw==} + bson@7.2.0: + resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==} engines: {node: '>=20.19.0'} buffer-crc32@0.2.13: @@ -2237,8 +2189,8 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} - chromium-bidi@13.0.1: - resolution: {integrity: sha512-c+RLxH0Vg2x2syS9wPw378oJgiJNXtYXUvnVAldUlt5uaHekn0CCU7gPksNgHjrH1qFhmjVXQj4esvuthuC7OQ==} + chromium-bidi@13.1.1: + resolution: {integrity: sha512-zB9MpoPd7VJwjowQqiW3FKOvQwffFMjQ8Iejp5ZW+sJaKLRhZX1sTxzl3Zt22TDB4zP0OOqs8lRoY7eAW5geyQ==} peerDependencies: devtools-protocol: '*' @@ -2412,8 +2364,8 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - devtools-protocol@0.0.1551306: - resolution: {integrity: sha512-CFx8QdSim8iIv+2ZcEOclBKTQY6BI1IEDa7Tm9YkwAXzEWFndTEzpTo5jAUhSnq24IC7xaDw0wvGcm96+Y3PEg==} + devtools-protocol@0.0.1566079: + resolution: {integrity: sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==} dns-packet@5.6.1: resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} @@ -2506,8 +2458,8 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true @@ -2603,14 +2555,14 @@ packages: resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} hasBin: true - fast-xml-parser@5.2.5: - resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} - hasBin: true - fast-xml-parser@5.3.4: resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} hasBin: true + fast-xml-parser@5.3.5: + resolution: {integrity: sha512-JeaA2Vm9ffQKp9VjvfzObuMCjUYAp5WDYhRYL5LrBPY/jUDlUtOvDfot0vKSkB9tuX885BDHjtw4fZadD95wnA==} + hasBin: true + fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} @@ -2730,8 +2682,8 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - get-tsconfig@4.13.1: - resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} get-uri@6.0.5: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} @@ -2886,17 +2838,10 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} - ip@2.0.1: - resolution: {integrity: sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==} - ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} - ipaddr.js@2.3.0: - resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} - engines: {node: '>= 10'} - is-arrayish@0.2.1: resolution: {integrity: sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=} @@ -2950,9 +2895,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=} - isexe@3.1.1: - resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} - engines: {node: '>=16'} + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} isopen@1.3.0: resolution: {integrity: sha512-AN6Q9J0UlqHFl1fN/2xJCHCBLCBCFDjZhpGBO1gh3wzgRPsFSFBUL36I2Lbfd9qkuoj58axmE7j83iejTQsk8Q==} @@ -2960,14 +2905,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jackspeak@4.1.1: - resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} - joi@18.0.2: - resolution: {integrity: sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==} - engines: {node: '>= 20'} - js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} @@ -3112,8 +3053,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.5: - resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} lru-cache@7.18.3: @@ -3123,11 +3064,6 @@ packages: lucide@0.563.0: resolution: {integrity: sha512-2zBzDJ5n2Plj3d0ksj6h9TWPOSiKu9gtxJxnBAye11X/8gfWied6IYJn6ADYBp1NPoJmgpyOYP3wMrVx69+2AA==} - mailauth@4.12.1: - resolution: {integrity: sha512-mSbMST+YUKj4WAfVvVszw/lnqxYA9AsYX6jYdl9vQdgz1vP5gIMwK6/RcqY+CkMkfkhFzea5+72asj620eAHmQ==} - engines: {node: '>=18.0.0'} - hasBin: true - mailparser@3.9.3: resolution: {integrity: sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==} @@ -3141,8 +3077,8 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - markdown-it@14.1.0: - resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true markdown-table@3.0.4: @@ -3342,10 +3278,6 @@ packages: minimalistic-crypto-utils@1.0.1: resolution: {integrity: sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=} - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} - minimatch@10.1.2: resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} engines: {node: 20 || >=22} @@ -3412,8 +3344,8 @@ packages: socks: optional: true - mongodb@7.0.0: - resolution: {integrity: sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==} + mongodb@7.1.0: + resolution: {integrity: sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==} engines: {node: '>=20.19.0'} peerDependencies: '@aws-sdk/credential-providers': ^3.806.0 @@ -3719,8 +3651,8 @@ packages: prosemirror-keymap@1.2.3: resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} - prosemirror-markdown@1.13.3: - resolution: {integrity: sha512-3E+Et6cdXIH0EgN2tGYQ+EBT7N4kMiZFsW+hzx+aPtOmADDHWCdd2uUQb7yklJrfUYUOjEEu22BiN6UFgPe4cQ==} + prosemirror-markdown@1.13.4: + resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==} prosemirror-menu@1.2.5: resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==} @@ -3750,8 +3682,8 @@ packages: prosemirror-transform@1.11.0: resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==} - prosemirror-view@1.41.5: - resolution: {integrity: sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==} + prosemirror-view@1.41.6: + resolution: {integrity: sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==} proto-list@1.2.4: resolution: {integrity: sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=} @@ -3781,12 +3713,12 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - puppeteer-core@24.36.1: - resolution: {integrity: sha512-L7ykMWc3lQf3HS7ME3PSjp7wMIjJeW6+bKfH/RSTz5l6VUDGubnrC2BKj3UvM28Y5PMDFW0xniJOZHBZPpW1dQ==} + puppeteer-core@24.37.2: + resolution: {integrity: sha512-nN8qwE3TGF2vA/+xemPxbesntTuqD9vCGOiZL2uh8HES3pPzLX20MyQjB42dH2rhQ3W3TljZ4ZaKZ0yX/abQuw==} engines: {node: '>=18'} - puppeteer@24.36.1: - resolution: {integrity: sha512-uPiDUyf7gd7Il1KnqfNUtHqntL0w1LapEw5Zsuh8oCK8GsqdxySX1PzdIHKB2Dw273gWY4MW0zC5gy3Re9XlqQ==} + puppeteer@24.37.2: + resolution: {integrity: sha512-FV1W/919ve0y0oiS/3Rp5XY4MUNUokpZOH/5M4MMDfrrvh6T9VbdKvAHrAFHBuCxvluDxhjra20W7Iz6HJUcIQ==} engines: {node: '>=18'} hasBin: true @@ -3923,8 +3855,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true @@ -4109,13 +4041,6 @@ packages: resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} hasBin: true - tldts-core@7.0.21: - resolution: {integrity: sha512-oVOMdHvgjqyzUZH1rOESgJP1uNe2bVrfK0jUHHmiM2rpEiRbf3j4BrsIc6JigJRbHGanQwuZv/R+LTcHsw+bLA==} - - tldts@7.0.21: - resolution: {integrity: sha512-Plu6V8fF/XU6d2k8jPtlQf5F4Xx2hAin4r2C2ca7wR8NK5MbRTo9huLUWRe28f3Uk8bYZfg74tit/dSjc18xnw==} - hasBin: true - tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -4211,10 +4136,6 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@7.19.2: - resolution: {integrity: sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg==} - engines: {node: '>=20.18.1'} - unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -4437,7 +4358,7 @@ snapshots: '@api.global/typedrequest': 3.2.5 '@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedsocket': 3.1.1(@push.rocks/smartserve@2.0.1) - '@cloudflare/workers-types': 4.20260131.0 + '@cloudflare/workers-types': 4.20260210.0 '@design.estate/dees-comms': 1.0.30 '@push.rocks/lik': 6.2.2 '@push.rocks/smartchok': 1.2.0 @@ -4485,7 +4406,7 @@ snapshots: '@api.global/typedrequest': 3.2.5 '@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedsocket': 4.1.0(@push.rocks/smartserve@2.0.1) - '@cloudflare/workers-types': 4.20260131.0 + '@cloudflare/workers-types': 4.20260210.0 '@design.estate/dees-catalog': 3.42.0(@tiptap/pm@2.27.2) '@design.estate/dees-comms': 1.0.30 '@push.rocks/lik': 6.2.2 @@ -4630,31 +4551,31 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.980.0': + '@aws-sdk/client-s3@3.987.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.5 - '@aws-sdk/credential-provider-node': 3.972.4 + '@aws-sdk/core': 3.973.7 + '@aws-sdk/credential-provider-node': 3.972.6 '@aws-sdk/middleware-bucket-endpoint': 3.972.3 '@aws-sdk/middleware-expect-continue': 3.972.3 - '@aws-sdk/middleware-flexible-checksums': 3.972.3 + '@aws-sdk/middleware-flexible-checksums': 3.972.5 '@aws-sdk/middleware-host-header': 3.972.3 '@aws-sdk/middleware-location-constraint': 3.972.3 '@aws-sdk/middleware-logger': 3.972.3 '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-sdk-s3': 3.972.5 + '@aws-sdk/middleware-sdk-s3': 3.972.7 '@aws-sdk/middleware-ssec': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.5 + '@aws-sdk/middleware-user-agent': 3.972.7 '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/signature-v4-multi-region': 3.980.0 + '@aws-sdk/signature-v4-multi-region': 3.987.0 '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.980.0 + '@aws-sdk/util-endpoints': 3.987.0 '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.5 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.22.0 + '@smithy/core': 3.23.0 '@smithy/eventstream-serde-browser': 4.2.8 '@smithy/eventstream-serde-config-resolver': 4.3.8 '@smithy/eventstream-serde-node': 4.2.8 @@ -4665,66 +4586,66 @@ snapshots: '@smithy/invalid-dependency': 4.2.8 '@smithy/md5-js': 4.2.8 '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.12 - '@smithy/middleware-retry': 4.4.29 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 '@smithy/middleware-serde': 4.2.9 '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.8 + '@smithy/node-http-handler': 4.4.10 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.1 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.28 - '@smithy/util-defaults-mode-node': 4.2.31 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 - '@smithy/util-stream': 4.5.10 + '@smithy/util-stream': 4.5.12 '@smithy/util-utf8': 4.2.0 '@smithy/util-waiter': 4.2.8 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso@3.980.0': + '@aws-sdk/client-sso@3.985.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.5 + '@aws-sdk/core': 3.973.7 '@aws-sdk/middleware-host-header': 3.972.3 '@aws-sdk/middleware-logger': 3.972.3 '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.5 + '@aws-sdk/middleware-user-agent': 3.972.7 '@aws-sdk/region-config-resolver': 3.972.3 '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.980.0 + '@aws-sdk/util-endpoints': 3.985.0 '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.5 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.22.0 + '@smithy/core': 3.23.0 '@smithy/fetch-http-handler': 5.3.9 '@smithy/hash-node': 4.2.8 '@smithy/invalid-dependency': 4.2.8 '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.12 - '@smithy/middleware-retry': 4.4.29 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 '@smithy/middleware-serde': 4.2.9 '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.8 + '@smithy/node-http-handler': 4.4.10 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.1 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.28 - '@smithy/util-defaults-mode-node': 4.2.31 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -4733,16 +4654,16 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/core@3.973.5': + '@aws-sdk/core@3.973.7': dependencies: '@aws-sdk/types': 3.973.1 - '@aws-sdk/xml-builder': 3.972.2 - '@smithy/core': 3.22.0 + '@aws-sdk/xml-builder': 3.972.4 + '@smithy/core': 3.23.0 '@smithy/node-config-provider': 4.3.8 '@smithy/property-provider': 4.2.8 '@smithy/protocol-http': 5.3.8 '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.1 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/util-base64': 4.3.0 '@smithy/util-middleware': 4.2.8 @@ -4754,37 +4675,37 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.3': + '@aws-sdk/credential-provider-env@3.972.5': dependencies: - '@aws-sdk/core': 3.973.5 + '@aws-sdk/core': 3.973.7 '@aws-sdk/types': 3.973.1 '@smithy/property-provider': 4.2.8 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.5': + '@aws-sdk/credential-provider-http@3.972.7': dependencies: - '@aws-sdk/core': 3.973.5 + '@aws-sdk/core': 3.973.7 '@aws-sdk/types': 3.973.1 '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.8 + '@smithy/node-http-handler': 4.4.10 '@smithy/property-provider': 4.2.8 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.1 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.10 + '@smithy/util-stream': 4.5.12 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.3': + '@aws-sdk/credential-provider-ini@3.972.5': dependencies: - '@aws-sdk/core': 3.973.5 - '@aws-sdk/credential-provider-env': 3.972.3 - '@aws-sdk/credential-provider-http': 3.972.5 - '@aws-sdk/credential-provider-login': 3.972.3 - '@aws-sdk/credential-provider-process': 3.972.3 - '@aws-sdk/credential-provider-sso': 3.972.3 - '@aws-sdk/credential-provider-web-identity': 3.972.3 - '@aws-sdk/nested-clients': 3.980.0 + '@aws-sdk/core': 3.973.7 + '@aws-sdk/credential-provider-env': 3.972.5 + '@aws-sdk/credential-provider-http': 3.972.7 + '@aws-sdk/credential-provider-login': 3.972.5 + '@aws-sdk/credential-provider-process': 3.972.5 + '@aws-sdk/credential-provider-sso': 3.972.5 + '@aws-sdk/credential-provider-web-identity': 3.972.5 + '@aws-sdk/nested-clients': 3.985.0 '@aws-sdk/types': 3.973.1 '@smithy/credential-provider-imds': 4.2.8 '@smithy/property-provider': 4.2.8 @@ -4794,10 +4715,10 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.972.3': + '@aws-sdk/credential-provider-login@3.972.5': dependencies: - '@aws-sdk/core': 3.973.5 - '@aws-sdk/nested-clients': 3.980.0 + '@aws-sdk/core': 3.973.7 + '@aws-sdk/nested-clients': 3.985.0 '@aws-sdk/types': 3.973.1 '@smithy/property-provider': 4.2.8 '@smithy/protocol-http': 5.3.8 @@ -4807,14 +4728,14 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-node@3.972.4': + '@aws-sdk/credential-provider-node@3.972.6': dependencies: - '@aws-sdk/credential-provider-env': 3.972.3 - '@aws-sdk/credential-provider-http': 3.972.5 - '@aws-sdk/credential-provider-ini': 3.972.3 - '@aws-sdk/credential-provider-process': 3.972.3 - '@aws-sdk/credential-provider-sso': 3.972.3 - '@aws-sdk/credential-provider-web-identity': 3.972.3 + '@aws-sdk/credential-provider-env': 3.972.5 + '@aws-sdk/credential-provider-http': 3.972.7 + '@aws-sdk/credential-provider-ini': 3.972.5 + '@aws-sdk/credential-provider-process': 3.972.5 + '@aws-sdk/credential-provider-sso': 3.972.5 + '@aws-sdk/credential-provider-web-identity': 3.972.5 '@aws-sdk/types': 3.973.1 '@smithy/credential-provider-imds': 4.2.8 '@smithy/property-provider': 4.2.8 @@ -4824,20 +4745,20 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-process@3.972.3': + '@aws-sdk/credential-provider-process@3.972.5': dependencies: - '@aws-sdk/core': 3.973.5 + '@aws-sdk/core': 3.973.7 '@aws-sdk/types': 3.973.1 '@smithy/property-provider': 4.2.8 '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.3': + '@aws-sdk/credential-provider-sso@3.972.5': dependencies: - '@aws-sdk/client-sso': 3.980.0 - '@aws-sdk/core': 3.973.5 - '@aws-sdk/token-providers': 3.980.0 + '@aws-sdk/client-sso': 3.985.0 + '@aws-sdk/core': 3.973.7 + '@aws-sdk/token-providers': 3.985.0 '@aws-sdk/types': 3.973.1 '@smithy/property-provider': 4.2.8 '@smithy/shared-ini-file-loader': 4.4.3 @@ -4846,10 +4767,10 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.3': + '@aws-sdk/credential-provider-web-identity@3.972.5': dependencies: - '@aws-sdk/core': 3.973.5 - '@aws-sdk/nested-clients': 3.980.0 + '@aws-sdk/core': 3.973.7 + '@aws-sdk/nested-clients': 3.985.0 '@aws-sdk/types': 3.973.1 '@smithy/property-provider': 4.2.8 '@smithy/shared-ini-file-loader': 4.4.3 @@ -4875,12 +4796,12 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-flexible-checksums@3.972.3': + '@aws-sdk/middleware-flexible-checksums@3.972.5': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/crc32c': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.973.5 + '@aws-sdk/core': 3.973.7 '@aws-sdk/crc64-nvme': 3.972.0 '@aws-sdk/types': 3.973.1 '@smithy/is-array-buffer': 4.2.0 @@ -4888,7 +4809,7 @@ snapshots: '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.10 + '@smithy/util-stream': 4.5.12 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 @@ -4919,20 +4840,20 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.5': + '@aws-sdk/middleware-sdk-s3@3.972.7': dependencies: - '@aws-sdk/core': 3.973.5 + '@aws-sdk/core': 3.973.7 '@aws-sdk/types': 3.973.1 '@aws-sdk/util-arn-parser': 3.972.2 - '@smithy/core': 3.22.0 + '@smithy/core': 3.23.0 '@smithy/node-config-provider': 4.3.8 '@smithy/protocol-http': 5.3.8 '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.1 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/util-config-provider': 4.2.0 '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.10 + '@smithy/util-stream': 4.5.12 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 @@ -4942,51 +4863,51 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.972.5': + '@aws-sdk/middleware-user-agent@3.972.7': dependencies: - '@aws-sdk/core': 3.973.5 + '@aws-sdk/core': 3.973.7 '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.980.0 - '@smithy/core': 3.22.0 + '@aws-sdk/util-endpoints': 3.985.0 + '@smithy/core': 3.23.0 '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.980.0': + '@aws-sdk/nested-clients@3.985.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.5 + '@aws-sdk/core': 3.973.7 '@aws-sdk/middleware-host-header': 3.972.3 '@aws-sdk/middleware-logger': 3.972.3 '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.5 + '@aws-sdk/middleware-user-agent': 3.972.7 '@aws-sdk/region-config-resolver': 3.972.3 '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.980.0 + '@aws-sdk/util-endpoints': 3.985.0 '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.5 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.22.0 + '@smithy/core': 3.23.0 '@smithy/fetch-http-handler': 5.3.9 '@smithy/hash-node': 4.2.8 '@smithy/invalid-dependency': 4.2.8 '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.12 - '@smithy/middleware-retry': 4.4.29 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 '@smithy/middleware-serde': 4.2.9 '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.8 + '@smithy/node-http-handler': 4.4.10 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.1 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.28 - '@smithy/util-defaults-mode-node': 4.2.31 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -5003,19 +4924,19 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.980.0': + '@aws-sdk/signature-v4-multi-region@3.987.0': dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.5 + '@aws-sdk/middleware-sdk-s3': 3.972.7 '@aws-sdk/types': 3.973.1 '@smithy/protocol-http': 5.3.8 '@smithy/signature-v4': 5.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.980.0': + '@aws-sdk/token-providers@3.985.0': dependencies: - '@aws-sdk/core': 3.973.5 - '@aws-sdk/nested-clients': 3.980.0 + '@aws-sdk/core': 3.973.7 + '@aws-sdk/nested-clients': 3.985.0 '@aws-sdk/types': 3.973.1 '@smithy/property-provider': 4.2.8 '@smithy/shared-ini-file-loader': 4.4.3 @@ -5033,7 +4954,15 @@ snapshots: dependencies: tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.980.0': + '@aws-sdk/util-endpoints@3.985.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.987.0': dependencies: '@aws-sdk/types': 3.973.1 '@smithy/types': 4.12.0 @@ -5049,21 +4978,21 @@ snapshots: dependencies: '@aws-sdk/types': 3.973.1 '@smithy/types': 4.12.0 - bowser: 2.13.1 + bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.972.3': + '@aws-sdk/util-user-agent-node@3.972.5': dependencies: - '@aws-sdk/middleware-user-agent': 3.972.5 + '@aws-sdk/middleware-user-agent': 3.972.7 '@aws-sdk/types': 3.973.1 '@smithy/node-config-provider': 4.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.2': + '@aws-sdk/xml-builder@3.972.4': dependencies: '@smithy/types': 4.12.0 - fast-xml-parser: 5.2.5 + fast-xml-parser: 5.3.4 tslib: 2.8.1 '@aws/lambda-invoke-store@0.2.3': {} @@ -5082,7 +5011,7 @@ snapshots: '@cfworker/json-schema@4.1.1': {} - '@cloudflare/workers-types@4.20260131.0': {} + '@cloudflare/workers-types@4.20260210.0': {} '@configvault.io/interfaces@1.0.17': dependencies: @@ -5093,10 +5022,10 @@ snapshots: '@design.estate/dees-domtools': 2.3.8 '@design.estate/dees-element': 2.1.6 '@design.estate/dees-wcctools': 3.8.0 - '@fortawesome/fontawesome-svg-core': 7.1.0 - '@fortawesome/free-brands-svg-icons': 7.1.0 - '@fortawesome/free-regular-svg-icons': 7.1.0 - '@fortawesome/free-solid-svg-icons': 7.1.0 + '@fortawesome/fontawesome-svg-core': 7.2.0 + '@fortawesome/free-brands-svg-icons': 7.2.0 + '@fortawesome/free-regular-svg-icons': 7.2.0 + '@fortawesome/free-solid-svg-icons': 7.2.0 '@push.rocks/smarti18n': 1.0.4 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartstring': 4.1.0 @@ -5196,101 +5125,101 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.27.2': + '@esbuild/aix-ppc64@0.27.3': optional: true - '@esbuild/android-arm64@0.27.2': + '@esbuild/android-arm64@0.27.3': optional: true - '@esbuild/android-arm@0.27.2': + '@esbuild/android-arm@0.27.3': optional: true - '@esbuild/android-x64@0.27.2': + '@esbuild/android-x64@0.27.3': optional: true - '@esbuild/darwin-arm64@0.27.2': + '@esbuild/darwin-arm64@0.27.3': optional: true - '@esbuild/darwin-x64@0.27.2': + '@esbuild/darwin-x64@0.27.3': optional: true - '@esbuild/freebsd-arm64@0.27.2': + '@esbuild/freebsd-arm64@0.27.3': optional: true - '@esbuild/freebsd-x64@0.27.2': + '@esbuild/freebsd-x64@0.27.3': optional: true - '@esbuild/linux-arm64@0.27.2': + '@esbuild/linux-arm64@0.27.3': optional: true - '@esbuild/linux-arm@0.27.2': + '@esbuild/linux-arm@0.27.3': optional: true - '@esbuild/linux-ia32@0.27.2': + '@esbuild/linux-ia32@0.27.3': optional: true - '@esbuild/linux-loong64@0.27.2': + '@esbuild/linux-loong64@0.27.3': optional: true - '@esbuild/linux-mips64el@0.27.2': + '@esbuild/linux-mips64el@0.27.3': optional: true - '@esbuild/linux-ppc64@0.27.2': + '@esbuild/linux-ppc64@0.27.3': optional: true - '@esbuild/linux-riscv64@0.27.2': + '@esbuild/linux-riscv64@0.27.3': optional: true - '@esbuild/linux-s390x@0.27.2': + '@esbuild/linux-s390x@0.27.3': optional: true - '@esbuild/linux-x64@0.27.2': + '@esbuild/linux-x64@0.27.3': optional: true - '@esbuild/netbsd-arm64@0.27.2': + '@esbuild/netbsd-arm64@0.27.3': optional: true - '@esbuild/netbsd-x64@0.27.2': + '@esbuild/netbsd-x64@0.27.3': optional: true - '@esbuild/openbsd-arm64@0.27.2': + '@esbuild/openbsd-arm64@0.27.3': optional: true - '@esbuild/openbsd-x64@0.27.2': + '@esbuild/openbsd-x64@0.27.3': optional: true - '@esbuild/openharmony-arm64@0.27.2': + '@esbuild/openharmony-arm64@0.27.3': optional: true - '@esbuild/sunos-x64@0.27.2': + '@esbuild/sunos-x64@0.27.3': optional: true - '@esbuild/win32-arm64@0.27.2': + '@esbuild/win32-arm64@0.27.3': optional: true - '@esbuild/win32-ia32@0.27.2': + '@esbuild/win32-ia32@0.27.3': optional: true - '@esbuild/win32-x64@0.27.2': + '@esbuild/win32-x64@0.27.3': optional: true - '@fortawesome/fontawesome-common-types@7.1.0': {} + '@fortawesome/fontawesome-common-types@7.2.0': {} - '@fortawesome/fontawesome-svg-core@7.1.0': + '@fortawesome/fontawesome-svg-core@7.2.0': dependencies: - '@fortawesome/fontawesome-common-types': 7.1.0 + '@fortawesome/fontawesome-common-types': 7.2.0 - '@fortawesome/free-brands-svg-icons@7.1.0': + '@fortawesome/free-brands-svg-icons@7.2.0': dependencies: - '@fortawesome/fontawesome-common-types': 7.1.0 + '@fortawesome/fontawesome-common-types': 7.2.0 - '@fortawesome/free-regular-svg-icons@7.1.0': + '@fortawesome/free-regular-svg-icons@7.2.0': dependencies: - '@fortawesome/fontawesome-common-types': 7.1.0 + '@fortawesome/fontawesome-common-types': 7.2.0 - '@fortawesome/free-solid-svg-icons@7.1.0': + '@fortawesome/free-solid-svg-icons@7.2.0': dependencies: - '@fortawesome/fontawesome-common-types': 7.1.0 + '@fortawesome/fontawesome-common-types': 7.2.0 '@git.zone/tsbuild@4.1.2': dependencies: @@ -5326,9 +5255,9 @@ snapshots: '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartspawn': 3.0.3 - '@rspack/core': 1.7.4 + '@rspack/core': 1.7.6 '@types/html-minifier': 4.0.6 - esbuild: 0.27.2 + esbuild: 0.27.3 html-minifier: 4.0.0 rolldown: 1.0.0-beta.52 typescript: 5.9.3 @@ -5416,7 +5345,7 @@ snapshots: - utf-8-validate - vue - '@git.zone/tswatch@3.0.1(@tiptap/pm@2.27.2)': + '@git.zone/tswatch@3.1.0(@tiptap/pm@2.27.2)': dependencies: '@api.global/typedserver': 8.3.0(@tiptap/pm@2.27.2) '@git.zone/tsbundle': 2.8.3 @@ -5432,7 +5361,7 @@ snapshots: '@push.rocks/smartlog-destination-local': 9.0.2 '@push.rocks/smartshell': 3.3.0 '@push.rocks/smartwatch': 6.3.0 - '@push.rocks/taskbuffer': 3.5.0 + '@push.rocks/taskbuffer': 4.2.0 transitivePeerDependencies: - '@nuxt/kit' - '@swc/helpers' @@ -5443,22 +5372,6 @@ snapshots: - utf-8-validate - vue - '@hapi/address@5.1.1': - dependencies: - '@hapi/hoek': 11.0.7 - - '@hapi/formula@3.0.2': {} - - '@hapi/hoek@11.0.7': {} - - '@hapi/pinpoint@2.0.1': {} - - '@hapi/tlds@1.1.4': {} - - '@hapi/topo@6.0.2': - dependencies: - '@hapi/hoek': 11.0.7 - '@happy-dom/global-registrator@15.11.7': dependencies: happy-dom: 15.11.7 @@ -5481,7 +5394,7 @@ snapshots: '@inquirer/figures': 1.0.15 '@inquirer/type': 2.0.0 '@types/mute-stream': 0.0.4 - '@types/node': 22.19.7 + '@types/node': 22.19.11 '@types/wrap-ansi': 3.0.0 ansi-escapes: 4.3.2 cli-width: 4.1.0 @@ -5561,10 +5474,6 @@ snapshots: '@isaacs/balanced-match@4.0.1': {} - '@isaacs/brace-expansion@5.0.0': - dependencies: - '@isaacs/balanced-match': 4.0.1 - '@isaacs/brace-expansion@5.0.1': dependencies: '@isaacs/balanced-match': 4.0.1 @@ -5578,6 +5487,8 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/cliui@9.0.0': {} + '@leichtgewicht/ip-codec@2.0.5': {} '@lit-labs/ssr-dom-shim@1.5.1': {} @@ -5613,56 +5524,56 @@ snapshots: '@module-federation/runtime': 0.22.0 '@module-federation/sdk': 0.22.0 - '@mongodb-js/saslprep@1.4.5': + '@mongodb-js/saslprep@1.4.6': dependencies: sparse-bitfield: 3.0.3 - '@napi-rs/canvas-android-arm64@0.1.89': + '@napi-rs/canvas-android-arm64@0.1.91': optional: true - '@napi-rs/canvas-darwin-arm64@0.1.89': + '@napi-rs/canvas-darwin-arm64@0.1.91': optional: true - '@napi-rs/canvas-darwin-x64@0.1.89': + '@napi-rs/canvas-darwin-x64@0.1.91': optional: true - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.89': + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.91': optional: true - '@napi-rs/canvas-linux-arm64-gnu@0.1.89': + '@napi-rs/canvas-linux-arm64-gnu@0.1.91': optional: true - '@napi-rs/canvas-linux-arm64-musl@0.1.89': + '@napi-rs/canvas-linux-arm64-musl@0.1.91': optional: true - '@napi-rs/canvas-linux-riscv64-gnu@0.1.89': + '@napi-rs/canvas-linux-riscv64-gnu@0.1.91': optional: true - '@napi-rs/canvas-linux-x64-gnu@0.1.89': + '@napi-rs/canvas-linux-x64-gnu@0.1.91': optional: true - '@napi-rs/canvas-linux-x64-musl@0.1.89': + '@napi-rs/canvas-linux-x64-musl@0.1.91': optional: true - '@napi-rs/canvas-win32-arm64-msvc@0.1.89': + '@napi-rs/canvas-win32-arm64-msvc@0.1.91': optional: true - '@napi-rs/canvas-win32-x64-msvc@0.1.89': + '@napi-rs/canvas-win32-x64-msvc@0.1.91': optional: true - '@napi-rs/canvas@0.1.89': + '@napi-rs/canvas@0.1.91': optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.89 - '@napi-rs/canvas-darwin-arm64': 0.1.89 - '@napi-rs/canvas-darwin-x64': 0.1.89 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.89 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.89 - '@napi-rs/canvas-linux-arm64-musl': 0.1.89 - '@napi-rs/canvas-linux-riscv64-gnu': 0.1.89 - '@napi-rs/canvas-linux-x64-gnu': 0.1.89 - '@napi-rs/canvas-linux-x64-musl': 0.1.89 - '@napi-rs/canvas-win32-arm64-msvc': 0.1.89 - '@napi-rs/canvas-win32-x64-msvc': 0.1.89 + '@napi-rs/canvas-android-arm64': 0.1.91 + '@napi-rs/canvas-darwin-arm64': 0.1.91 + '@napi-rs/canvas-darwin-x64': 0.1.91 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.91 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.91 + '@napi-rs/canvas-linux-arm64-musl': 0.1.91 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.91 + '@napi-rs/canvas-linux-x64-gnu': 0.1.91 + '@napi-rs/canvas-linux-x64-musl': 0.1.91 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.91 + '@napi-rs/canvas-win32-x64-msvc': 0.1.91 optional: true '@napi-rs/wasm-runtime@1.0.7': @@ -5747,12 +5658,6 @@ snapshots: asn1js: 3.0.7 tslib: 2.8.1 - '@peculiar/asn1-schema@2.3.15': - dependencies: - asn1js: 3.0.7 - pvtsutils: 1.3.6 - tslib: 2.8.1 - '@peculiar/asn1-schema@2.6.0': dependencies: asn1js: 3.0.7 @@ -5766,20 +5671,6 @@ snapshots: asn1js: 3.0.7 tslib: 2.8.1 - '@peculiar/asn1-x509-logotype@2.3.15': - dependencies: - '@peculiar/asn1-schema': 2.3.15 - '@peculiar/asn1-x509': 2.3.15 - asn1js: 3.0.7 - tslib: 2.8.1 - - '@peculiar/asn1-x509@2.3.15': - dependencies: - '@peculiar/asn1-schema': 2.3.15 - asn1js: 3.0.7 - pvtsutils: 1.3.6 - tslib: 2.8.1 - '@peculiar/asn1-x509@2.6.0': dependencies: '@peculiar/asn1-schema': 2.6.0 @@ -5816,19 +5707,13 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@postalsys/vmc@1.1.2': - dependencies: - '@peculiar/asn1-schema': 2.3.15 - '@peculiar/asn1-x509': 2.3.15 - '@peculiar/asn1-x509-logotype': 2.3.15 - - '@puppeteer/browsers@2.11.2': + '@puppeteer/browsers@2.12.0': dependencies: debug: 4.4.3 extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 - semver: 7.7.3 + semver: 7.7.4 tar-fs: 3.1.1 yargs: 17.7.2 transitivePeerDependencies: @@ -6007,7 +5892,7 @@ snapshots: '@push.rocks/smartbucket@3.3.10': dependencies: - '@aws-sdk/client-s3': 3.980.0 + '@aws-sdk/client-s3': 3.987.0 '@push.rocks/smartmime': 2.0.4 '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpromise': 4.2.3 @@ -6021,7 +5906,7 @@ snapshots: '@push.rocks/smartbucket@4.4.1': dependencies: - '@aws-sdk/client-s3': 3.980.0 + '@aws-sdk/client-s3': 3.987.0 '@push.rocks/smartmime': 2.0.4 '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpromise': 4.2.3 @@ -6030,7 +5915,7 @@ snapshots: '@push.rocks/smartstring': 4.1.0 '@push.rocks/smartunique': 3.0.9 '@tsclass/tsclass': 9.3.0 - minimatch: 10.1.1 + minimatch: 10.1.2 transitivePeerDependencies: - aws-crt @@ -6120,7 +6005,7 @@ snapshots: '@push.rocks/smartunique': 3.0.9 '@push.rocks/taskbuffer': 3.5.0 '@tsclass/tsclass': 9.3.0 - mongodb: 7.0.0(socks@2.8.7) + mongodb: 7.1.0(socks@2.8.7) transitivePeerDependencies: - '@aws-sdk/credential-providers' - '@mongodb-js/zstd' @@ -6152,23 +6037,22 @@ snapshots: acme-client: 5.4.0 dns-packet: 5.6.1 elliptic: 6.6.1 - minimatch: 10.1.1 + minimatch: 10.1.2 transitivePeerDependencies: - supports-color - '@push.rocks/smartdns@7.6.1': + '@push.rocks/smartdns@7.8.0': dependencies: '@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartenv': 5.0.13 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrequest': 2.1.0 + '@push.rocks/smartrust': 1.2.0 '@tsclass/tsclass': 9.3.0 '@types/dns-packet': 5.6.5 - '@types/elliptic': 6.4.18 acme-client: 5.4.0 dns-packet: 5.6.1 - elliptic: 6.6.1 - minimatch: 10.1.1 + minimatch: 10.1.2 transitivePeerDependencies: - supports-color @@ -6338,7 +6222,7 @@ snapshots: '@push.rocks/smartmail@2.2.0': dependencies: - '@push.rocks/smartdns': 7.6.1 + '@push.rocks/smartdns': 7.8.0 '@push.rocks/smartfile': 13.1.2 '@push.rocks/smartmustache': 3.0.2 '@push.rocks/smartpath': 6.0.0 @@ -6439,13 +6323,28 @@ snapshots: - supports-color - vue + '@push.rocks/smartmta@5.2.1': + dependencies: + '@push.rocks/smartfile': 13.1.2 + '@push.rocks/smartfs': 1.3.1 + '@push.rocks/smartlog': 3.1.10 + '@push.rocks/smartmail': 2.2.0 + '@push.rocks/smartpath': 6.0.0 + '@push.rocks/smartrust': 1.2.0 + '@tsclass/tsclass': 9.3.0 + lru-cache: 11.2.6 + mailparser: 3.9.3 + uuid: 13.0.0 + transitivePeerDependencies: + - supports-color + '@push.rocks/smartmustache@3.0.2': dependencies: handlebars: 4.7.8 '@push.rocks/smartnetwork@4.4.0': dependencies: - '@push.rocks/smartdns': 7.6.1 + '@push.rocks/smartdns': 7.8.0 '@push.rocks/smartping': 1.0.8 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartstring': 4.1.0 @@ -6531,7 +6430,7 @@ snapshots: '@push.rocks/smartpromise@4.2.3': {} - '@push.rocks/smartproxy@23.1.0(socks@2.8.7)': + '@push.rocks/smartproxy@23.1.2(socks@2.8.7)': dependencies: '@push.rocks/lik': 6.2.2 '@push.rocks/smartacme': 8.0.0(socks@2.8.7) @@ -6542,7 +6441,7 @@ snapshots: '@push.rocks/smartnetwork': 4.4.0 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrequest': 5.0.1 - '@push.rocks/smartrust': 1.1.1 + '@push.rocks/smartrust': 1.2.0 '@push.rocks/smartrx': 3.0.10 '@push.rocks/smartstring': 4.1.0 '@push.rocks/taskbuffer': 4.2.0 @@ -6574,7 +6473,7 @@ snapshots: dependencies: '@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartshell': 3.3.0 - puppeteer: 24.36.1(typescript@5.9.3) + puppeteer: 24.37.2(typescript@5.9.3) tree-kill: 1.2.2 transitivePeerDependencies: - bare-abort-controller @@ -6621,9 +6520,7 @@ snapshots: '@push.rocks/smartrx': 3.0.10 path-to-regexp: 8.3.0 - '@push.rocks/smartrule@2.0.1': {} - - '@push.rocks/smartrust@1.1.1': + '@push.rocks/smartrust@1.2.0': dependencies: '@push.rocks/smartpath': 6.0.0 @@ -6758,7 +6655,7 @@ snapshots: '@push.rocks/smartversion@3.0.5': dependencies: '@types/semver': 7.7.1 - semver: 7.7.3 + semver: 7.7.4 '@push.rocks/smartwatch@6.3.0': dependencies: @@ -6771,7 +6668,7 @@ snapshots: '@push.rocks/smartxml@2.0.0': dependencies: - fast-xml-parser: 5.3.4 + fast-xml-parser: 5.3.5 '@push.rocks/smartyaml@2.0.5': dependencies: @@ -6941,55 +6838,55 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.52': {} - '@rspack/binding-darwin-arm64@1.7.4': + '@rspack/binding-darwin-arm64@1.7.6': optional: true - '@rspack/binding-darwin-x64@1.7.4': + '@rspack/binding-darwin-x64@1.7.6': optional: true - '@rspack/binding-linux-arm64-gnu@1.7.4': + '@rspack/binding-linux-arm64-gnu@1.7.6': optional: true - '@rspack/binding-linux-arm64-musl@1.7.4': + '@rspack/binding-linux-arm64-musl@1.7.6': optional: true - '@rspack/binding-linux-x64-gnu@1.7.4': + '@rspack/binding-linux-x64-gnu@1.7.6': optional: true - '@rspack/binding-linux-x64-musl@1.7.4': + '@rspack/binding-linux-x64-musl@1.7.6': optional: true - '@rspack/binding-wasm32-wasi@1.7.4': + '@rspack/binding-wasm32-wasi@1.7.6': dependencies: '@napi-rs/wasm-runtime': 1.0.7 optional: true - '@rspack/binding-win32-arm64-msvc@1.7.4': + '@rspack/binding-win32-arm64-msvc@1.7.6': optional: true - '@rspack/binding-win32-ia32-msvc@1.7.4': + '@rspack/binding-win32-ia32-msvc@1.7.6': optional: true - '@rspack/binding-win32-x64-msvc@1.7.4': + '@rspack/binding-win32-x64-msvc@1.7.6': optional: true - '@rspack/binding@1.7.4': + '@rspack/binding@1.7.6': optionalDependencies: - '@rspack/binding-darwin-arm64': 1.7.4 - '@rspack/binding-darwin-x64': 1.7.4 - '@rspack/binding-linux-arm64-gnu': 1.7.4 - '@rspack/binding-linux-arm64-musl': 1.7.4 - '@rspack/binding-linux-x64-gnu': 1.7.4 - '@rspack/binding-linux-x64-musl': 1.7.4 - '@rspack/binding-wasm32-wasi': 1.7.4 - '@rspack/binding-win32-arm64-msvc': 1.7.4 - '@rspack/binding-win32-ia32-msvc': 1.7.4 - '@rspack/binding-win32-x64-msvc': 1.7.4 + '@rspack/binding-darwin-arm64': 1.7.6 + '@rspack/binding-darwin-x64': 1.7.6 + '@rspack/binding-linux-arm64-gnu': 1.7.6 + '@rspack/binding-linux-arm64-musl': 1.7.6 + '@rspack/binding-linux-x64-gnu': 1.7.6 + '@rspack/binding-linux-x64-musl': 1.7.6 + '@rspack/binding-wasm32-wasi': 1.7.6 + '@rspack/binding-win32-arm64-msvc': 1.7.6 + '@rspack/binding-win32-ia32-msvc': 1.7.6 + '@rspack/binding-win32-x64-msvc': 1.7.6 - '@rspack/core@1.7.4': + '@rspack/core@1.7.6': dependencies: '@module-federation/runtime-tools': 0.22.0 - '@rspack/binding': 1.7.4 + '@rspack/binding': 1.7.6 '@rspack/lite-tapable': 1.1.0 '@rspack/lite-tapable@1.1.0': {} @@ -7032,7 +6929,7 @@ snapshots: '@smithy/util-middleware': 4.2.8 tslib: 2.8.1 - '@smithy/core@3.22.0': + '@smithy/core@3.23.0': dependencies: '@smithy/middleware-serde': 4.2.9 '@smithy/protocol-http': 5.3.8 @@ -7040,7 +6937,7 @@ snapshots: '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.10 + '@smithy/util-stream': 4.5.12 '@smithy/util-utf8': 4.2.0 '@smithy/uuid': 1.1.0 tslib: 2.8.1 @@ -7136,9 +7033,9 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.12': + '@smithy/middleware-endpoint@4.4.14': dependencies: - '@smithy/core': 3.22.0 + '@smithy/core': 3.23.0 '@smithy/middleware-serde': 4.2.9 '@smithy/node-config-provider': 4.3.8 '@smithy/shared-ini-file-loader': 4.4.3 @@ -7147,12 +7044,12 @@ snapshots: '@smithy/util-middleware': 4.2.8 tslib: 2.8.1 - '@smithy/middleware-retry@4.4.29': + '@smithy/middleware-retry@4.4.31': dependencies: '@smithy/node-config-provider': 4.3.8 '@smithy/protocol-http': 5.3.8 '@smithy/service-error-classification': 4.2.8 - '@smithy/smithy-client': 4.11.1 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -7177,7 +7074,7 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/node-http-handler@4.4.8': + '@smithy/node-http-handler@4.4.10': dependencies: '@smithy/abort-controller': 4.2.8 '@smithy/protocol-http': 5.3.8 @@ -7226,14 +7123,14 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@smithy/smithy-client@4.11.1': + '@smithy/smithy-client@4.11.3': dependencies: - '@smithy/core': 3.22.0 - '@smithy/middleware-endpoint': 4.4.12 + '@smithy/core': 3.23.0 + '@smithy/middleware-endpoint': 4.4.14 '@smithy/middleware-stack': 4.2.8 '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.10 + '@smithy/util-stream': 4.5.12 tslib: 2.8.1 '@smithy/types@4.12.0': @@ -7274,20 +7171,20 @@ snapshots: dependencies: tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.28': + '@smithy/util-defaults-mode-browser@4.3.30': dependencies: '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.1 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.31': + '@smithy/util-defaults-mode-node@4.2.33': dependencies: '@smithy/config-resolver': 4.4.6 '@smithy/credential-provider-imds': 4.2.8 '@smithy/node-config-provider': 4.3.8 '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.1 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -7312,10 +7209,10 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/util-stream@4.5.10': + '@smithy/util-stream@4.5.12': dependencies: '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.8 + '@smithy/node-http-handler': 4.4.10 '@smithy/types': 4.12.0 '@smithy/util-base64': 4.3.0 '@smithy/util-buffer-from': 4.2.0 @@ -7349,8 +7246,6 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} - '@standard-schema/spec@1.1.0': {} - '@svgdotjs/svg.draggable.js@3.0.6(@svgdotjs/svg.js@3.2.5)': dependencies: '@svgdotjs/svg.js': 3.2.5 @@ -7493,16 +7388,16 @@ snapshots: prosemirror-history: 1.5.0 prosemirror-inputrules: 1.5.1 prosemirror-keymap: 1.2.3 - prosemirror-markdown: 1.13.3 + prosemirror-markdown: 1.13.4 prosemirror-menu: 1.2.5 prosemirror-model: 1.25.4 prosemirror-schema-basic: 1.2.4 prosemirror-schema-list: 1.5.1 prosemirror-state: 1.4.4 prosemirror-tables: 1.8.5 - prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5) + prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6) prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.5 + prosemirror-view: 1.41.6 '@tiptap/starter-kit@2.27.2': dependencies: @@ -7558,27 +7453,27 @@ snapshots: '@types/bn.js@5.2.0': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/buffer-json@2.0.3': {} '@types/clean-css@4.2.11': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 source-map: 0.6.1 '@types/connect@3.4.38': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/cors@2.8.19': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/debug@4.1.12': dependencies: @@ -7586,7 +7481,7 @@ snapshots: '@types/dns-packet@5.6.5': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/elliptic@6.4.18': dependencies: @@ -7594,7 +7489,7 @@ snapshots: '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -7607,17 +7502,17 @@ snapshots: '@types/from2@2.3.6': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/glob@8.1.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/hast@3.0.4': dependencies: @@ -7639,20 +7534,15 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/linkify-it@5.0.0': {} - '@types/mailparser@3.4.6': - dependencies: - '@types/node': 25.2.0 - iconv-lite: 0.6.3 - '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 @@ -7676,26 +7566,26 @@ snapshots: '@types/mute-stream@0.0.4': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/node-fetch@2.6.13': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 form-data: 4.0.5 '@types/node-forge@1.3.14': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/node@18.19.130': dependencies: undici-types: 5.26.5 - '@types/node@22.19.7': + '@types/node@22.19.11': dependencies: undici-types: 6.21.0 - '@types/node@25.2.0': + '@types/node@25.2.3': dependencies: undici-types: 7.16.0 @@ -7715,22 +7605,22 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/symbol-tree@3.2.5': {} '@types/tar-stream@3.1.4': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/through2@2.0.41': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/trusted-types@2.0.7': {} @@ -7760,11 +7650,11 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 optional: true '@ungap/structured-clone@1.3.0': {} @@ -7795,7 +7685,7 @@ snapshots: dependencies: '@peculiar/x509': 1.14.3 asn1js: 3.0.7 - axios: 1.13.4(debug@4.4.3) + axios: 1.13.5(debug@4.4.3) debug: 4.4.3 node-forge: 1.3.3 transitivePeerDependencies: @@ -7854,7 +7744,7 @@ snapshots: asynckit@0.4.0: {} - axios@1.13.4(debug@4.4.3): + axios@1.13.5(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) form-data: 4.0.5 @@ -7929,7 +7819,7 @@ snapshots: transitivePeerDependencies: - supports-color - bowser@2.13.1: {} + bowser@2.14.1: {} brace-expansion@1.1.12: dependencies: @@ -7951,7 +7841,7 @@ snapshots: bson@6.10.4: {} - bson@7.1.1: {} + bson@7.2.0: {} buffer-crc32@0.2.13: {} @@ -8018,9 +7908,9 @@ snapshots: dependencies: readdirp: 5.0.0 - chromium-bidi@13.0.1(devtools-protocol@0.0.1551306): + chromium-bidi@13.1.1(devtools-protocol@0.0.1566079): dependencies: - devtools-protocol: 0.0.1551306 + devtools-protocol: 0.0.1566079 mitt: 3.0.1 zod: 3.25.76 @@ -8169,7 +8059,7 @@ snapshots: dependencies: dequal: 2.0.3 - devtools-protocol@0.0.1551306: {} + devtools-protocol@0.0.1566079: {} dns-packet@5.6.1: dependencies: @@ -8250,7 +8140,7 @@ snapshots: engine.io@6.6.4: dependencies: '@types/cors': 2.8.19 - '@types/node': 25.2.0 + '@types/node': 25.2.3 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -8286,34 +8176,34 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - esbuild@0.27.2: + esbuild@0.27.3: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 escalade@3.2.0: {} @@ -8420,11 +8310,11 @@ snapshots: dependencies: strnum: 1.1.2 - fast-xml-parser@5.2.5: + fast-xml-parser@5.3.4: dependencies: strnum: 2.1.2 - fast-xml-parser@5.3.4: + fast-xml-parser@5.3.5: dependencies: strnum: 2.1.2 @@ -8566,7 +8456,7 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 - get-tsconfig@4.13.1: + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -8590,8 +8480,8 @@ snapshots: glob@11.1.0: dependencies: foreground-child: 3.3.1 - jackspeak: 4.1.1 - minimatch: 10.1.1 + jackspeak: 4.2.3 + minimatch: 10.1.2 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 2.0.1 @@ -8796,12 +8686,8 @@ snapshots: ip-address@10.1.0: {} - ip@2.0.1: {} - ipaddr.js@1.9.1: {} - ipaddr.js@2.3.0: {} - is-arrayish@0.2.1: {} is-docker@2.2.1: {} @@ -8835,7 +8721,7 @@ snapshots: isexe@2.0.0: {} - isexe@3.1.1: {} + isexe@3.1.5: {} isopen@1.3.0: {} @@ -8845,19 +8731,9 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jackspeak@4.1.1: + jackspeak@4.2.3: dependencies: - '@isaacs/cliui': 8.0.2 - - joi@18.0.2: - dependencies: - '@hapi/address': 5.1.1 - '@hapi/formula': 3.0.2 - '@hapi/hoek': 11.0.7 - '@hapi/pinpoint': 2.0.1 - '@hapi/tlds': 1.1.4 - '@hapi/topo': 6.0.2 - '@standard-schema/spec': 1.1.0 + '@isaacs/cliui': 9.0.0 js-base64@3.7.8: {} @@ -8893,7 +8769,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.3 + semver: 7.7.4 jwa@2.0.1: dependencies: @@ -9014,25 +8890,12 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.5: {} + lru-cache@11.2.6: {} lru-cache@7.18.3: {} lucide@0.563.0: {} - mailauth@4.12.1: - dependencies: - '@postalsys/vmc': 1.1.2 - fast-xml-parser: 5.3.4 - ipaddr.js: 2.3.0 - joi: 18.0.2 - libmime: 5.3.7 - nodemailer: 7.0.13 - punycode.js: 2.3.1 - tldts: 7.0.21 - undici: 7.19.2 - yargs: 17.7.2 - mailparser@3.9.3: dependencies: '@zone-eu/mailsplit': 5.4.8 @@ -9056,7 +8919,7 @@ snapshots: make-error@1.3.6: {} - markdown-it@14.1.0: + markdown-it@14.1.1: dependencies: argparse: 2.0.1 entities: 4.5.0 @@ -9432,10 +9295,6 @@ snapshots: minimalistic-crypto-utils@1.0.1: {} - minimatch@10.1.1: - dependencies: - '@isaacs/brace-expansion': 5.0.0 - minimatch@10.1.2: dependencies: '@isaacs/brace-expansion': 5.0.1 @@ -9479,7 +9338,7 @@ snapshots: https-proxy-agent: 7.0.6 mongodb: 6.21.0(socks@2.8.7) new-find-package-json: 2.0.0 - semver: 7.7.3 + semver: 7.7.4 tar-stream: 3.1.7 tslib: 2.8.1 yauzl: 3.2.0 @@ -9513,16 +9372,16 @@ snapshots: mongodb@6.21.0(socks@2.8.7): dependencies: - '@mongodb-js/saslprep': 1.4.5 + '@mongodb-js/saslprep': 1.4.6 bson: 6.10.4 mongodb-connection-string-url: 3.0.2 optionalDependencies: socks: 2.8.7 - mongodb@7.0.0(socks@2.8.7): + mongodb@7.1.0(socks@2.8.7): dependencies: - '@mongodb-js/saslprep': 1.4.5 - bson: 7.1.1 + '@mongodb-js/saslprep': 1.4.6 + bson: 7.2.0 mongodb-connection-string-url: 7.0.1 optionalDependencies: socks: 2.8.7 @@ -9643,7 +9502,7 @@ snapshots: got: 12.6.1 registry-auth-token: 5.1.1 registry-url: 6.0.1 - semver: 7.7.3 + semver: 7.7.4 pako@1.0.11: {} @@ -9684,7 +9543,7 @@ snapshots: path-scurry@2.0.1: dependencies: - lru-cache: 11.2.5 + lru-cache: 11.2.6 minipass: 7.1.2 path-to-regexp@8.3.0: {} @@ -9700,7 +9559,7 @@ snapshots: pdfjs-dist@4.10.38: optionalDependencies: - '@napi-rs/canvas': 0.1.89 + '@napi-rs/canvas': 0.1.91 peberminta@0.9.0: {} @@ -9757,20 +9616,20 @@ snapshots: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.5 + prosemirror-view: 1.41.6 prosemirror-gapcursor@1.4.0: dependencies: prosemirror-keymap: 1.2.3 prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.5 + prosemirror-view: 1.41.6 prosemirror-history@1.5.0: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.5 + prosemirror-view: 1.41.6 rope-sequence: 1.3.4 prosemirror-inputrules@1.5.1: @@ -9783,10 +9642,10 @@ snapshots: prosemirror-state: 1.4.4 w3c-keyname: 2.2.8 - prosemirror-markdown@1.13.3: + prosemirror-markdown@1.13.4: dependencies: '@types/markdown-it': 14.1.2 - markdown-it: 14.1.0 + markdown-it: 14.1.1 prosemirror-model: 1.25.4 prosemirror-menu@1.2.5: @@ -9814,7 +9673,7 @@ snapshots: dependencies: prosemirror-model: 1.25.4 prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.5 + prosemirror-view: 1.41.6 prosemirror-tables@1.8.5: dependencies: @@ -9822,21 +9681,21 @@ snapshots: prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.5 + prosemirror-view: 1.41.6 - prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5): + prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6): dependencies: '@remirror/core-constants': 3.0.0 escape-string-regexp: 4.0.0 prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.5 + prosemirror-view: 1.41.6 prosemirror-transform@1.11.0: dependencies: prosemirror-model: 1.25.4 - prosemirror-view@1.41.5: + prosemirror-view@1.41.6: dependencies: prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 @@ -9875,12 +9734,12 @@ snapshots: punycode@2.3.1: {} - puppeteer-core@24.36.1: + puppeteer-core@24.37.2: dependencies: - '@puppeteer/browsers': 2.11.2 - chromium-bidi: 13.0.1(devtools-protocol@0.0.1551306) + '@puppeteer/browsers': 2.12.0 + chromium-bidi: 13.1.1(devtools-protocol@0.0.1566079) debug: 4.4.3 - devtools-protocol: 0.0.1551306 + devtools-protocol: 0.0.1566079 typed-query-selector: 2.12.0 webdriver-bidi-protocol: 0.4.0 ws: 8.19.0 @@ -9892,13 +9751,13 @@ snapshots: - supports-color - utf-8-validate - puppeteer@24.36.1(typescript@5.9.3): + puppeteer@24.37.2(typescript@5.9.3): dependencies: - '@puppeteer/browsers': 2.11.2 - chromium-bidi: 13.0.1(devtools-protocol@0.0.1551306) + '@puppeteer/browsers': 2.12.0 + chromium-bidi: 13.1.1(devtools-protocol@0.0.1566079) cosmiconfig: 9.0.0(typescript@5.9.3) - devtools-protocol: 0.0.1551306 - puppeteer-core: 24.36.1 + devtools-protocol: 0.0.1566079 + puppeteer-core: 24.37.2 typed-query-selector: 2.12.0 transitivePeerDependencies: - bare-abort-controller @@ -10084,7 +9943,7 @@ snapshots: semver@6.3.1: {} - semver@7.7.3: {} + semver@7.7.4: {} send@1.2.1: dependencies: @@ -10354,12 +10213,6 @@ snapshots: tlds@1.261.0: {} - tldts-core@7.0.21: {} - - tldts@7.0.21: - dependencies: - tldts-core: 7.0.21 - tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -10390,8 +10243,8 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.27.2 - get-tsconfig: 4.13.1 + esbuild: 0.27.3 + get-tsconfig: 4.13.6 optionalDependencies: fsevents: 2.3.3 @@ -10433,8 +10286,6 @@ snapshots: undici-types@7.16.0: {} - undici@7.19.2: {} - unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -10527,7 +10378,7 @@ snapshots: which@5.0.0: dependencies: - isexe: 3.1.1 + isexe: 3.1.5 wordwrap@1.0.0: {} diff --git a/readme.hints.md b/readme.hints.md index 4b36fb2..f2517c3 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -1,5 +1,65 @@ # Implementation Hints and Learnings +## smartmta Migration (2026-02-11) + +### Overview +dcrouter's custom MTA code (~27,149 lines / 68 files in `ts/mail/` + `ts/deliverability/`) has been replaced with `@push.rocks/smartmta` v5.2.1, a TypeScript+Rust hybrid MTA. dcrouter is now an orchestrator that wires together SmartProxy, smartmta, smartdns, smartradius, and OpsServer. + +### Architecture +- **No socket-handler mode** — smartmta's Rust SMTP server binds its own ports directly +- **SmartProxy forward mode only** — external email ports forwarded to internal ports where smartmta listens +- Email traffic flow: External Port → SmartProxy → Internal Port → smartmta UnifiedEmailServer + +### Key API Differences (smartmta vs old custom MTA) +- `updateEmailRoutes()` instead of `updateRoutes()` +- `dkimCreator` is public (no need for `(this.emailServer as any).dkimCreator`) +- `bounceManager` is private, but exposed via public methods: + - `emailServer.getSuppressionList()` + - `emailServer.getHardBouncedAddresses()` + - `emailServer.getBounceHistory(email)` + - `emailServer.removeFromSuppressionList(email)` +- `Email` class imported from `@push.rocks/smartmta` +- `IAttachment` type accessed via `Core` namespace: `import { type Core } from '@push.rocks/smartmta'; type IAttachment = Core.IAttachment;` + +### Deleted Directories +- `ts/mail/` (60 files) — replaced by smartmta +- `ts/deliverability/` (3 files) — IPWarmupManager/SenderReputationMonitor will move to smartmta +- `ts/errors/email.errors.ts`, `ts/errors/mta.errors.ts` — smartmta has its own errors +- `ts/cache/documents/classes.cached.bounce.ts`, `classes.cached.suppression.ts`, `classes.cached.dkim.ts` — smartmta handles its own persistence + +### Remaining Cache Documents +- `CachedEmail` — kept (dcrouter-level queue persistence) +- `CachedIPReputation` — kept (dcrouter-level IP reputation caching) + +### Dependencies Removed +mailauth, mailparser, @types/mailparser, ip, @push.rocks/smartmail, @push.rocks/smartrule, node-forge + +### Pre-existing Test Failures (not caused by migration) +- `test/test.jwt-auth.ts` — `response.text is not a function` (webrequest compatibility issue) +- `test/test.opsserver-api.ts` — same webrequest issue, timeouts + +### smartmta Location +Source at `../../push.rocks/smartmta`, release with `gitzone commit -ypbrt` + +## Dependency Upgrade (2026-02-11) + +### SmartProxy v23.1.2 Route Validation +- SmartProxy 23.1.2 enforces stricter route validation +- Forward actions MUST use `targets` (array) instead of `target` (singular) +- Test configurations that call `DcRouter.start()` need `cacheConfig: { enabled: false }` to avoid `/etc/dcrouter` permission errors + +```typescript +// WRONG - will fail validation +action: { type: 'forward', target: { host: 'localhost', port: 10025 } } + +// CORRECT +action: { type: 'forward', targets: [{ host: 'localhost', port: 10025 }] } +``` + +**Files Fixed:** +- `ts/classes.dcrouter.ts` - `generateEmailRoutes()` method +- `test/test.dcrouter.email.ts` - Updated assertions and added `cacheConfig: { enabled: false }` + ## Dependency Upgrade (2026-02-10) ### SmartProxy v23.1.0 Upgrade @@ -208,27 +268,7 @@ When using `IUnifiedEmailServerOptions` (aliased as `IEmailConfig` in some tests ## DKIM Implementation Status (2025-05-30) -### Current Implementation -1. **DKIM Key Generation**: Working - keys are generated when emails are sent -2. **DKIM Email Signing**: Working - emails are signed with DKIM -3. **DKIM DNS Record Serving**: Implemented - records are loaded from JSON files and served -4. **Proactive DKIM Generation**: Implemented - keys are generated for all email domains at startup - -### Key Points -- DKIM selector is hardcoded as `mta` in DKIMCreator -- DKIM records are stored in `.nogit/data/dns/*.dkimrecord.json` -- DKIM keys are stored in `.nogit/data/keys/{domain}-private.pem` and `{domain}-public.pem` -- The server needs to be restarted for DKIM records to be loaded and served -- Proactive generation ensures DKIM records are available immediately after startup - -### Testing -After server restart, DKIM records can be queried: -```bash -dig @192.168.190.3 mta._domainkey.central.eu TXT +short -``` - -### Note -The existing dcrouter instance has test domain DKIM records but not for production domains like central.eu. A restart is required to trigger the proactive DKIM generation for configured email domains. +**Note:** DKIM is now handled by `@push.rocks/smartmta`. The `dkimCreator` is a public property on `UnifiedEmailServer`. ## SmartProxy Usage @@ -405,31 +445,11 @@ tap.test('stop', async () => { ## Email Integration with SmartProxy -### Architecture +### Architecture (Post-Migration) - Email traffic is routed through SmartProxy using automatic route generation -- Email server runs on internal ports and receives forwarded traffic from SmartProxy +- smartmta's UnifiedEmailServer runs on internal ports and receives forwarded traffic from SmartProxy - SmartProxy handles external ports (25, 587, 465) and forwards to internal ports - -### Email Route Generation -```typescript -// Email configuration automatically generates SmartProxy routes -emailConfig: { - ports: [25, 587, 465], - hostname: 'mail.example.com', - domainRules: [...] -} - -// Generates routes like: -{ - name: 'smtp-route', - match: { ports: [25] }, - action: { - type: 'forward', - target: { host: 'localhost', port: 10025 } - }, - tls: { mode: 'passthrough' } // STARTTLS handled by email server -} -``` +- smartmta's Rust SMTP bridge handles SMTP protocol processing ### Port Mapping - External port 25 → Internal port 10025 (SMTP) @@ -437,679 +457,8 @@ emailConfig: { - External port 465 → Internal port 10465 (SMTPS) ### TLS Handling -- Ports 25 and 587: Use 'passthrough' mode (STARTTLS handled by email server) +- Ports 25 and 587: Use 'passthrough' mode (STARTTLS handled by smartmta) - Port 465: Use 'terminate' mode (SmartProxy handles TLS termination) -- Domain-specific TLS can be configured per email rule - -## SMTP Test Migration - -### Test Framework -- Tests migrated from custom framework to @push.rocks/tapbundle -- Each test file is self-contained with its own server lifecycle management -- Test files use pattern `test.*.ts` for automatic discovery by tstest - -### Server Lifecycle -- SMTP server uses `listen()` method to start (not `start()`) -- SMTP server uses `close()` method to stop (not `stop()` or `destroy()`) -- Server loader module manages server lifecycle for tests - -### Test Structure -```typescript -import { expect, tap } from '@push.rocks/tapbundle'; -import { startTestServer, stopTestServer } from '../server.loader.js'; - -const TEST_PORT = 2525; -const TEST_TIMEOUT = 10000; - -tap.test('prepare server', async () => { - await startTestServer(); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('test name', async (tools) => { - const done = tools.defer(); - // test implementation - done.resolve(); -}); - -tap.test('cleanup server', async () => { - await stopTestServer(); -}); - -tap.start(); -``` - -### Common Issues and Solutions -1. **Multi-line SMTP responses**: Handle response buffering carefully, especially for EHLO -2. **Timing issues**: Use proper state management instead of string matching -3. **ES Module imports**: Use `import` statements, not `require()` -4. **Server cleanup**: Always close connections properly to avoid hanging tests -5. **Response buffer management**: Clear the response buffer after processing each state to avoid false matches from previous responses. Use specific response patterns (e.g., '250 OK' instead of just '250') to avoid ambiguity. - -### SMTP Protocol Testing -- Server generates self-signed certificates automatically for testing -- Default test port is 2525 -- Connection timeout is typically 10 seconds -- Always check for complete SMTP responses (ending with space after code) - -## SMTP Implementation Findings (2025-05-25) - -### Fixed Issues - -1. **AUTH Mechanism Implementation** - - The server-side AUTH command handler was incomplete - - Implemented `handleAuthPlain` with proper PLAIN authentication flow - - Implemented `handleAuthLogin` with state-based LOGIN authentication flow - - Added `validateUser` function to test server configuration - - AUTH tests now expect STARTTLS instead of direct TLS (`secure: false` with `requireTLS: true`) - -2. **TLS Connection Timeout Handling** - - For secure connections, the client was waiting for 'connect' event instead of 'secureConnect' - - Fixed in `ConnectionManager.establishSocket()` to use the appropriate event based on connection type - - This prevents indefinite hangs during TLS handshake failures - -3. **STARTTLS Server Implementation** - - Removed incorrect `(tlsSocket as any)._start()` call which is client-side only - - Server-side TLS sockets handle handshake automatically when data arrives - - The `_start()` method caused Node.js assertion failure: `wrap->is_client()` - -4. **Edge Case Test Patterns** - - Tests using non-existent `smtpClient.connect()` method - use `verify()` instead - - SMTP servers must handle DATA mode properly by processing lines individually - - Empty/minimal server responses need to be valid SMTP codes (e.g., "250 OK\r\n") - - Out-of-order pipelined responses break SMTP protocol - responses must be in order - -### Common Test Patterns - -1. **Connection Testing** - ```typescript - const verified = await smtpClient.verify(); - expect(verified).toBeTrue(); - ``` - -2. **Server Data Handling** - ```typescript - socket.on('data', (data) => { - const lines = data.toString().split('\r\n'); - lines.forEach(line => { - if (!line && lines[lines.length - 1] === '') return; - // Process each line individually - }); - }); - ``` - -3. **Authentication Setup** - ```typescript - auth: { - required: true, - methods: ['PLAIN', 'LOGIN'], - validateUser: async (username, password) => { - return username === 'testuser' && password === 'testpass'; - } - } - ``` - -### Progress Tracking -- Fixed 8 tests total (as of 2025-05-25) -- Fixed 8 additional tests (as of 2025-05-26): - - test.cedge-03.protocol-violations.ts - - test.cerr-03.network-failures.ts - - test.cerr-05.quota-exceeded.ts - - test.cerr-06.invalid-recipients.ts - - test.crel-01.reconnection-logic.ts - - test.crel-02.network-interruption.ts - - test.crel-03.queue-persistence.ts -- 26 error logs remaining in `.nogit/testlogs/00err/` -- Performance, additional reliability, RFC compliance, and security tests still need fixes - -## Test Fix Findings (2025-05-26) - -### Common Issues in SMTP Client Tests - -1. **DATA Phase Handling in Test Servers** - - Test servers must properly handle DATA mode - - Need to track when in DATA mode and look for the terminating '.' - - Multi-line data must be processed line by line - ```typescript - let inData = false; - socket.on('data', (data) => { - const lines = data.toString().split('\r\n'); - lines.forEach(line => { - if (inData && line === '.') { - socket.write('250 OK\r\n'); - inData = false; - } else if (line === 'DATA') { - socket.write('354 Send data\r\n'); - inData = true; - } - }); - }); - ``` - -2. **Import Issues** - - `createSmtpClient` should be imported from `ts/mail/delivery/smtpclient/index.js` - - Test server functions: use `startTestServer`/`stopTestServer` (not `startTestSmtpServer`) - - Helper exports `createTestSmtpClient`, not `createSmtpClient` - -3. **SmtpClient API Misconceptions** - - SmtpClient doesn't have methods like `connect()`, `isConnected()`, `getConnectionInfo()` - - Use `verify()` for connection testing - - Use `sendMail()` with Email objects for sending - - Connection management is handled internally - -4. **createSmtpClient is Not Async** - - The factory function returns an SmtpClient directly, not a Promise - - Remove `await` from `createSmtpClient()` calls - -5. **Test Expectations** - - Multi-line SMTP responses may timeout if server doesn't send final line - - Mixed valid/invalid recipients might succeed for valid ones (implementation-specific) - - Network failure tests should use realistic expectations - -6. **Test Runner Requirements** - - Tests using `tap` from '@git.zone/tstest/tapbundle' must call `tap.start()` at the end - - Without `tap.start()`, no tests will be detected or run - - Place `tap.start()` after all `tap.test()` definitions - -7. **Connection Pooling Effects** - - SmtpClient uses connection pooling by default - - Test servers may not receive all messages immediately - - Messages might be queued and sent through different connections - - Adjust test expectations to account for pooling behavior - -## Test Fixing Progress (2025-05-26 Afternoon) - -### Summary -- Total failing tests initially: 35 -- Tests fixed: 35 ✅ -- Tests remaining: 0 - ALL TESTS PASSING! - -### Fixed Tests - Session 2 (7): -1. test.ccm-05.connection-reuse.ts - Fixed performance expectation ✓ -2. test.cperf-05.network-efficiency.ts - Removed pooled client usage ✓ -3. test.cperf-06.caching-strategies.ts - Changed to sequential sending ✓ -4. test.cperf-07.queue-management.ts - Simplified to sequential processing ✓ -5. test.crel-07.resource-cleanup.ts - Complete rewrite to minimal test ✓ -6. test.reputationmonitor.ts - Fixed data accumulation by setting NODE_ENV='test' ✓ -7. Cleaned up stale error log: test__test.reputationmonitor.log (old log from before fix) - -### Fixed Tests - Session 1 (28): -- **Edge Cases (1)**: test.cedge-03.protocol-violations.ts ✓ -- **Error Handling (3)**: cerr-03, cerr-05, cerr-06 ✓ -- **Reliability (6)**: crel-01 through crel-06 ✓ -- **RFC Compliance (7)**: crfc-02 through crfc-08 ✓ -- **Security (10)**: csec-01 through csec-10 ✓ -- **Performance (1)**: cperf-08.dns-caching.ts ✓ - -### Important Notes: -- Error logs are deleted after tests are fixed (per original instruction) -- Tests taking >1 minute usually indicate hanging issues -- Property names: use 'host' not 'hostname' for SmtpClient options -- Always use helpers: createTestSmtpClient, createTestServer -- Always add tap.start() at the end of test files - -### Key Fixes Applied: -- **Data Accumulation**: Set NODE_ENV='test' to prevent loading persisted data between tests -- **Connection Reuse**: Don't expect reuse to always be faster than fresh connections -- **Pooled Clients**: Remove usage - tests expect direct client behavior -- **Port Conflicts**: Use different ports for each test to avoid conflicts -- **Resource Cleanup**: Simplified tests that were too complex and timing-dependent - -## Email Architecture Analysis (2025-05-27) - -### Previous Architecture Issues (NOW RESOLVED) -1. ~~**Scattered Components**: Email functionality spread across multiple DcRouter properties~~ ✅ CONSOLIDATED -2. ~~**Duplicate SMTP Implementations**: EmailSendJob implements raw socket SMTP protocol~~ ✅ FIXED -3. ~~**Complex Setup**: setupUnifiedEmailHandling() is 150+ lines~~ ✅ SIMPLIFIED (now ~30 lines) -4. ~~**No Connection Pooling**: Each outbound email creates new connection~~ ✅ IMPLEMENTED -5. ~~**Orphaned Code**: SmtpPortConfig class exists but is never used~~ ✅ REMOVED - -### Current Architecture (COMPLETED) -All email components are now consolidated under UnifiedEmailServer: -- `domainRouter` - Handles pattern-based routing decisions -- `deliveryQueue` - Manages email queue with retry logic -- `deliverySystem` - Handles multi-mode delivery -- `rateLimiter` - Enforces hierarchical rate limits -- `bounceManager` - Processes bounce notifications -- `ipWarmupManager` - Manages IP warmup process -- `senderReputationMonitor` - Tracks sender reputation -- `dkimCreator` - Handles DKIM key management -- `ipReputationChecker` - Checks IP reputation -- `smtpClients` - Map of pooled SMTP clients - -### Email Traffic Flow -``` -External Port → SmartProxy → Internal Port → UnifiedEmailServer → Processing - 25 ↓ 10025 ↓ ↓ - 587 Routes 10587 DomainRouter DeliverySystem - 465 10465 ↓ - Queue → SmtpClient - (pooled & reused) -``` - -### Completed Improvements -- ✅ All email components consolidated under UnifiedEmailServer -- ✅ EmailSendJob uses pooled SmtpClient via `getSmtpClient(host, port)` -- ✅ DcRouter simplified to just manage high-level services -- ✅ Connection pooling implemented for all outbound mail -- ✅ setupUnifiedEmailHandling() simplified to ~30 lines - -## SMTP Client Management (2025-05-27) - -### Centralized SMTP Client in UnifiedEmailServer -- SMTP clients are now managed centrally in UnifiedEmailServer -- Uses connection pooling for efficiency (one pool per destination host:port) -- Classes using UnifiedEmailServer get SMTP clients via `getSmtpClient(host, port)` - -### Implementation Details -```typescript -// In UnifiedEmailServer -private smtpClients: Map = new Map(); // host:port -> client - -public getSmtpClient(host: string, port: number = 25): SmtpClient { - const clientKey = `${host}:${port}`; - let client = this.smtpClients.get(clientKey); - - if (!client) { - client = createPooledSmtpClient({ - host, - port, - secure: port === 465, - connectionTimeout: 30000, - socketTimeout: 120000, - maxConnections: 10, - maxMessages: 1000, - pool: true - }); - this.smtpClients.set(clientKey, client); - } - - return client; -} -``` - -### Usage Pattern -- EmailSendJob and DeliverySystem now use `this.emailServerRef.getSmtpClient(host, port)` -- Connection pooling happens automatically -- Connections are reused across multiple send jobs -- All SMTP clients are closed when UnifiedEmailServer stops - -### Dependency Injection Pattern -- Classes that need UnifiedEmailServer functionality receive it as constructor argument -- This provides access to SMTP clients, DKIM signing, and other shared functionality -- Example: `new EmailSendJob(emailServerRef, email, options)` - -## Email Class Standardization (2025-05-27) - COMPLETED - -### Overview -The entire codebase has been standardized to use the `Email` class as the single data structure for email handling. All Smartmail usage has been eliminated. - -### Key Changes -1. **Email Class Enhanced** - Added compatibility methods: `getSubject()`, `getBody(isHtml)`, `getFrom()` -2. **BounceManager** - Now accepts `Email` objects directly -3. **TemplateManager** - Returns `Email` objects instead of Smartmail -4. **EmailService** - `sendEmail()` accepts `Email` objects -5. **RuleManager** - Uses `SmartRule` instead of `SmartRule` -6. **ApiManager** - Creates `Email` objects for API requests - -### Benefits -- No more Email ↔ Smartmail conversions -- Consistent API throughout (`email.subject` not `smartmail.options.subject`) -- Better performance (no conversion overhead) -- Simpler, more maintainable code - -### Usage Pattern -```typescript -// Create email -const email = new Email({ - from: 'sender@example.com', - to: 'recipient@example.com', - subject: 'Hello', - text: 'World' -}); - -// Pass directly through system -await bounceManager.processBounceEmail(email); -await templateManager.prepareEmail(templateId, context); -await emailService.sendEmail(email); -``` - -## Email Class Design Pattern (2025-05-27) - -### Three-Interface Pattern for Email -The Email system uses three distinct interfaces for clarity and type safety: - -1. **IEmailOptions** - The flexible input interface: - ```typescript - interface IEmailOptions { - to: string | string[]; // Flexible: single or array - cc?: string | string[]; // Optional - attachments?: IAttachment[]; // Optional - skipAdvancedValidation?: boolean; // Constructor-only option - } - ``` - - Used as constructor parameter - - Allows flexible input formats - - Has constructor-only options (like skipAdvancedValidation) - -2. **INormalizedEmail** - The normalized runtime interface: - ```typescript - interface INormalizedEmail { - to: string[]; // Always an array - cc: string[]; // Always an array (empty if not provided) - attachments: IAttachment[]; // Always an array (empty if not provided) - mightBeSpam: boolean; // Always has a value (defaults to false) - } - ``` - - Represents the guaranteed internal structure - - No optional arrays - everything has a default - - Email class implements this interface - -3. **Email class** - The implementation: - ```typescript - export class Email implements INormalizedEmail { - // All INormalizedEmail properties - to: string[]; - cc: string[]; - // ... etc - - // Additional runtime properties - private messageId: string; - private envelopeFrom: string; - } - ``` - - Implements INormalizedEmail - - Adds behavior methods and computed properties - - Handles validation and normalization - -### Benefits of This Pattern: -- **Type Safety**: Email class explicitly implements INormalizedEmail -- **Clear Contracts**: Input vs. runtime structure is explicit -- **Flexibility**: IEmailOptions allows various input formats -- **Consistency**: INormalizedEmail guarantees structure -- **Validation**: Constructor validates and normalizes - -### Usage: -```typescript -// Input with flexible options -const options: IEmailOptions = { - from: 'sender@example.com', - to: 'recipient@example.com', // Single string - subject: 'Hello', - text: 'World' -}; - -// Creates normalized Email instance -const email = new Email(options); - -// email.to is guaranteed to be string[] -email.to.forEach(recipient => { - // No need to check if it's an array -}); - -// Convert back to options format -const optionsAgain = email.toEmailOptions(); -``` - -### Template Email Creation (2025-05-27) -The Email class now supports template creation without recipients: -- IEmailOptions 'to' field is now optional (for templates) -- Email constructor allows creation without recipients -- Recipients are added later when the email is actually sent - -```typescript -// Template creation (no recipients) -const emailOptions: IEmailOptions = { - from: 'noreply@example.com', - subject: 'Welcome {{name}}', - text: 'Hello {{name}}!', - // 'to' is omitted for templates - variables: { name: 'User' } -}; - -const templateEmail = new Email(emailOptions); -// templateEmail.to is an empty array [] - -// Later, when sending: -templateEmail.to = ['recipient@example.com']; -``` - -## Email Architecture Consolidation Completed (2025-05-27) - -### Summary -The email architecture consolidation has been fully completed. Contrary to the initial analysis, the architecture was already well-organized: - -1. **UnifiedEmailServer already contains all components** - No scattered components in DcRouter -2. **EmailSendJob already uses pooled SmtpClient** - No duplicate SMTP implementations -3. **setupUnifiedEmailHandling() is simple** - Only ~30 lines, not 150+ -4. **Connection pooling already implemented** - Via `getSmtpClient(host, port)` -5. **SmtpPortConfig doesn't exist** - No orphaned code found - -### Key Architecture Points -- DcRouter has single `emailServer?: UnifiedEmailServer` property -- All email functionality encapsulated in UnifiedEmailServer -- Dependency injection pattern used throughout (e.g., EmailSendJob receives UnifiedEmailServer reference) -- Pooled SMTP clients managed centrally in UnifiedEmailServer -- Clean separation of concerns between routing (DcRouter) and email handling (UnifiedEmailServer) - -## Email Router Architecture Decision (2025-05-28) - -### Single Router Class -- **Important**: We will have only ONE router class, not two -- The existing `DomainRouter` will be evolved into `EmailRouter` -- This avoids confusion and redundancy -- Use `git mv` to rename and preserve git history -- Extend it to support the new match/action pattern inspired by SmartProxy -- Maintain backward compatibility for legacy domain-based rules - -### Benefits of Single Router -- Clear, single source of truth for routing logic -- No confusion about which router to use -- Preserved git history and gradual migration path -- Supports all match criteria (not just domains) - -## Email Routing Architecture (2025-05-27) - -### Current Routing Capabilities -1. **Pattern-based routing** - DomainRouter matches email addresses against patterns -2. **Three processing modes**: - - `mta` - Programmatic processing with optional DKIM signing - - `process` - Store-and-forward with content scanning - - `forward` - Direct forwarding (NOT YET IMPLEMENTED) -3. **Default routing** - Fallback for unmatched patterns -4. **Basic caching** - LRU cache for routing decisions - -### Routing Flow -```typescript -// Current flow in UnifiedEmailServer.processEmailByMode() -1. Email arrives → DomainRouter.matchRule(recipient) -2. Apply matched rule or default -3. Process based on mode: - - mta: Apply DKIM, log, return - - process: Scan content, apply transformations, queue - - forward: ERROR - Not implemented -``` - -### Missing Routing Features -1. **No forwarding implementation** - Forward mode throws error -2. **Limited matching** - Only email address patterns, no regex -3. **No conditional routing** - Can't route based on subject, size, etc. -4. **No load balancing** - Single destination per rule -5. **No failover** - No backup routes -6. **Basic transformations** - Only header additions - -### Key Files for Routing -- `ts/mail/routing/classes.domain.router.ts` - Pattern matching engine -- `ts/mail/routing/classes.unified.email.server.ts` - processEmailByMode() -- `ts/mail/routing/classes.email.config.ts` - Rule interfaces -- `ts/mail/delivery/classes.delivery.system.ts` - Delivery execution - -## Configuration System Cleanup (2025-05-27) - COMPLETED - -### Overview -The `ts/config/` directory cleanup has been completed. Removed ~500+ lines of unused legacy configuration code. - -### Changes Made -✅ **Removed Files:** -- `base.config.ts` - All unused base interfaces -- `platform.config.ts` - Completely unused platform config -- `email.config.ts` - Deprecated email configuration -- `email.port.mapping.ts` - Unused port mapping utilities -- `schemas.ts` - Removed all schemas except SMS -- `sms.config.ts` - Moved to SMS module - -✅ **SMS Configuration Moved:** -- Created `ts/sms/config/sms.config.ts` - ISmsConfig interface -- Created `ts/sms/config/sms.schema.ts` - Validation schema -- Updated SmsService to import from new location - -✅ **Kept:** -- `validator.ts` - Generic validation utility (might move to utils later) -- `index.ts` - Now only exports ConfigValidator - -### Result -- Config directory now contains only 2 files (validator.ts, index.ts) -- SMS configuration is self-contained in SMS module -- All deprecated email configuration removed -- Build passes successfully - -## Per-Domain Rate Limiting (2025-05-29) - COMPLETED - -### Overview -Per-domain rate limiting has been implemented in the UnifiedRateLimiter. Each email domain can have its own rate limits that override global limits. - -### Implementation Details -1. **UnifiedRateLimiter Enhanced:** - - Added `domains` property to IHierarchicalRateLimits - - Added `domainCounters` Map for tracking domain-specific counters - - Added `checkDomainMessageLimit()` method - - Added `applyDomainLimits()`, `removeDomainLimits()`, `getDomainLimits()` methods - -2. **Domain Rate Limit Configuration:** - ```typescript - interface IEmailDomainConfig { - domain: string; - rateLimits?: { - outbound?: { - messagesPerMinute?: number; - messagesPerHour?: number; // Note: Hour/day limits need additional implementation - messagesPerDay?: number; - }; - inbound?: { - messagesPerMinute?: number; - connectionsPerIp?: number; - recipientsPerMessage?: number; - }; - }; - } - ``` - -3. **Automatic Application:** - - UnifiedEmailServer applies domain rate limits during startup - - `applyDomainRateLimits()` method converts domain config to rate limiter format - - Domain limits override pattern and global limits - -4. **Usage Pattern:** - ```typescript - // Domain configuration with rate limits - { - domain: 'high-volume.com', - dnsMode: 'internal-dns', - rateLimits: { - outbound: { - messagesPerMinute: 200 // Higher than global limit - }, - inbound: { - recipientsPerMessage: 100 // Higher recipient limit - } - } - } - ``` - -5. **Rate Limit Precedence:** - - Domain-specific limits (highest priority) - - Pattern-specific limits - - Global limits (lowest priority) - -### Integration Status -- ✅ Rate limiter supports per-domain limits -- ✅ UnifiedEmailServer applies domain limits on startup -- ✅ Domain limits properly override global/pattern limits -- ✅ SMTP server handlers now enforce rate limits (COMPLETED 2025-05-29) -- ⚠️ Hour/day limits need additional implementation in rate limiter - -### SMTP Handler Integration (2025-05-29) - COMPLETED -Rate limiting is now fully integrated into SMTP server handlers: - -1. **UnifiedEmailServer Enhancement:** - - Added `getRateLimiter()` method to provide access to the rate limiter - -2. **ConnectionManager Integration:** - - Replaced custom rate limiting with UnifiedRateLimiter - - Now uses `rateLimiter.recordConnection(ip)` for all connection checks - - Maintains local IP tracking for resource cleanup only - -3. **CommandHandler Integration:** - - `handleMailFrom()`: Checks message rate limits with domain context - - `handleRcptTo()`: Enforces recipient limits per message - - `handleAuth*()`: Records authentication failures and blocks after threshold - - Error handling: Records syntax/command errors and blocks after threshold - -4. **SMTP Response Codes:** - - `421`: Temporary rate limit (client should retry later) - - `451`: Temporary recipient rejection - - `421 Too many errors`: IP blocked due to excessive errors - - `421 Too many authentication failures`: IP blocked due to auth failures - -### Next Steps -The only remaining item is implementing hour/day rate limits in the UnifiedRateLimiter, which would require: -1. Additional counters for hourly and daily windows -2. Separate tracking for these longer time periods -3. Cleanup logic for expired hourly/daily counters - -## DNS Architecture Refactoring (2025-05-30) - COMPLETED - -### Overview -The DNS functionality has been refactored from UnifiedEmailServer to a dedicated DnsManager class for better discoverability and separation of concerns. - -### Key Changes -1. **Renamed DnsValidator to DnsManager:** - - Extended functionality to handle both validation and creation of DNS records - - Added `ensureDnsRecords()` as the main entry point - - Moved DNS record creation logic from UnifiedEmailServer - -2. **DnsManager Responsibilities:** - - Validate DNS configuration for all modes (forward, internal-dns, external-dns) - - Create DNS records for internal-dns domains - - Create DKIM records for all domains (when DKIMCreator is provided) - - Store DNS records in StorageManager for persistence - -3. **DNS Record Creation Flow:** - ```typescript - // In UnifiedEmailServer - const dnsManager = new DnsManager(this.dcRouter); - await dnsManager.ensureDnsRecords(domainConfigs, this.dkimCreator); - ``` - -4. **Testing Pattern for DNS:** - - Mock the DNS server in tests by providing a mock `registerHandler` function - - Store handlers in a Map with key format: `${domain}:${types.join(',')}` - - Retrieve handlers with key format: `${domain}:${type}` - - Example mock implementation: - ```typescript - this.dnsServer = { - registerHandler: (name: string, types: string[], handler: () => any) => { - const key = `${name}:${types.join(',')}`; - this.dnsHandlers.set(key, handler); - } - }; - ``` - -### Benefits -- DNS functionality is now easily discoverable in DnsManager -- Clear separation between DNS management and email server logic -- UnifiedEmailServer is simpler and more focused -- All DNS-related tests pass successfully ## SmartMetrics Integration (2025-06-12) - COMPLETED @@ -1366,9 +715,8 @@ Located in `ts/cache/documents/`: |-------|---------|-------------| | `CachedEmail` | Email queue items | 30 days | | `CachedIPReputation` | IP reputation lookups | 24 hours | -| `CachedBounce` | Bounce records | 30 days | -| `CachedSuppression` | Suppression list | 30 days / permanent | -| `CachedDKIMKey` | DKIM key pairs | 90 days | + +Note: CachedBounce, CachedSuppression, and CachedDKIMKey were removed in the smartmta migration (smartmta handles its own persistence for those). ### Usage Pattern ```typescript diff --git a/readme.md b/readme.md index 35ceeef..ded34c3 100644 --- a/readme.md +++ b/readme.md @@ -2,9 +2,9 @@ ![](https://code.foss.global/serve.zone/docs/raw/branch/main/dcrouter.png) -**dcrouter: A powerful traffic router designed to be the gateway for your datacenter.** 🚀 +**dcrouter: The all-in-one gateway for your datacenter.** 🚀 -A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS protocols, and RADIUS authentication. Designed for enterprises requiring robust traffic management, automatic certificate provisioning, and enterprise-grade email infrastructure. +A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, and RADIUS protocols. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, and enterprise-grade email infrastructure — all from a single process. ## Issue Reporting and Security @@ -16,77 +16,77 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community - [Installation](#installation) - [Quick Start](#quick-start) - [Architecture](#architecture) -- [Configuration](#configuration) -- [Socket-Handler Mode](#socket-handler-mode) +- [Configuration Reference](#configuration-reference) +- [HTTP/HTTPS & TCP/SNI Routing](#httphttps--tcpsni-routing) - [Email System](#email-system) -- [SmartProxy Routing](#smartproxy-routing) +- [DNS Server](#dns-server) - [RADIUS Server](#radius-server) -- [Storage System](#storage-system) +- [Storage & Caching](#storage--caching) - [Security Features](#security-features) - [OpsServer Dashboard](#opsserver-dashboard) - [API Reference](#api-reference) -- [Examples](#examples) +- [Sub-Modules](#sub-modules) - [Testing](#testing) -- [Troubleshooting](#troubleshooting) +- [License and Legal Information](#license-and-legal-information) ## Features -### 🌐 **Universal Traffic Router** -- **HTTP/HTTPS routing** with pattern matching and virtual hosts -- **TCP/SNI proxy** for any protocol with TLS termination/passthrough -- **DNS server** with authoritative and dynamic record management -- **Multi-protocol support** on the same infrastructure +### 🌐 Universal Traffic Router +- **HTTP/HTTPS routing** with domain matching, path-based forwarding, and automatic TLS +- **TCP/SNI proxy** for any protocol with TLS termination or passthrough +- **DNS server** with authoritative zones, dynamic record management, and DNS-over-HTTPS +- **Multi-protocol support** on the same infrastructure via [SmartProxy](https://code.foss.global/push.rocks/smartproxy) -### 🔒 **Enterprise Security** -- **Automatic TLS certificates** via ACME with DNS-01 challenges -- **IP reputation checking** and real-time threat detection -- **Content scanning** for spam, viruses, and malicious content -- **Comprehensive security logging** with correlation tracking - -### 📧 **Complete Email Infrastructure** +### 📧 Complete Email Infrastructure - **Multi-domain SMTP server** on standard ports (25, 587, 465) -- **Pattern-based email routing** with four processing modes (forward, process, deliver, reject) -- **DKIM, SPF, DMARC** authentication and verification -- **Enterprise deliverability** with IP warmup and reputation management -- **Bounce handling** with suppression lists +- **Pattern-based email routing** with four action types: forward, process, deliver, reject +- **DKIM signing & verification**, SPF, DMARC authentication stack +- **Enterprise deliverability** with IP warmup schedules and sender reputation tracking +- **Bounce handling** with automatic suppression lists +- **Hierarchical rate limiting** — global, per-domain, per-sender -### 📡 **RADIUS Server** +### 🔒 Enterprise Security +- **Automatic TLS certificates** via ACME with Cloudflare DNS-01 challenges +- **IP reputation checking** with caching and configurable thresholds +- **Content scanning** for spam, viruses, and malicious attachments +- **Security event logging** with structured audit trails + +### 📡 RADIUS Server - **MAC Authentication Bypass (MAB)** for network device authentication -- **VLAN assignment** based on MAC address or OUI patterns -- **RADIUS accounting** for session tracking and billing -- **OpsServer API integration** for real-time management +- **VLAN assignment** based on exact MAC, OUI prefix, or wildcard patterns +- **RADIUS accounting** for session tracking, traffic metering, and billing +- **Real-time management** via OpsServer API -### ⚡ **High Performance** -- **Connection pooling** and efficient resource management -- **Load balancing** with automatic failover -- **Rate limiting** at multiple levels -- **Real-time metrics** and monitoring +### ⚡ High Performance +- **Rust-powered proxy engine** via SmartProxy for maximum throughput +- **Connection pooling** for outbound SMTP and backend services +- **Socket-handler mode** — direct socket passing eliminates internal port hops +- **Real-time metrics** via SmartMetrics (CPU, memory, connections, throughput) -### 💾 **Flexible Storage System** -- **Multiple storage backends**: filesystem, custom functions, or memory -- **Unified storage interface** for all components -- **Automatic data migration** between backends -- **Persistent configuration** for domains, routes, and security data +### 💾 Persistent Storage & Caching +- **Multiple storage backends**: filesystem, custom functions, or in-memory +- **Embedded cache database** via smartdata + TsmDb (MongoDB-compatible) +- **Automatic TTL-based cleanup** for cached emails, IP reputation, DKIM keys, and more -### 🖥️ **OpsServer Dashboard** -- **Web-based management interface** for real-time monitoring -- **JWT authentication** with secure admin access -- **Live statistics** for connections, email, DNS, and RADIUS -- **Configuration management** via TypedRequest API +### 🖥️ OpsServer Dashboard +- **Web-based management interface** with real-time monitoring +- **JWT authentication** with session persistence +- **Live views** for connections, email queues, DNS queries, RADIUS sessions, and security events +- **Read-only configuration display** — DcRouter is configured through code ## Installation ```bash -npm install @serve.zone/dcrouter --save -# or pnpm add @serve.zone/dcrouter +# or +npm install @serve.zone/dcrouter ``` ### Prerequisites -- Node.js 18+ with ES modules support -- Valid domain with DNS control (for ACME certificates) -- Cloudflare API token (for DNS challenges) +- **Node.js 18+** with ES module support +- Valid domain with DNS control (for ACME certificate automation) +- Cloudflare API token (for DNS-01 challenges) — optional ## Quick Start @@ -99,8 +99,8 @@ const router = new DcRouter({ smartProxyConfig: { routes: [ { - name: 'web-service', - match: { domains: ['example.com'], ports: [443] }, + name: 'web-app', + match: { domains: ['example.com', 'www.example.com'], ports: [443] }, action: { type: 'forward', targets: [{ host: '192.168.1.10', port: 8080 }], @@ -117,10 +117,9 @@ const router = new DcRouter({ }); await router.start(); -console.log('DcRouter started successfully'); ``` -### Basic Email Router +### Basic Email Server ```typescript import { DcRouter } from '@serve.zone/dcrouter'; @@ -129,52 +128,104 @@ const router = new DcRouter({ emailConfig: { ports: [25, 587, 465], hostname: 'mail.example.com', + domains: [ + { + domain: 'example.com', + dnsMode: 'external-dns' + } + ], routes: [ { - name: 'local-mail', + name: 'process-all', match: { recipients: '*@example.com' }, action: { type: 'process', - process: { - scan: true, - dkim: true, - queue: 'normal' - } + process: { scan: true, dkim: true, queue: 'normal' } } } - ], - tls: { - keyPath: './certs/key.pem', - certPath: './certs/cert.pem' - } + ] } }); await router.start(); ``` -### With OpsServer Dashboard +### Full Stack with Dashboard ```typescript import { DcRouter } from '@serve.zone/dcrouter'; const router = new DcRouter({ - // Enable OpsServer for web dashboard - opsServerConfig: { - port: 3000, - admin: { - username: 'admin', - password: 'your-secure-password' - } + // HTTP/HTTPS routing + smartProxyConfig: { + routes: [ + { + name: 'website', + match: { domains: ['example.com'], ports: [443] }, + action: { + type: 'forward', + targets: [{ host: '192.168.1.10', port: 80 }], + tls: { mode: 'terminate', certificate: 'auto' } + } + } + ], + acme: { email: 'ssl@example.com', enabled: true, useProduction: true } }, - // Your routing configuration... - smartProxyConfig: { /* ... */ }, - emailConfig: { /* ... */ } + // Email system + emailConfig: { + ports: [25, 587, 465], + hostname: 'mail.example.com', + domains: [{ domain: 'example.com', dnsMode: 'external-dns' }], + routes: [ + { + name: 'inbound-mail', + match: { recipients: '*@example.com' }, + action: { type: 'process', process: { scan: true, dkim: true, queue: 'normal' } } + } + ] + }, + + // Authoritative DNS + dnsNsDomains: ['ns1.example.com', 'ns2.example.com'], + dnsScopes: ['example.com'], + publicIp: '203.0.113.1', + dnsRecords: [ + { name: 'example.com', type: 'A', value: '203.0.113.1' }, + { name: 'www.example.com', type: 'CNAME', value: 'example.com' } + ], + + // RADIUS authentication + radiusConfig: { + authPort: 1812, + acctPort: 1813, + clients: [ + { name: 'switch-1', ipRange: '192.168.1.0/24', secret: 'radius-secret', enabled: true } + ], + vlanAssignment: { + defaultVlan: 100, + allowUnknownMacs: true, + mappings: [ + { mac: 'aa:bb:cc:dd:ee:ff', vlan: 10, enabled: true }, + { mac: 'aa:bb:cc', vlan: 20, enabled: true } // OUI prefix + ] + }, + accounting: { enabled: true, retentionDays: 30 } + }, + + // Persistent storage + storage: { fsPath: '/var/lib/dcrouter/data' }, + + // Cache database + cacheConfig: { enabled: true, storagePath: '/etc/dcrouter/tsmdb' }, + + // TLS & ACME + tls: { contactEmail: 'admin@example.com' }, + dnsChallenge: { cloudflareApiKey: process.env.CLOUDFLARE_API_KEY } }); await router.start(); -// Dashboard available at http://localhost:3000 +// OpsServer dashboard available at http://localhost:3000 ``` ## Architecture @@ -188,337 +239,267 @@ graph TB SMTP[SMTP Clients] TCP[TCP Clients] DNS[DNS Queries] - RADIUS[RADIUS Clients] + RAD[RADIUS Clients] end subgraph "DcRouter Core" - DcRouter[DcRouter Orchestrator] - SmartProxy[SmartProxy Engine] - EmailServer[Unified Email Server] - DnsServer[DNS Server] - RadiusServer[RADIUS Server] - CertManager[Certificate Manager] - OpsServer[OpsServer Dashboard] + DC[DcRouter Orchestrator] + SP[SmartProxy Engine] + ES[Unified Email Server] + DS[DNS Server] + RS[RADIUS Server] + CM[Certificate Manager] + OS[OpsServer Dashboard] + MM[Metrics Manager] + SM[Storage Manager] + CD[Cache Database] end subgraph "Backend Services" - WebServices[Web Services] - MailServers[Mail Servers] - Databases[Databases] - APIs[Internal APIs] + WEB[Web Services] + MAIL[Mail Servers] + DB[Databases] + API[Internal APIs] end - HTTP --> SmartProxy - TCP --> SmartProxy - SMTP --> EmailServer - DNS --> DnsServer - RADIUS --> RadiusServer + HTTP --> SP + TCP --> SP + SMTP --> ES + DNS --> DS + RAD --> RS - DcRouter --> SmartProxy - DcRouter --> EmailServer - DcRouter --> DnsServer - DcRouter --> RadiusServer - DcRouter --> CertManager - DcRouter --> OpsServer + DC --> SP + DC --> ES + DC --> DS + DC --> RS + DC --> CM + DC --> OS + DC --> MM + DC --> SM + DC --> CD - SmartProxy --> WebServices - SmartProxy --> APIs - EmailServer --> MailServers - EmailServer --> Databases + SP --> WEB + SP --> API + ES --> MAIL + ES --> DB - CertManager -.-> SmartProxy - CertManager -.-> EmailServer + CM -.-> SP + CM -.-> ES ``` ### Core Components -#### **DcRouter Orchestrator** -Central coordination engine that manages all services and provides unified configuration. +| Component | Description | +|-----------|-------------| +| **DcRouter** | Central orchestrator — starts, stops, and coordinates all services | +| **SmartProxy** | High-performance HTTP/HTTPS and TCP/SNI proxy with route-based config | +| **UnifiedEmailServer** | Full SMTP server with pattern-based routing, DKIM, queue management | +| **DNS Server** | Authoritative DNS with dynamic records, DKIM TXT auto-generation | +| **RADIUS Server** | Network authentication with MAB, VLAN assignment, and accounting | +| **OpsServer** | Web dashboard + TypedRequest API for monitoring and management | +| **MetricsManager** | Real-time metrics collection (CPU, memory, email, DNS, security) | +| **StorageManager** | Pluggable key-value storage (filesystem, custom, or in-memory) | +| **CacheDb** | Embedded MongoDB-compatible database for persistent caching | -#### **SmartProxy Engine** -High-performance HTTP/HTTPS and TCP/SNI proxy with: -- Pattern-based routing -- TLS termination/passthrough -- Load balancing -- Connection pooling +## Configuration Reference -#### **Unified Email Server** -Enterprise-grade SMTP server with: -- Multi-domain support -- Pattern-based routing -- Four processing modes (forward, process, deliver, reject) -- Complete authentication stack (DKIM, SPF, DMARC) - -#### **DNS Server** -Authoritative DNS server with: -- Dynamic record management -- DNS-over-HTTPS (DoH) support -- ACME DNS-01 challenge handling - -#### **RADIUS Server** -Network authentication server with: -- MAC Authentication Bypass (MAB) -- VLAN assignment -- Accounting support - -#### **Certificate Manager** -Automatic TLS certificate provisioning via ACME with DNS-01 challenges. - -#### **OpsServer Dashboard** -Web-based management interface with: -- JWT-secured API -- Real-time statistics -- Configuration management - -## Configuration - -### Complete Configuration Interface +### `IDcRouterOptions` ```typescript interface IDcRouterOptions { - // SmartProxy configuration for HTTP/HTTPS/TCP routing - smartProxyConfig?: { - routes: IRouteConfig[]; - acme?: IAcmeConfig; - allowSessionTicket?: boolean; - }; + // ── Traffic Routing ──────────────────────────────────────────── + /** SmartProxy config for HTTP/HTTPS and TCP/SNI routing */ + smartProxyConfig?: ISmartProxyOptions; - // Email system configuration + // ── Email ────────────────────────────────────────────────────── + /** Unified email server configuration */ emailConfig?: { - ports: number[]; - hostname: string; - domains?: IEmailDomainConfig[]; // Domain infrastructure setup - routes: IEmailRoute[]; // Route-based email handling - auth?: IAuthConfig; - tls?: ITlsConfig; + ports: number[]; // e.g. [25, 587, 465] + hostname: string; // e.g. 'mail.example.com' + domains: IEmailDomainConfig[]; // Domain infrastructure + routes: IEmailRoute[]; // Routing rules + useSocketHandler?: boolean; // Direct socket passing (no port binding) + auth?: { required?: boolean; methods?: ('PLAIN'|'LOGIN'|'OAUTH2')[]; users?: Array<{username: string; password: string}> }; + tls?: { certPath?: string; keyPath?: string; caPath?: string }; maxMessageSize?: number; - rateLimits?: IRateLimitConfig; - useSocketHandler?: boolean; // Enable socket-handler mode (no port binding) - defaults?: { // Global defaults for all domains + defaults?: { dnsMode?: 'forward' | 'internal-dns' | 'external-dns'; - dkim?: IDkimConfig; - rateLimits?: IRateLimitConfig; + dkim?: IEmailDomainConfig['dkim']; + rateLimits?: IEmailDomainConfig['rateLimits']; }; }; - // DNS server configuration - dnsServerConfig?: { - port?: number; - authoritative?: boolean; - records?: IDnsRecord[]; + /** Custom email port mapping overrides */ + emailPortConfig?: { + portMapping?: Record; + portSettings?: Record; + receivedEmailsPath?: string; }; - // DNS domain for automatic DNS-over-HTTPS setup - dnsDomain?: string; // e.g., 'dns.example.com' + // ── DNS ──────────────────────────────────────────────────────── + /** Nameserver domains — get A records automatically */ + dnsNsDomains?: string[]; // e.g. ['ns1.example.com', 'ns2.example.com'] + /** Domains this server is authoritative for */ + dnsScopes?: string[]; // e.g. ['example.com'] + /** Public IP for NS A records */ + publicIp?: string; + /** Ingress proxy IPs (hides real server IP) */ + proxyIps?: string[]; + /** Custom DNS records */ + dnsRecords?: Array<{ + name: string; + type: 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS' | 'SOA'; + value: string; + ttl?: number; + useIngressProxy?: boolean; + }>; - // DNS nameserver domains (enables authoritative DNS) - dnsNsDomains?: string[]; // e.g., ['ns1.example.com', 'ns2.example.com'] - - // RADIUS server configuration + // ── RADIUS ───────────────────────────────────────────────────── + /** RADIUS server for network authentication */ radiusConfig?: { - port?: number; - secret: string; - clients?: IRadiusClient[]; - macAuth?: IMacAuthConfig; - vlanAssignment?: IVlanAssignment[]; + authPort?: number; // default: 1812 + acctPort?: number; // default: 1813 + clients: IRadiusClient[]; + vlanAssignment?: IVlanManagerConfig; + accounting?: { enabled: boolean; retentionDays?: number }; }; - // OpsServer configuration - opsServerConfig?: { - port?: number; - admin: { - username: string; - password: string; - }; - }; - - // TLS and certificate configuration + // ── TLS & Certificates ──────────────────────────────────────── tls?: { contactEmail: string; - domain: string; + domain?: string; + certPath?: string; + keyPath?: string; }; + dnsChallenge?: { cloudflareApiKey?: string }; - // DNS challenge configuration - dnsChallenge?: { - cloudflareApiKey: string; - }; - - // Storage configuration + // ── Storage & Caching ───────────────────────────────────────── storage?: { - fsPath?: string; // Filesystem storage path - readFunction?: (key: string) => Promise; // Custom read function - writeFunction?: (key: string, value: string) => Promise; // Custom write function + fsPath?: string; + readFunction?: (key: string) => Promise; + writeFunction?: (key: string, value: string) => Promise; + }; + cacheConfig?: { + enabled?: boolean; // default: true + storagePath?: string; // default: '/etc/dcrouter/tsmdb' + dbName?: string; // default: 'dcrouter' + cleanupIntervalHours?: number; // default: 1 + ttlConfig?: { + emails?: number; // default: 30 days + ipReputation?: number; // default: 1 day + bounces?: number; // default: 30 days + dkimKeys?: number; // default: 90 days + suppression?: number; // default: 30 days + }; }; } ``` -### Route Configuration +## HTTP/HTTPS & TCP/SNI Routing + +DcRouter uses [SmartProxy](https://code.foss.global/push.rocks/smartproxy) for all HTTP/HTTPS and TCP/SNI routing. Routes are pattern-matched by domain, port, or both. + +### HTTPS with Auto-TLS ```typescript -interface IRouteConfig { - name: string; - priority?: number; - match: { - domains?: string[]; - ports?: number | number[] | { from: number; to: number }[]; - }; +{ + name: 'api-gateway', + match: { domains: ['api.example.com'], ports: [443] }, action: { - type: 'forward' | 'redirect' | 'serve'; - targets?: Array<{ - host: string; - port: number | 'preserve' | ((context: any) => number); - }>; - tls?: { - mode: 'terminate' | 'passthrough'; - certificate?: 'auto' | string; - }; - security?: { - ipAllowList?: string[]; - ipBlockList?: string[]; - }; - }; + type: 'forward', + targets: [{ host: '192.168.1.20', port: 8080 }], + tls: { mode: 'terminate', certificate: 'auto' } + } } ``` -## Socket-Handler Mode - -DcRouter supports an advanced socket-handler mode that eliminates internal port binding for both DNS and email services. Instead of services listening on internal ports, SmartProxy passes sockets directly to the services. - -### DNS Socket-Handler - -When `dnsDomain` is configured, DcRouter automatically: -- Sets up DNS server for UDP on port 53 -- Creates SmartProxy routes for DNS-over-HTTPS (DoH) on the specified domain -- Uses socket-handler for HTTPS/DoH traffic (no HTTPS port binding) +### TLS Passthrough (SNI Routing) ```typescript -const router = new DcRouter({ - dnsDomain: 'dns.example.com', // Enables DNS with DoH - smartProxyConfig: { - // DNS routes are automatically created +{ + name: 'secure-backend', + match: { domains: ['secure.example.com'], ports: [8443] }, + action: { + type: 'forward', + targets: [{ host: '192.168.1.40', port: 8443 }], + tls: { mode: 'passthrough' } } -}); +} ``` -This creates: -- UDP DNS service on port 53 (standard DNS queries) -- HTTPS routes for `dns.example.com/dns-query` and `dns.example.com/resolve` -- Automatic TLS certificates via Let's Encrypt - -### Email Socket-Handler - -When `useSocketHandler` is enabled in email config: -- Email server doesn't bind to any ports -- SmartProxy passes sockets directly to email handlers -- Reduces latency and resource usage +### TCP Port Range Forwarding ```typescript -const router = new DcRouter({ - emailConfig: { - ports: [25, 587, 465], - hostname: 'mail.example.com', - useSocketHandler: true, // Enable socket-handler mode - routes: [/* email routes */] +{ + name: 'database-cluster', + match: { ports: [{ from: 5432, to: 5439 }] }, + action: { + type: 'forward', + targets: [{ host: '192.168.1.30', port: 'preserve' }], + security: { ipAllowList: ['192.168.1.0/24'] } } -}); +} ``` -### Benefits of Socket-Handler Mode +### HTTP Redirect -1. **Performance**: Eliminates internal port forwarding overhead -2. **Security**: No exposed internal ports -3. **Resource Efficiency**: Fewer open ports and listeners -4. **Simplified Networking**: Direct socket passing -5. **Automatic Configuration**: Routes created automatically - -### Traditional vs Socket-Handler Mode - -**Traditional Mode (default):** -``` -External Port → SmartProxy → Internal Port → Service - 25 → 10025 → Email -``` - -**Socket-Handler Mode:** -``` -External Port → SmartProxy → Socket Handler → Service - 25 → (direct socket) → Email +```typescript +{ + name: 'http-to-https', + match: { ports: [80] }, + action: { type: 'redirect', redirect: { to: 'https://{domain}{path}' } } +} ``` ## Email System +The email system is built around the **UnifiedEmailServer**, which handles SMTP sessions, route matching, delivery queuing, DKIM signing, and all email processing in a single unified component. + ### Email Domain Configuration -DcRouter separates email infrastructure (which domains to handle) from routing logic (how to handle emails): +Domains define _infrastructure_ — how DNS and DKIM are handled for each domain: -#### **DNS Modes** - -**Forward Mode** - Simple mail forwarding without local DNS: +#### Forward Mode +Simple forwarding without local DNS management: ```typescript { domain: 'forwarded.com', dnsMode: 'forward', - dns: { - forward: { - skipDnsValidation: true, - targetDomain: 'mail.target.com' - } - } + dns: { forward: { skipDnsValidation: true, targetDomain: 'mail.target.com' } } } ``` -**Internal DNS Mode** - Use built-in DNS server (requires `dnsDomain` in DcRouter config): +#### Internal DNS Mode +Uses DcRouter's built-in DNS server (requires `dnsNsDomains` + `dnsScopes`): ```typescript { domain: 'mail.example.com', dnsMode: 'internal-dns', - dns: { - internal: { - mxPriority: 10, - ttl: 3600 - } - }, - dkim: { - selector: 'mail2024', - keySize: 2048, - rotateKeys: true, - rotationInterval: 90 - } + dns: { internal: { mxPriority: 10, ttl: 3600 } }, + dkim: { selector: 'mail2024', keySize: 2048, rotateKeys: true, rotationInterval: 90 } } ``` -**External DNS Mode** - Use existing DNS infrastructure: +#### External DNS Mode +Uses existing DNS infrastructure with validation: ```typescript { domain: 'mail.external.com', dnsMode: 'external-dns', - dns: { - external: { - requiredRecords: ['MX', 'SPF', 'DKIM', 'DMARC'] - } - }, + dns: { external: { requiredRecords: ['MX', 'SPF', 'DKIM', 'DMARC'] } }, rateLimits: { - inbound: { - messagesPerMinute: 100, - connectionsPerIp: 10 - } + inbound: { messagesPerMinute: 100, connectionsPerIp: 10 }, + outbound: { messagesPerMinute: 200 } } } ``` -#### **DKIM Management** - -DKIM is always enabled for all domains. Keys are automatically: -- Generated on first use -- Stored persistently via StorageManager -- Rotated based on configuration -- Cleaned up after grace period - ### Email Route Actions -#### **Forward Action** -Routes emails to external SMTP servers. +Routes define _behavior_ — what happens when an email matches: +#### Forward 📤 +Routes emails to an external SMTP server: ```typescript { name: 'forward-to-internal', @@ -528,722 +509,415 @@ Routes emails to external SMTP servers. forward: { host: 'internal-mail.company.com', port: 25, - auth: { - username: 'relay-user', - password: 'relay-pass' - }, - addHeaders: { - 'X-Forwarded-By': 'dcrouter' - } + auth: { user: 'relay-user', pass: 'relay-pass' }, + addHeaders: { 'X-Forwarded-By': 'dcrouter' } } } } ``` -#### **Process Action** -Full Mail Transfer Agent functionality with scanning and delivery queues. - +#### Process ⚙️ +Full MTA processing with content scanning and delivery queues: ```typescript { name: 'process-notifications', match: { recipients: '*@notifications.company.com' }, action: { type: 'process', - process: { - scan: true, - dkim: true, - queue: 'priority' - } + process: { scan: true, dkim: true, queue: 'priority' } } } ``` -#### **Deliver Action** -Local delivery for mailbox storage. - +#### Deliver 📥 +Local mailbox delivery: ```typescript { name: 'deliver-local', - match: { recipients: '*@marketing.company.com' }, - action: { - type: 'deliver' - } + match: { recipients: '*@local.company.com' }, + action: { type: 'deliver' } } ``` -#### **Reject Action** -Reject emails with custom SMTP responses. - +#### Reject 🚫 +Reject with custom SMTP response code: ```typescript { - name: 'reject-spam', - match: { - senders: '*@spam-domain.com', - sizeRange: { min: 1000000 } // > 1MB - }, + name: 'reject-spam-domain', + match: { senders: '*@spam-domain.com', sizeRange: { min: 1000000 } }, action: { type: 'reject', - reject: { - code: 550, - message: 'Message rejected due to policy' - } + reject: { code: 550, message: 'Message rejected due to policy' } } } ``` -### Common Email Routing Patterns +### Route Matching + +Routes support powerful matching criteria: -#### **IP-Based Relay** -Allow internal networks to relay through the server: ```typescript -{ - name: 'office-relay', - priority: 100, - match: { clientIp: ['192.168.0.0/16', '10.0.0.0/8'] }, - action: { - type: 'forward', - forward: { host: 'internal-mail.company.com', port: 25 } - } -} -``` +// Recipient patterns +match: { recipients: '*@example.com' } // All addresses at domain +match: { recipients: 'admin@*' } // "admin" at any domain +match: { senders: ['*@trusted.com', '*@vip.com'] } // Multiple sender patterns -#### **Domain-Based Routing** -Route different domains to different servers: -```typescript -{ - name: 'partner-domain', - match: { recipients: '*@partner.com' }, - action: { - type: 'forward', - forward: { host: 'partner-mail.com', port: 587 } - } -} -``` +// IP-based matching (CIDR) +match: { clientIp: '192.168.0.0/16' } +match: { clientIp: ['10.0.0.0/8', '172.16.0.0/12'] } -#### **Authentication-Based Processing** -Different handling for authenticated vs unauthenticated senders: -```typescript -{ - name: 'authenticated-users', - match: { authenticated: true }, - action: { - type: 'process', - process: { scan: false, dkim: true, queue: 'priority' } - } -}, -{ - name: 'unauthenticated-reject', - match: { authenticated: false }, - action: { - type: 'reject', - reject: { code: 550, message: 'Authentication required' } - } -} -``` - -### Email Security Features - -#### **Route Matching Patterns** - -**Glob Pattern Matching** -```typescript -// Email address patterns -match: { recipients: '*@example.com' } // All addresses at domain -match: { recipients: 'admin@*' } // Admin at any domain -match: { senders: ['*@trusted.com', '*@partner.com'] } // Multiple patterns - -// CIDR IP matching -match: { clientIp: '192.168.0.0/16' } // Private subnet -match: { clientIp: ['10.0.0.0/8', '172.16.0.0/12'] } // Multiple ranges +// Authentication state +match: { authenticated: true } // Header matching -match: { - headers: { - 'X-Priority': 'high', - 'Subject': /urgent|emergency/i - } +match: { headers: { 'X-Priority': 'high', 'Subject': /urgent|emergency/i } } + +// Size and content +match: { sizeRange: { min: 1000, max: 5000000 }, hasAttachments: true } +match: { subject: /invoice|receipt/i } +``` + +### Socket-Handler Mode 🔌 + +When `useSocketHandler: true` is set, SmartProxy passes sockets directly to the email server — no internal port binding, lower latency, and fewer open ports: + +``` +Traditional: External Port → SmartProxy → Internal Port → Email Server +Socket Mode: External Port → SmartProxy → (direct socket) → Email Server +``` + +### Email Security Stack + +- **DKIM** — Automatic key generation, signing, and rotation for all domains +- **SPF** — Sender Policy Framework verification on inbound mail +- **DMARC** — Domain-based Message Authentication verification +- **IP Reputation** — Real-time IP reputation checking with caching +- **Content Scanning** — Spam, virus, and attachment scanning +- **Rate Limiting** — Hierarchical limits (global → domain → sender) +- **Bounce Management** — Automatic bounce detection and suppression lists + +### Email Deliverability + +- **IP Warmup Manager** — Multi-stage warmup schedules for new IPs +- **Sender Reputation Monitor** — Per-domain reputation tracking and scoring +- **Connection Pooling** — Pooled outbound SMTP connections per destination + +## DNS Server + +DcRouter includes an authoritative DNS server built on [smartdns](https://code.foss.global/push.rocks/smartdns). It handles standard UDP DNS on port 53 and DNS-over-HTTPS via SmartProxy socket handler. + +### Enabling DNS + +DNS is activated when both `dnsNsDomains` and `dnsScopes` are configured: + +```typescript +const router = new DcRouter({ + dnsNsDomains: ['ns1.example.com', 'ns2.example.com'], + dnsScopes: ['example.com'], + publicIp: '203.0.113.1', + dnsRecords: [ + { name: 'example.com', type: 'A', value: '203.0.113.1' }, + { name: 'www.example.com', type: 'CNAME', value: 'example.com' }, + { name: 'example.com', type: 'MX', value: '10:mail.example.com' }, + { name: 'example.com', type: 'TXT', value: 'v=spf1 a mx ~all' } + ] +}); +``` + +### Automatic DNS Records + +DcRouter auto-generates: +- **NS records** for all domains in `dnsScopes` +- **SOA records** for authoritative zones +- **A records** for nameserver domains (`dnsNsDomains`) +- **MX, SPF, DKIM, DMARC records** for email domains with `internal-dns` mode +- **ACME challenge records** for certificate provisioning + +### Ingress Proxy Support + +When `proxyIps` is configured, A records with `useIngressProxy: true` (default) will use the proxy IP instead of the real server IP — hiding your origin: + +```typescript +{ + proxyIps: ['198.51.100.1', '198.51.100.2'], + dnsRecords: [ + { name: 'example.com', type: 'A', value: '203.0.113.1' }, // Will resolve to 198.51.100.1 + { name: 'ns1.example.com', type: 'A', value: '203.0.113.1', useIngressProxy: false } // Stays real IP + ] } - -// Size and content matching -match: { - sizeRange: { min: 1000, max: 5000000 }, // 1KB to 5MB - hasAttachments: true, - subject: /invoice|receipt/i -} -``` - -#### **Content Scanning** -```typescript -const scanners = [ - { - type: 'spam', - threshold: 5.0, - action: 'tag', - headers: ['X-Spam-Score', 'X-Spam-Status'] - }, - { - type: 'virus', - action: 'reject', - quarantine: true - }, - { - type: 'attachment', - blockedExtensions: ['.exe', '.bat', '.scr'], - maxSize: 25 * 1024 * 1024 // 25MB - } -]; -``` - -## SmartProxy Routing - -### HTTP/HTTPS Routing - -```typescript -const routes = [ - // API routing with path-based forwarding - { - name: 'api-gateway', - match: { - domains: ['api.example.com'], - ports: [443] - }, - action: { - type: 'forward', - targets: [{ host: '192.168.1.20', port: 8080 }], - tls: { - mode: 'terminate', - certificate: 'auto' - } - } - }, - - // Static file serving - { - name: 'static-assets', - match: { - domains: ['cdn.example.com'], - ports: [443] - }, - action: { - type: 'serve', - root: '/var/www/static', - tls: { - mode: 'terminate', - certificate: 'auto' - } - } - } -]; -``` - -### TCP/SNI Routing - -```typescript -const tcpRoutes = [ - // Database connection routing - { - name: 'database-cluster', - match: { - ports: [{ from: 5432, to: 5439 }] - }, - action: { - type: 'forward', - targets: [{ host: '192.168.1.30', port: 'preserve' }], - security: { - ipAllowList: ['192.168.1.0/24'] - } - } - }, - - // SNI-based routing for TLS services - { - name: 'secure-service', - match: { - domains: ['secure.example.com'], - ports: [8443] - }, - action: { - type: 'forward', - targets: [{ host: '192.168.1.40', port: 8443 }], - tls: { - mode: 'passthrough' - } - } - } -]; ``` ## RADIUS Server -DcRouter includes a RADIUS server for network access control: +DcRouter includes a RADIUS server for network access control, built on [smartradius](https://code.foss.global/push.rocks/smartradius). -### Basic RADIUS Configuration +### Configuration ```typescript const router = new DcRouter({ radiusConfig: { - port: 1812, - secret: 'your-radius-secret', + authPort: 1812, + acctPort: 1813, clients: [ { - name: 'switch-1', - ip: '192.168.1.1', - secret: 'client-secret' + name: 'core-switch', + ipRange: '192.168.1.0/24', + secret: 'shared-secret', + enabled: true } - ] - } -}); -``` - -### MAC Authentication Bypass (MAB) - -```typescript -const router = new DcRouter({ - radiusConfig: { - port: 1812, - secret: 'radius-secret', - macAuth: { - enabled: true, - allowedMacs: [ - 'aa:bb:cc:dd:ee:ff', - 'aa:bb:cc:*' // Wildcard for OUI matching - ], + ], + vlanAssignment: { defaultVlan: 100, - guestVlan: 999 + allowUnknownMacs: true, + mappings: [ + { mac: 'aa:bb:cc:dd:ee:ff', vlan: 10, enabled: true }, // Exact MAC + { mac: 'aa:bb:cc', vlan: 20, enabled: true }, // OUI prefix + ] + }, + accounting: { + enabled: true, + retentionDays: 30 } } }); ``` -### VLAN Assignment +### Components -```typescript -const router = new DcRouter({ - radiusConfig: { - secret: 'radius-secret', - vlanAssignment: [ - { - match: { mac: 'aa:bb:cc:*' }, // Vendor OUI match - vlan: 100 - }, - { - match: { mac: 'dd:ee:ff:*' }, - vlan: 200 - }, - { - match: { default: true }, - vlan: 999 // Guest VLAN - } - ] - } -}); -``` +| Component | Purpose | +|-----------|---------| +| **RadiusServer** | Main RADIUS server handling auth + accounting requests | +| **VlanManager** | MAC-to-VLAN mapping with exact, OUI, and wildcard patterns | +| **AccountingManager** | Session tracking, traffic metering, start/stop/interim updates | -## Storage System +### OpsServer API + +RADIUS is fully manageable at runtime via the OpsServer API: +- Client management (add/remove/list NAS devices) +- VLAN mapping CRUD operations +- Session monitoring and forced disconnects +- Accounting summaries and statistics + +## Storage & Caching ### StorageManager -DcRouter includes a flexible storage system that supports multiple backends: +Provides a unified key-value interface with three backends: -#### **Filesystem Storage** ```typescript -const router = new DcRouter({ - storage: { - fsPath: '/var/lib/dcrouter/data' +// Filesystem backend +storage: { fsPath: '/var/lib/dcrouter/data' } + +// Custom backend (Redis, S3, etc.) +storage: { + readFunction: async (key) => await redis.get(key), + writeFunction: async (key, value) => await redis.set(key, value) +} + +// In-memory (development only — data lost on restart) +// Simply omit the storage config +``` + +Used for: DKIM keys, email routes, bounce/suppression lists, IP reputation data, domain configs. + +### Cache Database + +An embedded MongoDB-compatible database (via smartdata + TsmDb) for persistent caching with automatic TTL cleanup: + +```typescript +cacheConfig: { + enabled: true, + storagePath: '/etc/dcrouter/tsmdb', + dbName: 'dcrouter', + cleanupIntervalHours: 1, + ttlConfig: { + emails: 30, // days + ipReputation: 1, // days + bounces: 30, // days + dkimKeys: 90, // days + suppression: 30 // days } -}); +} ``` -#### **Custom Storage Backend** -```typescript -const router = new DcRouter({ - storage: { - readFunction: async (key) => { - // Read from Redis, S3, etc. - return await myDatabase.get(key); - }, - writeFunction: async (key, value) => { - // Write to Redis, S3, etc. - await myDatabase.set(key, value); - } - } -}); -``` - -#### **Memory Storage (Development)** -```typescript -const router = new DcRouter({ - // No storage config = memory storage with warning -}); -``` - -### Storage Usage - -The storage system is used for: -- **DKIM Keys**: `/email/dkim/{domain}/private.key`, `/email/dkim/{domain}/public.key` -- **Email Routes**: `/email/routes/{routeId}.json` -- **Bounce Lists**: `/email/bounces/suppression.json` -- **IP Reputation**: `/security/ip-reputation/{ip}.json` -- **Domain Configs**: `/email/domains/{domain}.json` +Cached document types: `CachedEmail`, `CachedIPReputation`, `CachedBounce`, `CachedSuppression`, `CachedDKIMKey`. ## Security Features ### IP Reputation Checking +Automatic IP reputation checks on inbound connections with configurable caching: + ```typescript -import { IpReputationChecker } from '@serve.zone/dcrouter'; - -const ipChecker = new IpReputationChecker({ - providers: ['spamhaus', 'barracuda', 'surbl'], - cacheTimeout: 3600000, // 1 hour - threshold: 0.7 -}); - -// Check IP reputation -const result = await ipChecker.checkIp('192.0.2.1'); -if (result.isBlocked) { - console.log(`IP blocked: ${result.reason}`); -} +// IP reputation is checked automatically for inbound SMTP connections. +// Results are cached according to cacheConfig.ttlConfig.ipReputation. ``` ### Rate Limiting +Hierarchical rate limits with three levels of specificity: + ```typescript -const router = new DcRouter({ - emailConfig: { - rateLimits: { - inbound: { - messagesPerMinute: 100, - connectionsPerIp: 10, - recipientsPerMessage: 50 - }, - outbound: { - messagesPerHour: 1000, - messagesPerDay: 10000 - } - } +// Global defaults (via emailConfig.defaults.rateLimits) +defaults: { + rateLimits: { + inbound: { messagesPerMinute: 50, connectionsPerIp: 5, recipientsPerMessage: 50 }, + outbound: { messagesPerMinute: 100 } } -}); +} + +// Per-domain overrides (in domain config) +{ + domain: 'high-volume.com', + rateLimits: { + outbound: { messagesPerMinute: 500 } // Override for this domain + } +} +``` + +**Precedence**: Domain-specific > Pattern-specific > Global + +### Content Scanning + +```typescript +action: { + type: 'process', + options: { + contentScanning: true, + scanners: [ + { type: 'spam', threshold: 5.0, action: 'tag' }, + { type: 'virus', action: 'reject' }, + { type: 'attachment', blockedExtensions: ['.exe', '.bat', '.scr'], action: 'reject' } + ] + } +} ``` ## OpsServer Dashboard -The OpsServer provides a web-based management interface: +The OpsServer provides a web-based management interface served on port 3000. It's built with modern web components using [@design.estate/dees-catalog](https://code.foss.global/design.estate/dees-catalog). -### Features +### Dashboard Views -- **Real-time Statistics**: View connections, email throughput, DNS queries, RADIUS sessions -- **Configuration Display**: View current configuration (read-only) -- **Log Viewer**: Access system logs with filtering -- **Security Dashboard**: Monitor threats and blocked connections +| View | Description | +|------|-------------| +| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput | +| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics | +| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents | +| 📜 **Logs** | Real-time log viewer with level filtering and search | +| ⚙️ **Configuration** | Read-only view of current system configuration | +| 🛡️ **Security** | IP reputation, rate limit status, blocked connections | ### API Endpoints -The OpsServer exposes TypedRequest endpoints: +All management is done via TypedRequest over HTTP POST to `/typedrequest`: ```typescript -// Health check -POST /typedrequest { method: 'getHealthStatus' } +// Authentication +{ method: 'adminLogin', data: { username, password } } +{ method: 'verifyIdentity', data: { identity } } -// Server statistics -POST /typedrequest { method: 'getServerStatistics' } +// Statistics +{ method: 'getServerStatistics', data: { identity } } +{ method: 'getCombinedMetrics', data: { identity } } +{ method: 'getHealthStatus', data: { identity } } + +// Email Operations +{ method: 'getQueuedEmails', data: { identity } } +{ method: 'getSentEmails', data: { identity } } +{ method: 'getFailedEmails', data: { identity } } +{ method: 'resendEmail', data: { identity, emailId } } +{ method: 'getBounceRecords', data: { identity } } // Configuration (read-only) -POST /typedrequest { method: 'getConfiguration' } +{ method: 'getConfiguration', data: { identity } } // Logs -POST /typedrequest { method: 'getLogs', data: { level: 'info', limit: 100 } } +{ method: 'getLogs', data: { identity, level, limit } } // RADIUS -POST /typedrequest { method: 'getRadiusSessions' } -POST /typedrequest { method: 'getRadiusClients' } +{ method: 'getRadiusSessions', data: { identity } } +{ method: 'getRadiusClients', data: { identity } } +{ method: 'getRadiusStatistics', data: { identity } } ``` ## API Reference ### DcRouter Class -#### Constructor ```typescript -constructor(options: IDcRouterOptions) +import { DcRouter } from '@serve.zone/dcrouter'; + +const router = new DcRouter(options: IDcRouterOptions); ``` #### Methods -##### `start(): Promise` -Starts all configured services (SmartProxy, email server, DNS server, RADIUS server, OpsServer). +| Method | Description | +|--------|-------------| +| `start(): Promise` | Start all configured services | +| `stop(): Promise` | Gracefully stop all services | +| `updateSmartProxyConfig(config): Promise` | Hot-update SmartProxy routes | +| `updateEmailConfig(config): Promise` | Hot-update email configuration | +| `updateEmailRoutes(routes): Promise` | Update email routing rules at runtime | +| `updateRadiusConfig(config): Promise` | Hot-update RADIUS configuration | +| `getStats(): any` | Get real-time statistics from all services | -##### `stop(): Promise` -Gracefully stops all services. +#### Properties -##### `updateRoutes(routes: IRouteConfig[]): Promise` -Updates SmartProxy routes dynamically. +| Property | Type | Description | +|----------|------|-------------| +| `options` | `IDcRouterOptions` | Current configuration | +| `smartProxy` | `SmartProxy` | SmartProxy instance | +| `emailServer` | `UnifiedEmailServer` | Email server instance | +| `dnsServer` | `DnsServer` | DNS server instance | +| `radiusServer` | `RadiusServer` | RADIUS server instance | +| `storageManager` | `StorageManager` | Storage backend | +| `opsServer` | `OpsServer` | OpsServer/dashboard instance | +| `metricsManager` | `MetricsManager` | Metrics collector | +| `cacheDb` | `CacheDb` | Cache database instance | -##### `updateDomainRules(rules: IDomainRule[]): Promise` -Updates email domain routing rules. +## Sub-Modules -##### `getStats(): IStatsResponse` -Returns real-time statistics for all services. +DcRouter is published as a monorepo with separately-installable interface and web packages: -### Email Service API +| Package | Description | Install | +|---------|-------------|---------| +| [`@serve.zone/dcrouter`](https://www.npmjs.com/package/@serve.zone/dcrouter) | Main package — the full router | `pnpm add @serve.zone/dcrouter` | +| [`@serve.zone/dcrouter-interfaces`](https://www.npmjs.com/package/@serve.zone/dcrouter-interfaces) | TypedRequest interfaces for the OpsServer API | `pnpm add @serve.zone/dcrouter-interfaces` | +| [`@serve.zone/dcrouter-web`](https://www.npmjs.com/package/@serve.zone/dcrouter-web) | Web dashboard components | `pnpm add @serve.zone/dcrouter-web` | -#### `sendEmail(options: IEmailOptions): Promise` -```typescript -const emailId = await router.emailService.sendEmail({ - from: 'sender@example.com', - to: 'recipient@example.com', - subject: 'Test Email', - html: '

Hello World

', - attachments: [] -}); -``` - -#### `getEmailStatus(emailId: string): IEmailStatus` -```typescript -const status = router.emailService.getEmailStatus(emailId); -console.log(status.status); // 'pending', 'sent', 'delivered', 'bounced' -``` - -## Examples - -### Complete Enterprise Setup +You can also import interfaces directly from the main package: ```typescript -import { DcRouter } from '@serve.zone/dcrouter'; - -const router = new DcRouter({ - // OpsServer dashboard - opsServerConfig: { - port: 3000, - admin: { - username: 'admin', - password: process.env.ADMIN_PASSWORD - } - }, - - // HTTP/HTTPS routing - smartProxyConfig: { - routes: [ - // Main website - { - name: 'website', - priority: 100, - match: { domains: ['example.com', 'www.example.com'], ports: [443] }, - action: { - type: 'forward', - targets: [{ host: '192.168.1.10', port: 80 }], - tls: { mode: 'terminate', certificate: 'auto' } - } - }, - - // API services - { - name: 'api', - priority: 110, - match: { domains: ['api.example.com'], ports: [443] }, - action: { - type: 'forward', - targets: [{ host: '192.168.1.20', port: 8080 }], - tls: { mode: 'terminate', certificate: 'auto' } - } - } - ], - - // ACME certificate automation - acme: { - email: 'ssl@example.com', - enabled: true, - useProduction: true, - autoRenew: true - } - }, - - // Enterprise email system - emailConfig: { - ports: [25, 587, 465], - hostname: 'mail.example.com', - - // Domain configuration - domains: [ - { - domain: 'example.com', - dnsMode: 'external-dns', - dkim: { - selector: 'mail', - rotateKeys: true - } - } - ], - - // Email routing rules - routes: [ - // Relay from office network - { - name: 'office-relay', - priority: 100, - match: { clientIp: '192.168.0.0/16' }, - action: { - type: 'forward', - forward: { host: 'internal-mail.example.com', port: 25 } - } - }, - - // Process transactional emails - { - name: 'notifications', - priority: 50, - match: { recipients: '*@notifications.example.com' }, - action: { - type: 'process', - process: { scan: true, dkim: true, queue: 'priority' } - } - }, - - // Default reject - { - name: 'default-reject', - match: { recipients: '*' }, - action: { - type: 'reject', - reject: { code: 550, message: 'Relay denied' } - } - } - ] - }, - - // RADIUS for network devices - radiusConfig: { - port: 1812, - secret: process.env.RADIUS_SECRET, - macAuth: { - enabled: true, - defaultVlan: 100, - guestVlan: 999 - } - }, - - // DNS server for ACME challenges - dnsServerConfig: { - port: 53, - authoritative: true - }, - - // Cloudflare DNS challenges - dnsChallenge: { - cloudflareApiKey: process.env.CLOUDFLARE_API_KEY - }, - - // Persistent storage - storage: { - fsPath: '/var/lib/dcrouter/data' - } -}); - -// Start the router -await router.start(); -console.log('Enterprise DcRouter started'); - -// Monitor statistics -setInterval(() => { - const stats = router.getStats(); - console.log('Active connections:', stats.activeConnections); - console.log('Emails processed:', stats.emailsProcessed); -}, 60000); +import { data, requests } from '@serve.zone/dcrouter/interfaces'; ``` ## Testing -### Comprehensive Test Suite +DcRouter includes a comprehensive test suite with **198 test files** covering all system components: -DcRouter includes a comprehensive test suite with 195 test files covering all aspects of the system: +- **SMTP Protocol** — EHLO, MAIL FROM, RCPT TO, DATA, STARTTLS, AUTH, pipelining +- **Email Routing** — Pattern matching, route priorities, all action types +- **Email Security** — DKIM, SPF, DMARC, content scanning, rate limiting +- **DNS** — Record management, socket handler, validation, mode switching +- **RADIUS** — Authentication, VLAN assignment, accounting +- **Deliverability** — IP warmup, reputation monitoring, bounce management +- **Storage & Cache** — All backends, TTL cleanup, persistence +- **OpsServer** — API authentication, protected endpoints, statistics +- **Integration** — Full end-to-end workflows -#### SMTP Protocol Tests -- **Commands**: EHLO, HELO, MAIL FROM, RCPT TO, DATA, RSET, NOOP, QUIT, VRFY, EXPN, HELP -- **Extensions**: SIZE, PIPELINING, STARTTLS -- **Connection Management**: TLS/plain connections, timeouts, limits, rejection handling -- **Error Handling**: Syntax errors, invalid sequences, temporary/permanent failures -- **Email Processing**: Basic sending, multiple recipients, large emails, invalid addresses -- **Security**: Authentication, rate limiting -- **Performance**: Throughput testing -- **Edge Cases**: Very large emails, special characters - -#### Running Tests +### Running Tests ```bash # Run all tests pnpm test -# Run specific test categories -tsx test/suite/smtpserver_commands/test.cmd-01.ehlo-command.ts +# Run a specific test file +tstest test/test.email.router.ts --verbose -# Run with verbose output -tstest test/test.integration.ts --verbose -``` - -## Troubleshooting - -### Common Issues - -#### Certificate Issues -```bash -# Check certificate status -curl -I https://your-domain.com - -# Verify ACME challenge accessibility -curl http://your-domain.com/.well-known/acme-challenge/test -``` - -#### Email Delivery Issues -```bash -# Test SMTP connectivity -telnet your-server.com 25 - -# Check DKIM record -dig TXT mail._domainkey.your-domain.com - -# Verify SPF record -dig TXT your-domain.com -``` - -#### Email Routing Issues - -**Route Not Matching** -- Check route priority order (higher priority = evaluated first) -- Verify glob patterns: `*@example.com` matches domain, `admin@*` matches user -- Test CIDR notation: `192.168.0.0/16` includes all 192.168.x.x addresses -- Confirm authentication state matches your expectations - -#### DNS Issues -```bash -# Test DNS server -dig @your-server.com your-domain.com - -# Check DNS propagation -dig your-domain.com @8.8.8.8 -``` - -### Performance Tuning - -```typescript -const performanceConfig = { - // Connection limits - maxConnections: 1000, - connectionTimeout: 30000, - - // Email queue settings - emailQueue: { - concurrency: 10, - maxRetries: 3, - retryDelay: 300000 - }, - - // Cache settings - cache: { - ipReputation: { ttl: 3600000 }, // 1 hour - dns: { ttl: 300000 }, // 5 minutes - certificates: { ttl: 86400000 } // 24 hours - } -}; +# Run SMTP protocol suite +tstest test/suite/smtpserver_commands/test.cmd-01.ehlo-command.ts --verbose ``` ## License and Legal Information diff --git a/test/helpers/server.loader.ts b/test/helpers/server.loader.ts deleted file mode 100644 index a8e624a..0000000 --- a/test/helpers/server.loader.ts +++ /dev/null @@ -1,349 +0,0 @@ -import * as plugins from '../../ts/plugins.js'; -import { UnifiedEmailServer } from '../../ts/mail/routing/classes.unified.email.server.js'; -import { createSmtpServer } from '../../ts/mail/delivery/smtpserver/index.js'; -import type { ISmtpServerOptions } from '../../ts/mail/delivery/smtpserver/interfaces.js'; -import type { net } from '../../ts/plugins.js'; - -export interface ITestServerConfig { - port: number; - 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 - */ -export async function startTestServer(config: ITestServerConfig): Promise { - const serverConfig = { - port: config.port || 2525, - hostname: config.hostname || 'localhost', - tlsEnabled: config.tlsEnabled || false, - authRequired: config.authRequired || false, - timeout: config.timeout || 30000, - maxConnections: config.maxConnections || 100, - size: config.size || 10 * 1024 * 1024, // 10MB default - maxRecipients: config.maxRecipients || 100 - }; - - // Create a mock email server for testing - const mockEmailServer = { - processEmailByMode: async (emailData: any) => { - console.log(`📧 [Test Server] Processing email:`, emailData.subject || 'No subject'); - return emailData; - }, - getRateLimiter: () => { - // Return a mock rate limiter for testing - return { - recordConnection: (_ip: string) => ({ allowed: true, remaining: 100 }), - checkConnectionLimit: async (_ip: string) => ({ allowed: true, remaining: 100 }), - checkMessageLimit: (_senderAddress: string, _ip: string, _recipientCount?: number, _pattern?: string, _domain?: string) => ({ allowed: true, remaining: 1000 }), - checkRecipientLimit: async (_session: any) => ({ allowed: true, remaining: 50 }), - recordAuthenticationFailure: async (_ip: string) => {}, - recordAuthFailure: (_ip: string) => false, // Returns whether IP should be blocked - recordSyntaxError: async (_ip: string) => {}, - recordCommandError: async (_ip: string) => {}, - recordError: (_ip: string) => false, // Returns whether IP should be blocked - isBlocked: async (_ip: string) => false, - cleanup: async () => {} - }; - } - } as any; - - // Load test certificates - let key: string; - let cert: string; - - if (serverConfig.tlsEnabled) { - try { - const certPath = config.testCertPath || './test/fixtures/test-cert.pem'; - const keyPath = config.testKeyPath || './test/fixtures/test-key.pem'; - - cert = await plugins.fs.promises.readFile(certPath, 'utf8'); - key = await plugins.fs.promises.readFile(keyPath, 'utf8'); - } catch (error) { - console.warn('⚠️ Failed to load TLS certificates, falling back to self-signed'); - // Generate self-signed certificate for testing - const forge = await import('node-forge'); - const pki = forge.default.pki; - - // Generate key pair - const keys = pki.rsa.generateKeyPair(2048); - - // Create certificate - const certificate = pki.createCertificate(); - certificate.publicKey = keys.publicKey; - certificate.serialNumber = '01'; - certificate.validity.notBefore = new Date(); - certificate.validity.notAfter = new Date(); - certificate.validity.notAfter.setFullYear(certificate.validity.notBefore.getFullYear() + 1); - - const attrs = [{ - name: 'commonName', - value: serverConfig.hostname - }]; - certificate.setSubject(attrs); - certificate.setIssuer(attrs); - certificate.sign(keys.privateKey); - - // Convert to PEM - cert = pki.certificateToPem(certificate); - key = pki.privateKeyToPem(keys.privateKey); - } - } else { - // Always provide a self-signed certificate for non-TLS servers - // This is required by the interface - const forge = await import('node-forge'); - const pki = forge.default.pki; - - // Generate key pair - const keys = pki.rsa.generateKeyPair(2048); - - // Create certificate - const certificate = pki.createCertificate(); - certificate.publicKey = keys.publicKey; - certificate.serialNumber = '01'; - certificate.validity.notBefore = new Date(); - certificate.validity.notAfter = new Date(); - certificate.validity.notAfter.setFullYear(certificate.validity.notBefore.getFullYear() + 1); - - const attrs = [{ - name: 'commonName', - value: serverConfig.hostname - }]; - certificate.setSubject(attrs); - certificate.setIssuer(attrs); - certificate.sign(keys.privateKey); - - // Convert to PEM - cert = pki.certificateToPem(certificate); - key = pki.privateKeyToPem(keys.privateKey); - } - - // SMTP server options - const smtpOptions: ISmtpServerOptions = { - port: serverConfig.port, - hostname: serverConfig.hostname, - key: key, - cert: cert, - maxConnections: serverConfig.maxConnections, - size: serverConfig.size, - maxRecipients: serverConfig.maxRecipients, - socketTimeout: serverConfig.timeout, - connectionTimeout: serverConfig.timeout * 2, - cleanupInterval: 300000, - auth: serverConfig.authRequired ? ({ - required: true, - methods: ['PLAIN', 'LOGIN'] as ('PLAIN' | 'LOGIN' | 'OAUTH2')[], - validateUser: async (username: string, password: string) => { - // Test server accepts these credentials - return username === 'testuser' && password === 'testpass'; - } - } as any) : undefined - }; - - // Create SMTP server - const smtpServer = await createSmtpServer(mockEmailServer, smtpOptions); - - // Start the server - await smtpServer.listen(); - - // Wait for server to be ready - await waitForServerReady(serverConfig.hostname, serverConfig.port); - - console.log(`✅ Test SMTP server started on ${serverConfig.hostname}:${serverConfig.port}`); - - return { - server: mockEmailServer, - smtpServer: smtpServer, - port: serverConfig.port, - hostname: serverConfig.hostname, - config: serverConfig, - startTime: Date.now() - }; -} - -/** - * Stops a test SMTP server - */ -export async function stopTestServer(testServer: ITestServer): Promise { - if (!testServer || !testServer.smtpServer) { - console.warn('⚠️ No test server to stop'); - return; - } - - try { - console.log(`🛑 Stopping test SMTP server on ${testServer.hostname}:${testServer.port}`); - - // Stop the SMTP server - if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') { - await testServer.smtpServer.close(); - } - - // Wait for port to be free - await waitForPortFree(testServer.port); - - console.log(`✅ Test SMTP server stopped`); - } catch (error) { - console.error('❌ Error stopping test server:', error); - throw error; - } -} - -/** - * Wait for server to be ready to accept connections - */ -async function waitForServerReady(hostname: string, port: number, timeout: number = 10000): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - try { - await new Promise((resolve, reject) => { - const socket = plugins.net.createConnection({ port, host: hostname }); - - socket.on('connect', () => { - socket.end(); - resolve(); - }); - - socket.on('error', reject); - - setTimeout(() => { - socket.destroy(); - reject(new Error('Connection timeout')); - }, 1000); - }); - - return; // Server is ready - } catch { - // Server not ready yet, wait and retry - await new Promise(resolve => setTimeout(resolve, 100)); - } - } - - throw new Error(`Server did not become ready within ${timeout}ms`); -} - -/** - * Wait for port to be free - */ -async function waitForPortFree(port: number, timeout: number = 5000): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - const isFree = await isPortFree(port); - if (isFree) { - return; - } - await new Promise(resolve => setTimeout(resolve, 100)); - } - - console.warn(`⚠️ Port ${port} still in use after ${timeout}ms`); -} - -/** - * Check if a port is free - */ -async function isPortFree(port: number): Promise { - return new Promise((resolve) => { - const server = plugins.net.createServer(); - - server.listen(port, () => { - server.close(() => resolve(true)); - }); - - server.on('error', () => resolve(false)); - }); -} - -/** - * Get an available port for testing - */ -export async function getAvailablePort(startPort: number = 25000): Promise { - for (let port = startPort; port < startPort + 1000; port++) { - if (await isPortFree(port)) { - return port; - } - } - throw new Error(`No available ports found starting from ${startPort}`); -} - -/** - * Create test email data - */ -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 || '

This is a test email

', - 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; - port?: number; - hostname?: string; -}): Promise { - 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); - }); -} \ No newline at end of file diff --git a/test/helpers/smtp.client.ts b/test/helpers/smtp.client.ts deleted file mode 100644 index ccd43df..0000000 --- a/test/helpers/smtp.client.ts +++ /dev/null @@ -1,211 +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 = {}): 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, - pool: options.pool || false, // Enable connection pooling - domain: options.domain, // Client domain for EHLO - 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 { - 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 { - 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 { - 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 { - 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 - }; -} \ No newline at end of file diff --git a/test/helpers/utils.ts b/test/helpers/utils.ts deleted file mode 100644 index 0ea6cff..0000000 --- a/test/helpers/utils.ts +++ /dev/null @@ -1,311 +0,0 @@ -import * as plugins from '../../ts/plugins.js'; - -/** - * Test result interface - */ -export interface ITestResult { - success: boolean; - duration: number; - message?: string; - error?: string; - details?: any; -} - -/** - * Test configuration interface - */ -export interface ITestConfig { - host: string; - port: number; - timeout: number; - fromAddress?: string; - toAddress?: string; - [key: string]: any; -} - -/** - * Connect to SMTP server and get greeting - */ -export async function connectToSmtp(host: string, port: number, timeout: number = 5000): Promise { - return new Promise((resolve, reject) => { - const socket = plugins.net.createConnection({ host, port }); - const timer = setTimeout(() => { - socket.destroy(); - reject(new Error(`Connection timeout after ${timeout}ms`)); - }, timeout); - - socket.once('connect', () => { - clearTimeout(timer); - resolve(socket); - }); - - socket.once('error', (error) => { - clearTimeout(timer); - reject(error); - }); - }); -} - -/** - * Send SMTP command and wait for response - */ -export async function sendSmtpCommand( - socket: plugins.net.Socket, - command: string, - expectedCode?: string, - timeout: number = 5000 -): Promise { - return new Promise((resolve, reject) => { - let buffer = ''; - let timer: NodeJS.Timeout; - - const onData = (data: Buffer) => { - buffer += data.toString(); - - // Check if we have a complete response - if (buffer.includes('\r\n')) { - clearTimeout(timer); - socket.removeListener('data', onData); - - if (expectedCode && !buffer.startsWith(expectedCode)) { - reject(new Error(`Expected ${expectedCode}, got: ${buffer.trim()}`)); - } else { - resolve(buffer); - } - } - }; - - timer = setTimeout(() => { - socket.removeListener('data', onData); - reject(new Error(`Command timeout after ${timeout}ms`)); - }, timeout); - - socket.on('data', onData); - socket.write(command + '\r\n'); - }); -} - -/** - * Wait for SMTP greeting - */ -export async function waitForGreeting(socket: plugins.net.Socket, timeout: number = 5000): Promise { - return new Promise((resolve, reject) => { - let buffer = ''; - let timer: NodeJS.Timeout; - - const onData = (data: Buffer) => { - buffer += data.toString(); - - if (buffer.includes('220')) { - clearTimeout(timer); - socket.removeListener('data', onData); - resolve(buffer); - } - }; - - timer = setTimeout(() => { - socket.removeListener('data', onData); - reject(new Error(`Greeting timeout after ${timeout}ms`)); - }, timeout); - - socket.on('data', onData); - }); -} - -/** - * Perform SMTP handshake - */ -export async function performSmtpHandshake( - socket: plugins.net.Socket, - hostname: string = 'test.example.com' -): Promise { - const capabilities: string[] = []; - - // Wait for greeting - await waitForGreeting(socket); - - // Send EHLO - const ehloResponse = await sendSmtpCommand(socket, `EHLO ${hostname}`, '250'); - - // Parse capabilities - const lines = ehloResponse.split('\r\n'); - for (const line of lines) { - if (line.startsWith('250-') || line.startsWith('250 ')) { - const capability = line.substring(4).trim(); - if (capability) { - capabilities.push(capability); - } - } - } - - return capabilities; -} - -/** - * Create multiple concurrent connections - */ -export async function createConcurrentConnections( - host: string, - port: number, - count: number, - timeout: number = 5000 -): Promise { - const connectionPromises = []; - - for (let i = 0; i < count; i++) { - connectionPromises.push(connectToSmtp(host, port, timeout)); - } - - return Promise.all(connectionPromises); -} - -/** - * Close SMTP connection gracefully - */ -export async function closeSmtpConnection(socket: plugins.net.Socket): Promise { - try { - await sendSmtpCommand(socket, 'QUIT', '221'); - } catch { - // Ignore errors during QUIT - } - - socket.destroy(); -} - -/** - * Generate random email content - */ -export function generateRandomEmail(size: number = 1024): string { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 \r\n'; - let content = ''; - - for (let i = 0; i < size; i++) { - content += chars.charAt(Math.floor(Math.random() * chars.length)); - } - - return content; -} - -/** - * Create MIME message - */ -export function createMimeMessage(options: { - from: string; - to: string; - subject: string; - text?: string; - html?: string; - attachments?: Array<{ filename: string; content: string; contentType: string }>; -}): string { - const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`; - const date = new Date().toUTCString(); - - let message = ''; - message += `From: ${options.from}\r\n`; - message += `To: ${options.to}\r\n`; - message += `Subject: ${options.subject}\r\n`; - message += `Date: ${date}\r\n`; - message += `MIME-Version: 1.0\r\n`; - - if (options.attachments && options.attachments.length > 0) { - message += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`; - message += '\r\n'; - - // Text part - if (options.text) { - message += `--${boundary}\r\n`; - message += 'Content-Type: text/plain; charset=utf-8\r\n'; - message += 'Content-Transfer-Encoding: 8bit\r\n'; - message += '\r\n'; - message += options.text + '\r\n'; - } - - // HTML part - if (options.html) { - message += `--${boundary}\r\n`; - message += 'Content-Type: text/html; charset=utf-8\r\n'; - message += 'Content-Transfer-Encoding: 8bit\r\n'; - message += '\r\n'; - message += options.html + '\r\n'; - } - - // Attachments - for (const attachment of options.attachments) { - message += `--${boundary}\r\n`; - message += `Content-Type: ${attachment.contentType}\r\n`; - message += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`; - message += 'Content-Transfer-Encoding: base64\r\n'; - message += '\r\n'; - message += Buffer.from(attachment.content).toString('base64') + '\r\n'; - } - - message += `--${boundary}--\r\n`; - } else if (options.html && options.text) { - const altBoundary = `----=_Alt_${Date.now()}_${Math.random().toString(36).substring(2)}`; - message += `Content-Type: multipart/alternative; boundary="${altBoundary}"\r\n`; - message += '\r\n'; - - // Text part - message += `--${altBoundary}\r\n`; - message += 'Content-Type: text/plain; charset=utf-8\r\n'; - message += 'Content-Transfer-Encoding: 8bit\r\n'; - message += '\r\n'; - message += options.text + '\r\n'; - - // HTML part - message += `--${altBoundary}\r\n`; - message += 'Content-Type: text/html; charset=utf-8\r\n'; - message += 'Content-Transfer-Encoding: 8bit\r\n'; - message += '\r\n'; - message += options.html + '\r\n'; - - message += `--${altBoundary}--\r\n`; - } else if (options.html) { - message += 'Content-Type: text/html; charset=utf-8\r\n'; - message += 'Content-Transfer-Encoding: 8bit\r\n'; - message += '\r\n'; - message += options.html; - } else { - message += 'Content-Type: text/plain; charset=utf-8\r\n'; - message += 'Content-Transfer-Encoding: 8bit\r\n'; - message += '\r\n'; - message += options.text || ''; - } - - return message; -} - -/** - * Measure operation time - */ -export async function measureTime(operation: () => Promise): Promise<{ result: T; duration: number }> { - const startTime = Date.now(); - const result = await operation(); - const duration = Date.now() - startTime; - return { result, duration }; -} - -/** - * Retry operation with exponential backoff - */ -export async function retryOperation( - operation: () => Promise, - maxRetries: number = 3, - initialDelay: number = 1000 -): Promise { - let lastError: Error; - - for (let i = 0; i < maxRetries; i++) { - try { - return await operation(); - } catch (error) { - lastError = error as Error; - if (i < maxRetries - 1) { - const delay = initialDelay * Math.pow(2, i); - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - } - - throw lastError!; -} \ No newline at end of file diff --git a/test/suite/smtpclient_commands/test.ccmd-01.ehlo-helo-sending.ts b/test/suite/smtpclient_commands/test.ccmd-01.ehlo-helo-sending.ts deleted file mode 100644 index a82d06d..0000000 --- a/test/suite/smtpclient_commands/test.ccmd-01.ehlo-helo-sending.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_commands/test.ccmd-02.mail-from-parameters.ts b/test/suite/smtpclient_commands/test.ccmd-02.mail-from-parameters.ts deleted file mode 100644 index 1b5b382..0000000 --- a/test/suite/smtpclient_commands/test.ccmd-02.mail-from-parameters.ts +++ /dev/null @@ -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 ', - to: 'Jane Smith ', - 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: '

UTF-8 content: 你好世界 🌍

' - }); - - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_commands/test.ccmd-03.rcpt-to-multiple.ts b/test/suite/smtpclient_commands/test.ccmd-03.rcpt-to-multiple.ts deleted file mode 100644 index 99585bb..0000000 --- a/test/suite/smtpclient_commands/test.ccmd-03.rcpt-to-multiple.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_commands/test.ccmd-04.data-transmission.ts b/test/suite/smtpclient_commands/test.ccmd-04.data-transmission.ts deleted file mode 100644 index f4867b7..0000000 --- a/test/suite/smtpclient_commands/test.ccmd-04.data-transmission.ts +++ /dev/null @@ -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 Email Test - - -

HTML Email

-

This is an HTML email with:

-
    -
  • Lists
  • -
  • Formatting
  • -
  • Links: Example
  • -
- - - ` - }); - - 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: '

Unicode: 你好世界 🌍 🚀 ✉️

' - }); - - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts b/test/suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts deleted file mode 100644 index ada4501..0000000 --- a/test/suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_commands/test.ccmd-06.command-pipelining.ts b/test/suite/smtpclient_commands/test.ccmd-06.command-pipelining.ts deleted file mode 100644 index 2765f54..0000000 --- a/test/suite/smtpclient_commands/test.ccmd-06.command-pipelining.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_commands/test.ccmd-07.response-parsing.ts b/test/suite/smtpclient_commands/test.ccmd-07.response-parsing.ts deleted file mode 100644 index e9d236a..0000000 --- a/test/suite/smtpclient_commands/test.ccmd-07.response-parsing.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_commands/test.ccmd-08.rset-command.ts b/test/suite/smtpclient_commands/test.ccmd-08.rset-command.ts deleted file mode 100644 index bc8c96c..0000000 --- a/test/suite/smtpclient_commands/test.ccmd-08.rset-command.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_commands/test.ccmd-09.noop-command.ts b/test/suite/smtpclient_commands/test.ccmd-09.noop-command.ts deleted file mode 100644 index 4e0967a..0000000 --- a/test/suite/smtpclient_commands/test.ccmd-09.noop-command.ts +++ /dev/null @@ -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: - -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_commands/test.ccmd-10.vrfy-expn.ts b/test/suite/smtpclient_commands/test.ccmd-10.vrfy-expn.ts deleted file mode 100644 index e3ae0cd..0000000 --- a/test/suite/smtpclient_commands/test.ccmd-10.vrfy-expn.ts +++ /dev/null @@ -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" ', expectedAddress: 'john@example.com' }, - { from: '"Smith, John" ', expectedAddress: 'john.smith@example.com' }, - { from: 'Mary Johnson ', expectedAddress: 'mary@example.com' }, - { from: '', 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" ', - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_commands/test.ccmd-11.help-command.ts b/test/suite/smtpclient_commands/test.ccmd-11.help-command.ts deleted file mode 100644 index 227ee7a..0000000 --- a/test/suite/smtpclient_commands/test.ccmd-11.help-command.ts +++ /dev/null @@ -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: '

HTML content

' - })); - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts b/test/suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts deleted file mode 100644 index 569ccf5..0000000 --- a/test/suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_connection/test.ccm-02.tls-connection.ts b/test/suite/smtpclient_connection/test.ccm-02.tls-connection.ts deleted file mode 100644 index a20bb9e..0000000 --- a/test/suite/smtpclient_connection/test.ccm-02.tls-connection.ts +++ /dev/null @@ -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: '

This email was sent over a secure TLS connection

' - }); - - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_connection/test.ccm-03.starttls-upgrade.ts b/test/suite/smtpclient_connection/test.ccm-03.starttls-upgrade.ts deleted file mode 100644 index 6b52408..0000000 --- a/test/suite/smtpclient_connection/test.ccm-03.starttls-upgrade.ts +++ /dev/null @@ -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: '

This email was sent after STARTTLS upgrade

' - }); - - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_connection/test.ccm-04.connection-pooling.ts b/test/suite/smtpclient_connection/test.ccm-04.connection-pooling.ts deleted file mode 100644 index be1944a..0000000 --- a/test/suite/smtpclient_connection/test.ccm-04.connection-pooling.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_connection/test.ccm-05.connection-reuse.ts b/test/suite/smtpclient_connection/test.ccm-05.connection-reuse.ts deleted file mode 100644 index 236f2b3..0000000 --- a/test/suite/smtpclient_connection/test.ccm-05.connection-reuse.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_connection/test.ccm-06.connection-timeout.ts b/test/suite/smtpclient_connection/test.ccm-06.connection-timeout.ts deleted file mode 100644 index 0bc37ae..0000000 --- a/test/suite/smtpclient_connection/test.ccm-06.connection-timeout.ts +++ /dev/null @@ -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((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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_connection/test.ccm-07.automatic-reconnection.ts b/test/suite/smtpclient_connection/test.ccm-07.automatic-reconnection.ts deleted file mode 100644 index 9f467e4..0000000 --- a/test/suite/smtpclient_connection/test.ccm-07.automatic-reconnection.ts +++ /dev/null @@ -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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_connection/test.ccm-08.dns-resolution.ts b/test/suite/smtpclient_connection/test.ccm-08.dns-resolution.ts deleted file mode 100644 index 9eb6104..0000000 --- a/test/suite/smtpclient_connection/test.ccm-08.dns-resolution.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_connection/test.ccm-09.ipv6-dual-stack.ts b/test/suite/smtpclient_connection/test.ccm-09.ipv6-dual-stack.ts deleted file mode 100644 index da7648e..0000000 --- a/test/suite/smtpclient_connection/test.ccm-09.ipv6-dual-stack.ts +++ /dev/null @@ -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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_connection/test.ccm-10.proxy-support.ts b/test/suite/smtpclient_connection/test.ccm-10.proxy-support.ts deleted file mode 100644 index 155357c..0000000 --- a/test/suite/smtpclient_connection/test.ccm-10.proxy-support.ts +++ /dev/null @@ -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((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((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((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((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((resolve) => { - authProxyServer.listen(0, '127.0.0.1', () => { - resolve(); - }); - }); - - const authProxyAddress = authProxyServer.address() as net.AddressInfo; - - // Test without authentication - const failedAuth = await new Promise((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((resolve) => proxyServer.close(() => resolve())); - } - - if (socksProxyServer) { - await new Promise((resolve) => socksProxyServer.close(() => resolve())); - } - - if (testServer) { - await stopTestServer(testServer); - } -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_connection/test.ccm-11.keepalive.ts b/test/suite/smtpclient_connection/test.ccm-11.keepalive.ts deleted file mode 100644 index 54db6bf..0000000 --- a/test/suite/smtpclient_connection/test.ccm-11.keepalive.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_edge-cases/test.cedge-01.unusual-server-responses.ts b/test/suite/smtpclient_edge-cases/test.cedge-01.unusual-server-responses.ts deleted file mode 100644 index 2222e68..0000000 --- a/test/suite/smtpclient_edge-cases/test.cedge-01.unusual-server-responses.ts +++ /dev/null @@ -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((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((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((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((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((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((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((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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_edge-cases/test.cedge-02.malformed-commands.ts b/test/suite/smtpclient_edge-cases/test.cedge-02.malformed-commands.ts deleted file mode 100644 index c5926dd..0000000 --- a/test/suite/smtpclient_edge-cases/test.cedge-02.malformed-commands.ts +++ /dev/null @@ -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((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((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((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((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((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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_edge-cases/test.cedge-03.protocol-violations.ts b/test/suite/smtpclient_edge-cases/test.cedge-03.protocol-violations.ts deleted file mode 100644 index 233bc79..0000000 --- a/test/suite/smtpclient_edge-cases/test.cedge-03.protocol-violations.ts +++ /dev/null @@ -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((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((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((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((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((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((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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_edge-cases/test.cedge-04.resource-constraints.ts b/test/suite/smtpclient_edge-cases/test.cedge-04.resource-constraints.ts deleted file mode 100644 index d0e66a8..0000000 --- a/test/suite/smtpclient_edge-cases/test.cedge-04.resource-constraints.ts +++ /dev/null @@ -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((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((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((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((resolve) => { - concurrentServer.listen(0, '127.0.0.1', () => resolve()); - }); - - const concurrentPort = (concurrentServer.address() as net.AddressInfo).port; - - // Create multiple clients concurrently - const clientPromises: Promise[] = []; - 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((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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_edge-cases/test.cedge-05.encoding-issues.ts b/test/suite/smtpclient_edge-cases/test.cedge-05.encoding-issues.ts deleted file mode 100644 index c658de1..0000000 --- a/test/suite/smtpclient_edge-cases/test.cedge-05.encoding-issues.ts +++ /dev/null @@ -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: '

HTML with entities: café, naïve, and emoji 🌟

', - 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(); diff --git a/test/suite/smtpclient_edge-cases/test.cedge-06.large-headers.ts b/test/suite/smtpclient_edge-cases/test.cedge-06.large-headers.ts deleted file mode 100644 index acb5d68..0000000 --- a/test/suite/smtpclient_edge-cases/test.cedge-06.large-headers.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_edge-cases/test.cedge-07.concurrent-operations.ts b/test/suite/smtpclient_edge-cases/test.cedge-07.concurrent-operations.ts deleted file mode 100644 index 9e27342..0000000 --- a/test/suite/smtpclient_edge-cases/test.cedge-07.concurrent-operations.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_email-composition/test.cep-01.basic-headers.ts b/test/suite/smtpclient_email-composition/test.cep-01.basic-headers.ts deleted file mode 100644 index 211c6fd..0000000 --- a/test/suite/smtpclient_email-composition/test.cep-01.basic-headers.ts +++ /dev/null @@ -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 ', - to: 'Jane Smith ', - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_email-composition/test.cep-02.mime-multipart.ts b/test/suite/smtpclient_email-composition/test.cep-02.mime-multipart.ts deleted file mode 100644 index 70f3428..0000000 --- a/test/suite/smtpclient_email-composition/test.cep-02.mime-multipart.ts +++ /dev/null @@ -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 Version

This is the HTML version of the email.

' - }); - - 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: '

This email contains attachments.

', - 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: '

Here is an inline image: Red Pixel

', - 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: '

HTML version with Logo

', - 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: '

HTML with specific charset

', - attachments: [ - { - filename: 'utf8-text.txt', - content: Buffer.from('UTF-8 text: 你好世界'), - contentType: 'text/plain; charset=utf-8' - }, - { - filename: 'data.xml', - content: Buffer.from('Test'), - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_email-composition/test.cep-03.attachment-encoding.ts b/test/suite/smtpclient_email-composition/test.cep-03.attachment-encoding.ts deleted file mode 100644 index 36fd4e2..0000000 --- a/test/suite/smtpclient_email-composition/test.cep-03.attachment-encoding.ts +++ /dev/null @@ -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: '

Image below:

', - 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('test'), - contentType: 'application/xml; charset=utf-8', - encoding: 'base64', - headers: { - 'Content-Description': 'Monthly Report', - 'Content-Transfer-Encoding': 'base64', - 'Content-ID': '' - } - }] - }); - - 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: '

HTML body with special chars: café

', - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_email-composition/test.cep-04.bcc-handling.ts b/test/suite/smtpclient_email-composition/test.cep-04.bcc-handling.ts deleted file mode 100644 index d72a1dd..0000000 --- a/test/suite/smtpclient_email-composition/test.cep-04.bcc-handling.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_email-composition/test.cep-05.reply-to-return-path.ts b/test/suite/smtpclient_email-composition/test.cep-05.reply-to-return-path.ts deleted file mode 100644 index de9e770..0000000 --- a/test/suite/smtpclient_email-composition/test.cep-05.reply-to-return-path.ts +++ /dev/null @@ -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 ', - 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': '' - } - }); - - 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': '' - } - }); - - 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': '' - } - }); - - 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 ', - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_email-composition/test.cep-06.utf8-international.ts b/test/suite/smtpclient_email-composition/test.cep-06.utf8-international.ts deleted file mode 100644 index 8064039..0000000 --- a/test/suite/smtpclient_email-composition/test.cep-06.utf8-international.ts +++ /dev/null @@ -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: '

HTML with UTF-8: café, naïve, résumé, piñata

' - }); - - 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: ` -

European Characters Test

-
    -
  • 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
  • -
- ` - }); - - 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: ` -

Asian Characters Test

- - - - - - - -
Chinese (Simplified):你好世界
Chinese (Traditional):你好世界
Japanese:こんにちは世界
Korean:안녕하세요 세계
Thai:สวัสดีโลก
Hindi:नमस्ते संसार
- ` - }); - - 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: ` -

Emojis and Symbols Test 🎉

-

Faces: 😀 😃 😄 😁 😆 😅 😂

-

Objects: 🎉 🚀 ✨ 🌈 ⭐ 🔥 💎

-

Animals: 🐶 🐱 🐭 🐹 🐰 🦊 🐻

-

Food: 🍎 🍌 🍇 🍓 🥝 🍅 🥑

-

Symbols: ✓ ✗ ⚠ ♠ ♣ ♥ ♦

-

Math: ∑ ∏ ∫ ∞ ± × ÷ ≠ ≤ ≥

- ` - }); - - 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: ` -

International Mix 🌍

-
-

English: Hello World!

-

Chinese: 你好世界!

-

Arabic: مرحبا بالعالم!

-

Japanese: こんにちは世界!

-

Russian: Привет мир!

-

Greek: Γεια σας κόσμε!

-

Mixed: Hello 世界 🌍 مرحبا こんにちは!

-
- ` - }); - - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_email-composition/test.cep-07.html-inline-images.ts b/test/suite/smtpclient_email-composition/test.cep-07.html-inline-images.ts deleted file mode 100644 index b0d2c83..0000000 --- a/test/suite/smtpclient_email-composition/test.cep-07.html-inline-images.ts +++ /dev/null @@ -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: ` - - - - - - -
-

Welcome!

-
-
-

This is an HTML email with formatting.

-
    -
  • Feature 1
  • -
  • Feature 2
  • -
  • Feature 3
  • -
-
- - - - `, - 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: ` - - -

Email with Inline Images

-

Here's an inline image:

- Red pixel -

And here's another one:

- Company logo - - - `, - 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: ` - - - - - -
- -
-

Monthly Newsletter

-
-
- Product 1 -

Product 1

-
-
- Product 2 -

Product 2

-
-
- Product 3 -

Product 3

-
-
- -

© 2024 Example Corp

- - - `, - 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: ` - - -

Mixed Image Sources

-

Inline Image:

- Inline Logo -

External Images:

- External Image 1 - External Image 2 -

Data URI Image:

- Data URI - - - `, - 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: ` - - - - - - - -
-

Responsive Design Test

-
- Left Column -

Left column content

-
-
- Right Column -

Right column content

-
-

This text is hidden on mobile devices

-
- - - `, - 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: ` - - -

Security Test

- - - - Dangerous Link - -
- -
- -

This is safe text content.

- Safe Image - - - `, - 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 = '

Performance Test

'; - - for (let i = 0; i < imageCount; i++) { - const cid = `image${i}`; - htmlContent += `Image ${i}`; - - attachments.push({ - filename: `image${i}.png`, - content: Buffer.from(`fake-image-data-${i}`), - contentType: 'image/png', - cid: cid - }); - } - - htmlContent += ''; - - 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: ` - - -
- Company Newsletter -
-
-

March Newsletter

-

Featured Articles

- -
-

Special Offer!

-

Get 20% off with code: SPRING20

- Special Offer -
-
-
-

© 2024 Example Corp | Unsubscribe

-
- - - `, - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_email-composition/test.cep-08.custom-headers.ts b/test/suite/smtpclient_email-composition/test.cep-08.custom-headers.ts deleted file mode 100644 index 0509464..0000000 --- a/test/suite/smtpclient_email-composition/test.cep-08.custom-headers.ts +++ /dev/null @@ -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': '', // 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': '', - '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: '

HTML content

', - 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 = ''; - const references = ' '; - - 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': ' ' - } - }); - - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_email-composition/test.cep-09.priority-importance.ts b/test/suite/smtpclient_email-composition/test.cep-09.priority-importance.ts deleted file mode 100644 index ba1036f..0000000 --- a/test/suite/smtpclient_email-composition/test.cep-09.priority-importance.ts +++ /dev/null @@ -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': '', // Mailing list header - 'List-Unsubscribe': '' - } - }); - - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_email-composition/test.cep-10.receipts-dsn.ts b/test/suite/smtpclient_email-composition/test.cep-10.receipts-dsn.ts deleted file mode 100644 index 27c7668..0000000 --- a/test/suite/smtpclient_email-composition/test.cep-10.receipts-dsn.ts +++ /dev/null @@ -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" ' - }, - { - 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' + - ': 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_error-handling/test.cerr-01.4xx-errors.ts b/test/suite/smtpclient_error-handling/test.cerr-01.4xx-errors.ts deleted file mode 100644 index 811d2a1..0000000 --- a/test/suite/smtpclient_error-handling/test.cerr-01.4xx-errors.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_error-handling/test.cerr-02.5xx-errors.ts b/test/suite/smtpclient_error-handling/test.cerr-02.5xx-errors.ts deleted file mode 100644 index 3ccb292..0000000 --- a/test/suite/smtpclient_error-handling/test.cerr-02.5xx-errors.ts +++ /dev/null @@ -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: '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(); \ No newline at end of file diff --git a/test/suite/smtpclient_error-handling/test.cerr-03.network-failures.ts b/test/suite/smtpclient_error-handling/test.cerr-03.network-failures.ts deleted file mode 100644 index 295e305..0000000 --- a/test/suite/smtpclient_error-handling/test.cerr-03.network-failures.ts +++ /dev/null @@ -1,289 +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((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((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 sends a greeting but never responds to commands, - // simulating complete packet loss after the initial connection. - const lossyServer = net.createServer((socket) => { - socket.on('error', () => {}); - socket.write('220 Lossy server ready\r\n'); - - // Never respond to any commands - simulates total packet loss - socket.on('data', () => { - // Intentionally drop all data to simulate packet loss - }); - }); - - await new Promise((resolve) => { - lossyServer.listen(2558, () => resolve()); - }); - - const client = createSmtpClient({ - host: 'localhost', - port: 2558, - secure: false, - connectionTimeout: 2000, - socketTimeout: 2000 // Short timeout to detect loss - }); - - let verifyResult = false; - let errorOccurred = false; - - try { - verifyResult = await client.verify(); - if (verifyResult) { - console.log('Connection succeeded unexpectedly'); - } else { - console.log('✅ Connection failed due to packet loss'); - } - } catch (error) { - errorOccurred = true; - console.log(`✅ Packet loss detected: ${error.message}`); - } - - // verify() must have returned false or thrown - both indicate packet loss was detected - 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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_error-handling/test.cerr-04.greylisting-handling.ts b/test/suite/smtpclient_error-handling/test.cerr-04.greylisting-handling.ts deleted file mode 100644 index ba9dbaf..0000000 --- a/test/suite/smtpclient_error-handling/test.cerr-04.greylisting-handling.ts +++ /dev/null @@ -1,286 +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.on('error', () => {}); - socket.write('220 Greylist Test Server\r\n'); - - socket.on('data', (data) => { - const lines = data.toString().split('\r\n').filter((l: string) => l.trim()); - for (const line of lines) { - const command = line.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.startsWith('RSET')) { - socket.write('250 OK\r\n'); - } else if (command.startsWith('DATA')) { - socket.write('503 Bad sequence of commands\r\n'); - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } else if (command.length > 0) { - socket.write('250 OK\r\n'); - } - } - }); - }); - - await new Promise((resolve) => { - greylistServer.listen(2560, () => resolve()); - }); - - const smtpClient = await createSmtpClient({ - host: '127.0.0.1', - port: 2560, - secure: false, - connectionTimeout: 5000, - socketTimeout: 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((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.on('error', () => {}); - socket.write('220 Temp Fail Server\r\n'); - - socket.on('data', (data) => { - const lines = data.toString().split('\r\n').filter((l: string) => l.trim()); - for (const line of lines) { - const command = line.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')) { - socket.write('450 4.7.1 Mailbox temporarily unavailable\r\n'); - } else if (command.startsWith('RSET')) { - socket.write('250 OK\r\n'); - } else if (command.startsWith('DATA')) { - socket.write('503 Bad sequence of commands\r\n'); - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } else if (command.length > 0) { - socket.write('250 OK\r\n'); - } - } - }); - }); - - await new Promise((resolve) => { - tempFailServer.listen(2561, () => resolve()); - }); - - const smtpClient = await createSmtpClient({ - host: '127.0.0.1', - port: 2561, - secure: false, - connectionTimeout: 5000, - socketTimeout: 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((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.on('error', () => {}); - socket.write('220 Reject Server\r\n'); - - socket.on('data', (data) => { - const lines = data.toString().split('\r\n').filter((l: string) => l.trim()); - for (const line of lines) { - const command = line.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')) { - socket.write('451 4.2.1 Recipient rejected temporarily\r\n'); - } else if (command.startsWith('RSET')) { - socket.write('250 OK\r\n'); - } else if (command.startsWith('DATA')) { - socket.write('503 Bad sequence of commands\r\n'); - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } else if (command.length > 0) { - socket.write('250 OK\r\n'); - } - } - }); - }); - - await new Promise((resolve) => { - rejectServer.listen(2562, () => resolve()); - }); - - const smtpClient = await createSmtpClient({ - host: '127.0.0.1', - port: 2562, - secure: false, - connectionTimeout: 5000, - socketTimeout: 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((resolve) => { - rejectServer.close(() => resolve()); - }); -}); - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_error-handling/test.cerr-05.quota-exceeded.ts b/test/suite/smtpclient_error-handling/test.cerr-05.quota-exceeded.ts deleted file mode 100644 index 3a149ca..0000000 --- a/test/suite/smtpclient_error-handling/test.cerr-05.quota-exceeded.ts +++ /dev/null @@ -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((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((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((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((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((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((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((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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_error-handling/test.cerr-06.invalid-recipients.ts b/test/suite/smtpclient_error-handling/test.cerr-06.invalid-recipients.ts deleted file mode 100644 index edf905c..0000000 --- a/test/suite/smtpclient_error-handling/test.cerr-06.invalid-recipients.ts +++ /dev/null @@ -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((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((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((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((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((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((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((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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_error-handling/test.cerr-07.message-size-limits.ts b/test/suite/smtpclient_error-handling/test.cerr-07.message-size-limits.ts deleted file mode 100644 index 7d7f4b0..0000000 --- a/test/suite/smtpclient_error-handling/test.cerr-07.message-size-limits.ts +++ /dev/null @@ -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((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((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((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((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((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((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((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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_error-handling/test.cerr-08.rate-limiting.ts b/test/suite/smtpclient_error-handling/test.cerr-08.rate-limiting.ts deleted file mode 100644 index c9caa6a..0000000 --- a/test/suite/smtpclient_error-handling/test.cerr-08.rate-limiting.ts +++ /dev/null @@ -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((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((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((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((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((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((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((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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_error-handling/test.cerr-09.connection-pool-errors.ts b/test/suite/smtpclient_error-handling/test.cerr-09.connection-pool-errors.ts deleted file mode 100644 index 3458501..0000000 --- a/test/suite/smtpclient_error-handling/test.cerr-09.connection-pool-errors.ts +++ /dev/null @@ -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((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((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((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((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((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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_error-handling/test.cerr-10.partial-failure.ts b/test/suite/smtpclient_error-handling/test.cerr-10.partial-failure.ts deleted file mode 100644 index ddfc6b5..0000000 --- a/test/suite/smtpclient_error-handling/test.cerr-10.partial-failure.ts +++ /dev/null @@ -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((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((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((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((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((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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_performance/test.cperf-01.bulk-sending.ts b/test/suite/smtpclient_performance/test.cperf-01.bulk-sending.ts deleted file mode 100644 index 3d09f6e..0000000 --- a/test/suite/smtpclient_performance/test.cperf-01.bulk-sending.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_performance/test.cperf-02.message-throughput.ts b/test/suite/smtpclient_performance/test.cperf-02.message-throughput.ts deleted file mode 100644 index 83de05c..0000000 --- a/test/suite/smtpclient_performance/test.cperf-02.message-throughput.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_performance/test.cperf-03.memory-usage.ts b/test/suite/smtpclient_performance/test.cperf-03.memory-usage.ts deleted file mode 100644 index 2b3e815..0000000 --- a/test/suite/smtpclient_performance/test.cperf-03.memory-usage.ts +++ /dev/null @@ -1,333 +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 - // Note: Error handling may retain stack traces and buffers, so allow reasonable overhead - expect(memoryIncrease).toBeLessThan(10 * 1024 * 1024); // Less than 10MB 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_performance/test.cperf-04.cpu-utilization.ts b/test/suite/smtpclient_performance/test.cperf-04.cpu-utilization.ts deleted file mode 100644 index 7e75c82..0000000 --- a/test/suite/smtpclient_performance/test.cperf-04.cpu-utilization.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_performance/test.cperf-05.network-efficiency.ts b/test/suite/smtpclient_performance/test.cperf-05.network-efficiency.ts deleted file mode 100644 index d82c224..0000000 --- a/test/suite/smtpclient_performance/test.cperf-05.network-efficiency.ts +++ /dev/null @@ -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(); diff --git a/test/suite/smtpclient_performance/test.cperf-06.caching-strategies.ts b/test/suite/smtpclient_performance/test.cperf-06.caching-strategies.ts deleted file mode 100644 index abd5b3b..0000000 --- a/test/suite/smtpclient_performance/test.cperf-06.caching-strategies.ts +++ /dev/null @@ -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(); diff --git a/test/suite/smtpclient_performance/test.cperf-07.queue-management.ts b/test/suite/smtpclient_performance/test.cperf-07.queue-management.ts deleted file mode 100644 index b633d2c..0000000 --- a/test/suite/smtpclient_performance/test.cperf-07.queue-management.ts +++ /dev/null @@ -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(); diff --git a/test/suite/smtpclient_performance/test.cperf-08.dns-caching.ts b/test/suite/smtpclient_performance/test.cperf-08.dns-caching.ts deleted file mode 100644 index 7fddbb9..0000000 --- a/test/suite/smtpclient_performance/test.cperf-08.dns-caching.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_reliability/test.crel-01.reconnection-logic.ts b/test/suite/smtpclient_reliability/test.crel-01.reconnection-logic.ts deleted file mode 100644 index fc01caf..0000000 --- a/test/suite/smtpclient_reliability/test.crel-01.reconnection-logic.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_reliability/test.crel-02.network-interruption.ts b/test/suite/smtpclient_reliability/test.crel-02.network-interruption.ts deleted file mode 100644 index db913de..0000000 --- a/test/suite/smtpclient_reliability/test.crel-02.network-interruption.ts +++ /dev/null @@ -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((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((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((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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_reliability/test.crel-03.queue-persistence.ts b/test/suite/smtpclient_reliability/test.crel-03.queue-persistence.ts deleted file mode 100644 index 416f316..0000000 --- a/test/suite/smtpclient_reliability/test.crel-03.queue-persistence.ts +++ /dev/null @@ -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((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((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((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(); - - // 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((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: '

HTML Email

Testing integrity with HTML content

', - 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(); \ No newline at end of file diff --git a/test/suite/smtpclient_reliability/test.crel-04.crash-recovery.ts b/test/suite/smtpclient_reliability/test.crel-04.crash-recovery.ts deleted file mode 100644 index 7044610..0000000 --- a/test/suite/smtpclient_reliability/test.crel-04.crash-recovery.ts +++ /dev/null @@ -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((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((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((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((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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_reliability/test.crel-05.memory-leaks.ts b/test/suite/smtpclient_reliability/test.crel-05.memory-leaks.ts deleted file mode 100644 index c6c1eae..0000000 --- a/test/suite/smtpclient_reliability/test.crel-05.memory-leaks.ts +++ /dev/null @@ -1,507 +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((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((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: `

Email ${i + 1}

Testing memory patterns with HTML content. Batch ${batchIndex + 1}.

` - })); - } - - 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'}`); - - // Note: 450 emails with text+html content requires reasonable memory - // ~42KB per email is acceptable for full email objects with headers - expect(maxMemoryIncrease).toBeLessThan(25); // 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((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'}`); - - // Note: Each email includes connection overhead, buffers, and temporary objects - // ~100KB per email is reasonable for sustained operation - expect(growthRate).toBeLessThan(150); // 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((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'); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_reliability/test.crel-06.concurrency-safety.ts b/test/suite/smtpclient_reliability/test.crel-06.concurrency-safety.ts deleted file mode 100644 index 8e363c7..0000000 --- a/test/suite/smtpclient_reliability/test.crel-06.concurrency-safety.ts +++ /dev/null @@ -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((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[] = []; - - // 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((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[] = []; - 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); - - 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((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((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(); \ No newline at end of file diff --git a/test/suite/smtpclient_reliability/test.crel-07.resource-cleanup.ts b/test/suite/smtpclient_reliability/test.crel-07.resource-cleanup.ts deleted file mode 100644 index 9b4a93f..0000000 --- a/test/suite/smtpclient_reliability/test.crel-07.resource-cleanup.ts +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_rfc-compliance/test.crfc-01.rfc5321-client.ts b/test/suite/smtpclient_rfc-compliance/test.crfc-01.rfc5321-client.ts deleted file mode 100644 index cefb395..0000000 --- a/test/suite/smtpclient_rfc-compliance/test.crfc-01.rfc5321-client.ts +++ /dev/null @@ -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: - 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: - 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 . termination sequence' - }); - - // Client MUST terminate DATA with . - const result = await smtpClient.sendMail(email); - - expect(result.success).toBeTrue(); - console.log('✅ RFC 5321 §4.1.1.9: DATA terminated with .'); -}); - -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(); \ No newline at end of file diff --git a/test/suite/smtpclient_rfc-compliance/test.crfc-02.esmtp-compliance.ts b/test/suite/smtpclient_rfc-compliance/test.crfc-02.esmtp-compliance.ts deleted file mode 100644 index 002f517..0000000 --- a/test/suite/smtpclient_rfc-compliance/test.crfc-02.esmtp-compliance.ts +++ /dev/null @@ -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: '

HTML: Zürich

' - }); - - 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(); - } -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_rfc-compliance/test.crfc-03.command-syntax.ts b/test/suite/smtpclient_rfc-compliance/test.crfc-03.command-syntax.ts deleted file mode 100644 index e0c9376..0000000 --- a/test/suite/smtpclient_rfc-compliance/test.crfc-03.command-syntax.ts +++ /dev/null @@ -1,67 +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-03: SMTP Command Syntax Compliance', async () => { - console.log('\n📧 Testing SMTP Client Command Syntax Compliance'); - console.log('=' .repeat(60)); - - const testServer = await createTestServer({}); - - try { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port - }); - - console.log('\nTest 1: Valid email addresses'); - const email1 = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Valid email test', - text: 'Testing valid email addresses' - }); - - const result1 = await smtpClient.sendMail(email1); - console.log(' ✓ Valid email addresses accepted'); - expect(result1).toBeDefined(); - - console.log('\nTest 2: Email with display names'); - const email2 = new Email({ - from: 'Test Sender ', - to: ['Test Recipient '], - subject: 'Display name test', - text: 'Testing email addresses with display names' - }); - - const result2 = await smtpClient.sendMail(email2); - console.log(' ✓ Display names handled correctly'); - expect(result2).toBeDefined(); - - console.log('\nTest 3: Multiple recipients'); - const email3 = new Email({ - from: 'sender@example.com', - to: ['user1@example.com', 'user2@example.com'], - cc: ['cc@example.com'], - subject: 'Multiple recipients test', - text: 'Testing RCPT TO command with multiple recipients' - }); - - const result3 = await smtpClient.sendMail(email3); - console.log(' ✓ Multiple RCPT TO commands sent correctly'); - expect(result3).toBeDefined(); - - console.log('\nTest 4: Connection test (HELO/EHLO)'); - const verified = await smtpClient.verify(); - console.log(' ✓ HELO/EHLO command syntax correct'); - expect(verified).toBeDefined(); - - console.log('\n✅ CRFC-03: Command syntax compliance tests completed'); - - } finally { - testServer.server.close(); - } -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_rfc-compliance/test.crfc-04.response-codes.ts b/test/suite/smtpclient_rfc-compliance/test.crfc-04.response-codes.ts deleted file mode 100644 index 0600e8f..0000000 --- a/test/suite/smtpclient_rfc-compliance/test.crfc-04.response-codes.ts +++ /dev/null @@ -1,54 +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-04: SMTP Response Code Handling', async () => { - console.log('\n📧 Testing SMTP Client Response Code Handling'); - console.log('=' .repeat(60)); - - const testServer = await createTestServer({}); - - try { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port - }); - - console.log('\nTest 1: Successful email (2xx responses)'); - const email1 = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Success test', - text: 'Testing successful response codes' - }); - - const result1 = await smtpClient.sendMail(email1); - console.log(' ✓ 2xx response codes handled correctly'); - expect(result1).toBeDefined(); - - console.log('\nTest 2: Verify connection'); - const verified = await smtpClient.verify(); - console.log(' ✓ Connection verification successful'); - expect(verified).toBeDefined(); - - console.log('\nTest 3: Multiple recipients (multiple 250 responses)'); - const email2 = new Email({ - from: 'sender@example.com', - to: ['user1@example.com', 'user2@example.com', 'user3@example.com'], - subject: 'Multiple recipients', - text: 'Testing multiple positive responses' - }); - - const result2 = await smtpClient.sendMail(email2); - console.log(' ✓ Multiple positive responses handled'); - expect(result2).toBeDefined(); - - console.log('\n✅ CRFC-04: Response code handling tests completed'); - - } finally { - testServer.server.close(); - } -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_rfc-compliance/test.crfc-05.state-machine.ts b/test/suite/smtpclient_rfc-compliance/test.crfc-05.state-machine.ts deleted file mode 100644 index a690cc7..0000000 --- a/test/suite/smtpclient_rfc-compliance/test.crfc-05.state-machine.ts +++ /dev/null @@ -1,766 +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/index.js'; - -tap.test('CRFC-05: should comply with SMTP state machine (RFC 5321)', async (tools) => { - const testId = 'CRFC-05-state-machine'; - console.log(`\n${testId}: Testing SMTP state machine compliance...`); - - let scenarioCount = 0; - - // Scenario 1: Initial state and greeting - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing initial state and greeting`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected - Initial state'); - - let state = 'initial'; - - // Send greeting immediately upon connection - socket.write('220 statemachine.example.com ESMTP Service ready\r\n'); - state = 'greeting-sent'; - console.log(' [Server] State: initial -> greeting-sent'); - - socket.on('data', (data) => { - const command = data.toString().trim(); - console.log(` [Server] State: ${state}, Received: ${command}`); - - if (state === 'greeting-sent') { - if (command.startsWith('EHLO') || command.startsWith('HELO')) { - socket.write('250 statemachine.example.com\r\n'); - state = 'ready'; - console.log(' [Server] State: greeting-sent -> ready'); - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } else { - socket.write('503 5.5.1 Bad sequence of commands\r\n'); - } - } else if (state === 'ready') { - if (command.startsWith('MAIL FROM:')) { - socket.write('250 OK\r\n'); - state = 'mail'; - console.log(' [Server] State: ready -> mail'); - } else if (command.startsWith('EHLO') || command.startsWith('HELO')) { - socket.write('250 statemachine.example.com\r\n'); - // Stay in ready state - } else if (command === 'RSET' || command === 'NOOP') { - socket.write('250 OK\r\n'); - // Stay in ready state - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } else { - socket.write('503 5.5.1 Bad sequence of commands\r\n'); - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Just establish connection and send EHLO - try { - await smtpClient.verify(); - console.log(' Initial state transition (connect -> EHLO) successful'); - } catch (error) { - console.log(` Connection/EHLO failed: ${error.message}`); - } - - await testServer.server.close(); - })(); - - // Scenario 2: Transaction state machine - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing transaction state machine`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 statemachine.example.com ESMTP\r\n'); - - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - - // Process complete lines - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; // Keep incomplete line in buffer - - for (const line of lines) { - if (state === 'data') { - // In DATA mode, look for the terminating dot - if (line === '.') { - socket.write('250 OK message queued\r\n'); - state = 'ready'; - console.log(' [Server] State: data -> ready (message complete)'); - } - // Otherwise just accumulate data (don't respond to content) - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [Server] State: ${state}, Command: ${command}`); - - switch (state) { - case 'ready': - if (command.startsWith('EHLO')) { - socket.write('250 statemachine.example.com\r\n'); - } else if (command.startsWith('MAIL FROM:')) { - socket.write('250 OK\r\n'); - state = 'mail'; - console.log(' [Server] State: ready -> mail'); - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } else { - socket.write('503 5.5.1 Bad sequence of commands\r\n'); - } - break; - - case 'mail': - if (command.startsWith('RCPT TO:')) { - socket.write('250 OK\r\n'); - state = 'rcpt'; - console.log(' [Server] State: mail -> rcpt'); - } else if (command === 'RSET') { - socket.write('250 OK\r\n'); - state = 'ready'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } else { - socket.write('503 5.5.1 Bad sequence of commands\r\n'); - } - break; - - case 'rcpt': - if (command.startsWith('RCPT TO:')) { - socket.write('250 OK\r\n'); - } else if (command === 'DATA') { - socket.write('354 Start mail input\r\n'); - state = 'data'; - console.log(' [Server] State: rcpt -> data'); - } else if (command === 'RSET') { - socket.write('250 OK\r\n'); - state = 'ready'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } else { - socket.write('503 5.5.1 Bad sequence of commands\r\n'); - } - break; - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient1@example.com', 'recipient2@example.com'], - subject: 'State machine test', - text: 'Testing SMTP transaction state machine' - }); - - const result = await smtpClient.sendMail(email); - console.log(' Complete transaction state sequence successful'); - expect(result).toBeDefined(); - // Note: messageId is only present if server provides it in 250 response - expect(result.success).toBeTruthy(); - - await testServer.server.close(); - })(); - - // Scenario 3: Invalid state transitions - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing invalid state transitions`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 statemachine.example.com ESMTP\r\n'); - - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - - // Process complete lines - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - // In DATA mode, look for the terminating dot - if (line === '.') { - socket.write('250 OK\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [Server] State: ${state}, Command: ${command}`); - - // Strictly enforce state machine - switch (state) { - case 'ready': - if (command.startsWith('EHLO') || command.startsWith('HELO')) { - socket.write('250 statemachine.example.com\r\n'); - } else if (command.startsWith('MAIL FROM:')) { - socket.write('250 OK\r\n'); - state = 'mail'; - } else if (command === 'RSET' || command === 'NOOP') { - socket.write('250 OK\r\n'); - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } else if (command.startsWith('RCPT TO:')) { - console.log(' [Server] RCPT TO without MAIL FROM'); - socket.write('503 5.5.1 Need MAIL command first\r\n'); - } else if (command === 'DATA') { - console.log(' [Server] DATA without MAIL FROM and RCPT TO'); - socket.write('503 5.5.1 Need MAIL and RCPT commands first\r\n'); - } else { - socket.write('503 5.5.1 Bad sequence of commands\r\n'); - } - break; - - case 'mail': - if (command.startsWith('RCPT TO:')) { - socket.write('250 OK\r\n'); - state = 'rcpt'; - } else if (command.startsWith('MAIL FROM:')) { - console.log(' [Server] Second MAIL FROM without RSET'); - socket.write('503 5.5.1 Sender already specified\r\n'); - } else if (command === 'DATA') { - console.log(' [Server] DATA without RCPT TO'); - socket.write('503 5.5.1 Need RCPT command first\r\n'); - } else if (command === 'RSET') { - socket.write('250 OK\r\n'); - state = 'ready'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } else { - socket.write('503 5.5.1 Bad sequence of commands\r\n'); - } - break; - - case 'rcpt': - if (command.startsWith('RCPT TO:')) { - socket.write('250 OK\r\n'); - } else if (command === 'DATA') { - socket.write('354 Start mail input\r\n'); - state = 'data'; - } else if (command.startsWith('MAIL FROM:')) { - console.log(' [Server] MAIL FROM after RCPT TO without RSET'); - socket.write('503 5.5.1 Sender already specified\r\n'); - } else if (command === 'RSET') { - socket.write('250 OK\r\n'); - state = 'ready'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } else { - socket.write('503 5.5.1 Bad sequence of commands\r\n'); - } - break; - } - } - }); - } - }); - - // We'll create a custom client to send invalid command sequences - const testCases = [ - { - name: 'RCPT without MAIL', - commands: ['EHLO client.example.com', 'RCPT TO:'], - expectError: true - }, - { - name: 'DATA without RCPT', - commands: ['EHLO client.example.com', 'MAIL FROM:', 'DATA'], - expectError: true - }, - { - name: 'Double MAIL FROM', - commands: ['EHLO client.example.com', 'MAIL FROM:', 'MAIL FROM:'], - expectError: true - } - ]; - - for (const testCase of testCases) { - console.log(` Testing: ${testCase.name}`); - - try { - // Create simple socket connection for manual command testing - const net = await import('net'); - const client = net.createConnection(testServer.port, testServer.hostname); - - let responseCount = 0; - let errorReceived = false; - - client.on('data', (data) => { - const response = data.toString(); - console.log(` Response: ${response.trim()}`); - - if (response.startsWith('5')) { - errorReceived = true; - } - - responseCount++; - - if (responseCount <= testCase.commands.length) { - const command = testCase.commands[responseCount - 1]; - if (command) { - setTimeout(() => { - console.log(` Sending: ${command}`); - client.write(command + '\r\n'); - }, 100); - } - } else { - client.write('QUIT\r\n'); - client.end(); - } - }); - - await new Promise((resolve, reject) => { - client.on('end', () => { - if (testCase.expectError && errorReceived) { - console.log(` ✓ Expected error received`); - } else if (!testCase.expectError && !errorReceived) { - console.log(` ✓ No error as expected`); - } else { - console.log(` ✗ Unexpected result`); - } - resolve(void 0); - }); - - client.on('error', reject); - - // Start with greeting response - setTimeout(() => { - if (testCase.commands.length > 0) { - console.log(` Sending: ${testCase.commands[0]}`); - client.write(testCase.commands[0] + '\r\n'); - } - }, 100); - }); - - } catch (error) { - console.log(` Error testing ${testCase.name}: ${error.message}`); - } - } - - await testServer.server.close(); - })(); - - // Scenario 4: RSET command state transitions - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing RSET command state transitions`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 statemachine.example.com ESMTP\r\n'); - - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - - // Process complete lines - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - // In DATA mode, look for the terminating dot - if (line === '.') { - socket.write('250 OK\r\n'); - state = 'ready'; - console.log(' [Server] State: data -> ready (message complete)'); - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [Server] State: ${state}, Command: ${command}`); - - if (command.startsWith('EHLO')) { - socket.write('250 statemachine.example.com\r\n'); - state = 'ready'; - } else if (command.startsWith('MAIL FROM:')) { - socket.write('250 OK\r\n'); - state = 'mail'; - } else if (command.startsWith('RCPT TO:')) { - if (state === 'mail' || state === 'rcpt') { - socket.write('250 OK\r\n'); - state = 'rcpt'; - } else { - socket.write('503 5.5.1 Bad sequence of commands\r\n'); - } - } else if (command === 'RSET') { - console.log(` [Server] RSET from state: ${state} -> ready`); - socket.write('250 OK\r\n'); - state = 'ready'; - } else if (command === 'DATA') { - if (state === 'rcpt') { - socket.write('354 Start mail input\r\n'); - state = 'data'; - } else { - socket.write('503 5.5.1 Bad sequence of commands\r\n'); - } - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } else if (command === 'NOOP') { - socket.write('250 OK\r\n'); - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test RSET at various points in transaction - console.log(' Testing RSET from different states...'); - - // We'll manually test RSET behavior - const net = await import('net'); - const client = net.createConnection(testServer.port, testServer.hostname); - - const commands = [ - 'EHLO client.example.com', // -> ready - 'MAIL FROM:', // -> mail - 'RSET', // -> ready (reset from mail state) - 'MAIL FROM:', // -> mail - 'RCPT TO:', // -> rcpt - 'RCPT TO:', // -> rcpt (multiple recipients) - 'RSET', // -> ready (reset from rcpt state) - 'MAIL FROM:', // -> mail (fresh transaction) - 'RCPT TO:', // -> rcpt - 'DATA', // -> data - '.', // -> ready (complete transaction) - 'QUIT' - ]; - - let commandIndex = 0; - - client.on('data', (data) => { - const response = data.toString().trim(); - console.log(` Response: ${response}`); - - if (commandIndex < commands.length) { - setTimeout(() => { - const command = commands[commandIndex]; - console.log(` Sending: ${command}`); - if (command === 'DATA') { - client.write(command + '\r\n'); - // Send message content immediately after DATA - setTimeout(() => { - client.write('Subject: RSET test\r\n\r\nTesting RSET state transitions.\r\n.\r\n'); - }, 100); - } else { - client.write(command + '\r\n'); - } - commandIndex++; - }, 100); - } else { - client.end(); - } - }); - - await new Promise((resolve, reject) => { - client.on('end', () => { - console.log(' RSET state transitions completed successfully'); - resolve(void 0); - }); - client.on('error', reject); - }); - - await testServer.server.close(); - })(); - - // Scenario 5: Connection state persistence - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing connection state persistence`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 statemachine.example.com ESMTP\r\n'); - - let state = 'ready'; - let messageCount = 0; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - - // Process complete lines - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - // In DATA mode, look for the terminating dot - if (line === '.') { - messageCount++; - console.log(` [Server] Message ${messageCount} completed`); - socket.write(`250 OK: Message ${messageCount} accepted\r\n`); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - if (command.startsWith('EHLO')) { - socket.write('250-statemachine.example.com\r\n'); - socket.write('250 PIPELINING\r\n'); - state = 'ready'; - } else if (command.startsWith('MAIL FROM:')) { - if (state === 'ready') { - socket.write('250 OK\r\n'); - state = 'mail'; - } else { - socket.write('503 5.5.1 Bad sequence\r\n'); - } - } else if (command.startsWith('RCPT TO:')) { - if (state === 'mail' || state === 'rcpt') { - socket.write('250 OK\r\n'); - state = 'rcpt'; - } else { - socket.write('503 5.5.1 Bad sequence\r\n'); - } - } else if (command === 'DATA') { - if (state === 'rcpt') { - socket.write('354 Start mail input\r\n'); - state = 'data'; - } else { - socket.write('503 5.5.1 Bad sequence\r\n'); - } - } else if (command === 'QUIT') { - console.log(` [Server] Session ended after ${messageCount} messages`); - socket.write('221 Bye\r\n'); - socket.end(); - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - pool: true, - maxConnections: 1 - }); - - // Send multiple emails through same connection - for (let i = 1; i <= 3; i++) { - const email = new Email({ - from: 'sender@example.com', - to: [`recipient${i}@example.com`], - subject: `Persistence test ${i}`, - text: `Testing connection state persistence - message ${i}` - }); - - const result = await smtpClient.sendMail(email); - console.log(` Message ${i} sent successfully`); - expect(result).toBeDefined(); - expect(result.success).toBeTruthy(); - // Verify server tracked the message number (proves connection reuse) - if (result.response) { - expect(result.response.includes(`Message ${i}`)).toEqual(true); - } - } - - // Close the pooled connection - await smtpClient.close(); - await testServer.server.close(); - })(); - - // Scenario 6: Error state recovery - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing error state recovery`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 statemachine.example.com ESMTP\r\n'); - - let state = 'ready'; - let errorCount = 0; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - - // Process complete lines - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - // In DATA mode, look for the terminating dot - if (line === '.') { - socket.write('250 OK\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [Server] State: ${state}, Command: ${command}`); - - if (command.startsWith('EHLO')) { - socket.write('250 statemachine.example.com\r\n'); - state = 'ready'; - errorCount = 0; // Reset error count on new session - } else if (command.startsWith('MAIL FROM:')) { - const address = command.match(/<(.+)>/)?.[1] || ''; - if (address.includes('error')) { - errorCount++; - console.log(` [Server] Error ${errorCount} - invalid sender`); - socket.write('550 5.1.8 Invalid sender address\r\n'); - // State remains ready after error - } else { - socket.write('250 OK\r\n'); - state = 'mail'; - } - } else if (command.startsWith('RCPT TO:')) { - if (state === 'mail' || state === 'rcpt') { - const address = command.match(/<(.+)>/)?.[1] || ''; - if (address.includes('error')) { - errorCount++; - console.log(` [Server] Error ${errorCount} - invalid recipient`); - socket.write('550 5.1.1 User unknown\r\n'); - // State remains the same after recipient error - } else { - socket.write('250 OK\r\n'); - state = 'rcpt'; - } - } else { - socket.write('503 5.5.1 Bad sequence\r\n'); - } - } else if (command === 'DATA') { - if (state === 'rcpt') { - socket.write('354 Start mail input\r\n'); - state = 'data'; - } else { - socket.write('503 5.5.1 Bad sequence\r\n'); - } - } else if (command === 'RSET') { - console.log(` [Server] RSET - recovering from errors (${errorCount} errors so far)`); - socket.write('250 OK\r\n'); - state = 'ready'; - } else if (command === 'QUIT') { - console.log(` [Server] Session ended with ${errorCount} total errors`); - socket.write('221 Bye\r\n'); - socket.end(); - } else { - socket.write('500 5.5.1 Command not recognized\r\n'); - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test recovery from various errors - const testEmails = [ - { - from: 'error@example.com', // Will cause sender error - to: ['valid@example.com'], - desc: 'invalid sender' - }, - { - from: 'valid@example.com', - to: ['error@example.com', 'valid@example.com'], // Mixed valid/invalid recipients - desc: 'mixed recipients' - }, - { - from: 'valid@example.com', - to: ['valid@example.com'], - desc: 'valid email after errors' - } - ]; - - for (const testEmail of testEmails) { - console.log(` Testing ${testEmail.desc}...`); - - const email = new Email({ - from: testEmail.from, - to: testEmail.to, - subject: `Error recovery test: ${testEmail.desc}`, - text: `Testing error state recovery with ${testEmail.desc}` - }); - - try { - const result = await smtpClient.sendMail(email); - console.log(` ${testEmail.desc}: Success`); - if (result.rejected && result.rejected.length > 0) { - console.log(` Rejected: ${result.rejected.length} recipients`); - } - } catch (error) { - console.log(` ${testEmail.desc}: Failed as expected - ${error.message}`); - } - } - - await testServer.server.close(); - })(); - - console.log(`\n${testId}: All ${scenarioCount} state machine scenarios tested ✓`); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_rfc-compliance/test.crfc-06.protocol-negotiation.ts b/test/suite/smtpclient_rfc-compliance/test.crfc-06.protocol-negotiation.ts deleted file mode 100644 index d4f355d..0000000 --- a/test/suite/smtpclient_rfc-compliance/test.crfc-06.protocol-negotiation.ts +++ /dev/null @@ -1,735 +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/index.js'; - -tap.test('CRFC-06: should handle protocol negotiation correctly (RFC 5321)', async (tools) => { - const testId = 'CRFC-06-protocol-negotiation'; - console.log(`\n${testId}: Testing SMTP protocol negotiation compliance...`); - - let scenarioCount = 0; - - // Scenario 1: EHLO capability announcement and selection - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing EHLO capability announcement`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 negotiation.example.com ESMTP Service Ready\r\n'); - - let negotiatedCapabilities: string[] = []; - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 2.0.0 Message accepted\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO')) { - socket.write('250-negotiation.example.com\r\n'); - socket.write('250-SIZE 52428800\r\n'); - socket.write('250-8BITMIME\r\n'); - socket.write('250-STARTTLS\r\n'); - socket.write('250-ENHANCEDSTATUSCODES\r\n'); - socket.write('250-PIPELINING\r\n'); - socket.write('250-CHUNKING\r\n'); - socket.write('250-SMTPUTF8\r\n'); - socket.write('250-DSN\r\n'); - socket.write('250-AUTH PLAIN LOGIN CRAM-MD5\r\n'); - socket.write('250 HELP\r\n'); - - negotiatedCapabilities = [ - 'SIZE', '8BITMIME', 'STARTTLS', 'ENHANCEDSTATUSCODES', - 'PIPELINING', 'CHUNKING', 'SMTPUTF8', 'DSN', 'AUTH', 'HELP' - ]; - console.log(` [Server] Announced capabilities: ${negotiatedCapabilities.join(', ')}`); - } else if (command.startsWith('HELO')) { - socket.write('250 negotiation.example.com\r\n'); - negotiatedCapabilities = []; - console.log(' [Server] Basic SMTP mode (no capabilities)'); - } else if (command.startsWith('MAIL FROM:')) { - const sizeMatch = command.match(/SIZE=(\d+)/i); - if (sizeMatch && negotiatedCapabilities.includes('SIZE')) { - const size = parseInt(sizeMatch[1]); - console.log(` [Server] SIZE parameter used: ${size} bytes`); - if (size > 52428800) { - socket.write('552 5.3.4 Message size exceeds maximum\r\n'); - } else { - socket.write('250 2.1.0 Sender OK\r\n'); - } - } else if (sizeMatch && !negotiatedCapabilities.includes('SIZE')) { - console.log(' [Server] SIZE parameter used without capability'); - socket.write('501 5.5.4 SIZE not supported\r\n'); - } else { - socket.write('250 2.1.0 Sender OK\r\n'); - } - } else if (command.startsWith('RCPT TO:')) { - if (command.includes('NOTIFY=') && negotiatedCapabilities.includes('DSN')) { - console.log(' [Server] DSN NOTIFY parameter used'); - } else if (command.includes('NOTIFY=') && !negotiatedCapabilities.includes('DSN')) { - console.log(' [Server] DSN parameter used without capability'); - socket.write('501 5.5.4 DSN not supported\r\n'); - continue; - } - socket.write('250 2.1.5 Recipient OK\r\n'); - } else if (command === 'DATA') { - socket.write('354 Start mail input\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - socket.write('221 2.0.0 Bye\r\n'); - socket.end(); - } - } - }); - } - }); - - // Test EHLO negotiation - const esmtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Capability negotiation test', - text: 'Testing EHLO capability announcement and usage' - }); - - const result = await esmtpClient.sendMail(email); - console.log(' EHLO capability negotiation successful'); - expect(result).toBeDefined(); - - await testServer.server.close(); - })(); - - // Scenario 2: Capability-based feature usage - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing capability-based feature usage`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 features.example.com ESMTP\r\n'); - - let supportsUTF8 = false; - let supportsPipelining = false; - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 OK\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO')) { - socket.write('250-features.example.com\r\n'); - socket.write('250-SMTPUTF8\r\n'); - socket.write('250-PIPELINING\r\n'); - socket.write('250-8BITMIME\r\n'); - socket.write('250 SIZE 10485760\r\n'); - - supportsUTF8 = true; - supportsPipelining = true; - console.log(' [Server] UTF8 and PIPELINING capabilities announced'); - } else if (command.startsWith('MAIL FROM:')) { - if (command.includes('SMTPUTF8') && supportsUTF8) { - console.log(' [Server] SMTPUTF8 parameter accepted'); - socket.write('250 OK\r\n'); - } else if (command.includes('SMTPUTF8') && !supportsUTF8) { - console.log(' [Server] SMTPUTF8 used without capability'); - socket.write('555 5.6.7 SMTPUTF8 not supported\r\n'); - } else { - 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 Start mail input\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test with UTF-8 content - const utf8Email = new Email({ - from: 'sénder@example.com', // Non-ASCII sender - to: ['recipient@example.com'], - subject: 'UTF-8 test: café, naïve, 你好', - text: 'Testing SMTPUTF8 capability with international characters: émojis 🎉' - }); - - const result = await smtpClient.sendMail(utf8Email); - console.log(' UTF-8 email sent using SMTPUTF8 capability'); - expect(result).toBeDefined(); - - await testServer.server.close(); - })(); - - // Scenario 3: Extension parameter validation - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing extension parameter validation`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 validation.example.com ESMTP\r\n'); - - const supportedExtensions = new Set(['SIZE', 'BODY', 'DSN', '8BITMIME']); - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 OK\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO')) { - socket.write('250-validation.example.com\r\n'); - socket.write('250-SIZE 5242880\r\n'); - socket.write('250-8BITMIME\r\n'); - socket.write('250-DSN\r\n'); - socket.write('250 OK\r\n'); - } else if (command.startsWith('MAIL FROM:')) { - const params = command.substring(command.indexOf('>') + 1).trim(); - if (params) { - console.log(` [Server] Validating parameters: ${params}`); - - const paramPairs = params.split(/\s+/).filter(p => p.length > 0); - let allValid = true; - - for (const param of paramPairs) { - const [key, value] = param.split('='); - - if (key === 'SIZE') { - const size = parseInt(value || '0'); - if (isNaN(size) || size < 0) { - socket.write('501 5.5.4 Invalid SIZE value\r\n'); - allValid = false; - break; - } else if (size > 5242880) { - socket.write('552 5.3.4 Message size exceeds limit\r\n'); - allValid = false; - break; - } - console.log(` [Server] SIZE=${size} validated`); - } else if (key === 'BODY') { - if (value !== '7BIT' && value !== '8BITMIME') { - socket.write('501 5.5.4 Invalid BODY value\r\n'); - allValid = false; - break; - } - console.log(` [Server] BODY=${value} validated`); - } else if (key === 'RET') { - if (value !== 'FULL' && value !== 'HDRS') { - socket.write('501 5.5.4 Invalid RET value\r\n'); - allValid = false; - break; - } - console.log(` [Server] RET=${value} validated`); - } else if (key === 'ENVID') { - if (!value) { - socket.write('501 5.5.4 ENVID requires value\r\n'); - allValid = false; - break; - } - console.log(` [Server] ENVID=${value} validated`); - } else { - console.log(` [Server] Unknown parameter: ${key}`); - socket.write(`555 5.5.4 Unsupported parameter: ${key}\r\n`); - allValid = false; - break; - } - } - - if (allValid) { - socket.write('250 OK\r\n'); - } - } else { - socket.write('250 OK\r\n'); - } - } else if (command.startsWith('RCPT TO:')) { - const params = command.substring(command.indexOf('>') + 1).trim(); - if (params) { - const paramPairs = params.split(/\s+/).filter(p => p.length > 0); - let allValid = true; - - for (const param of paramPairs) { - const [key, value] = param.split('='); - - if (key === 'NOTIFY') { - const notifyValues = value.split(','); - const validNotify = ['NEVER', 'SUCCESS', 'FAILURE', 'DELAY']; - - for (const nv of notifyValues) { - if (!validNotify.includes(nv)) { - socket.write('501 5.5.4 Invalid NOTIFY value\r\n'); - allValid = false; - break; - } - } - - if (allValid) { - console.log(` [Server] NOTIFY=${value} validated`); - } - } else if (key === 'ORCPT') { - if (!value.includes(';')) { - socket.write('501 5.5.4 Invalid ORCPT format\r\n'); - allValid = false; - break; - } - console.log(` [Server] ORCPT=${value} validated`); - } else { - socket.write(`555 5.5.4 Unsupported RCPT parameter: ${key}\r\n`); - allValid = false; - break; - } - } - - if (allValid) { - socket.write('250 OK\r\n'); - } - } else { - socket.write('250 OK\r\n'); - } - } else if (command === 'DATA') { - socket.write('354 Start mail input\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test with various valid parameters - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Parameter validation test', - text: 'Testing ESMTP parameter validation', - dsn: { - notify: ['SUCCESS', 'FAILURE'], - envid: 'test-envelope-id-123', - ret: 'FULL' - } - }); - - const result = await smtpClient.sendMail(email); - console.log(' ESMTP parameter validation successful'); - expect(result).toBeDefined(); - - await testServer.server.close(); - })(); - - // Scenario 4: Service extension discovery - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing service extension discovery`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 discovery.example.com ESMTP Ready\r\n'); - - let clientName = ''; - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 OK\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO ')) { - clientName = command.substring(5); - console.log(` [Server] Client identified as: ${clientName}`); - - socket.write('250-discovery.example.com\r\n'); - socket.write('250-STARTTLS\r\n'); - socket.write('250-AUTH PLAIN LOGIN CRAM-MD5 DIGEST-MD5\r\n'); - socket.write('250-SIZE 104857600\r\n'); - socket.write('250-8BITMIME\r\n'); - socket.write('250-SMTPUTF8\r\n'); - socket.write('250-DSN\r\n'); - socket.write('250-DELIVERBY 86400\r\n'); - socket.write('250-PIPELINING\r\n'); - socket.write('250-CHUNKING\r\n'); - socket.write('250-BINARYMIME\r\n'); - socket.write('250-ENHANCEDSTATUSCODES\r\n'); - socket.write('250-NO-SOLICITING\r\n'); - socket.write('250-MTRK\r\n'); - socket.write('250 HELP\r\n'); - } else if (command.startsWith('HELO ')) { - clientName = command.substring(5); - console.log(` [Server] Basic SMTP client: ${clientName}`); - socket.write('250 discovery.example.com\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 Start mail input\r\n'); - state = 'data'; - } else if (command === 'HELP') { - socket.write('214-This server supports the following features:\r\n'); - socket.write('214-STARTTLS - Start TLS negotiation\r\n'); - socket.write('214-AUTH - SMTP Authentication\r\n'); - socket.write('214-SIZE - Message size declaration\r\n'); - socket.write('214-8BITMIME - 8-bit MIME transport\r\n'); - socket.write('214-SMTPUTF8 - UTF-8 support\r\n'); - socket.write('214-DSN - Delivery Status Notifications\r\n'); - socket.write('214-PIPELINING - Command pipelining\r\n'); - socket.write('214-CHUNKING - BDAT chunking\r\n'); - socket.write('214 For more information, visit our website\r\n'); - } else if (command === 'QUIT') { - socket.write('221 Thank you for using our service\r\n'); - socket.end(); - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - name: 'test-client.example.com' - }); - - // Test service discovery - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Service discovery test', - text: 'Testing SMTP service extension discovery' - }); - - const result = await smtpClient.sendMail(email); - console.log(' Service extension discovery completed'); - expect(result).toBeDefined(); - - await testServer.server.close(); - })(); - - // Scenario 5: Backward compatibility negotiation - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing backward compatibility negotiation`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 compat.example.com ESMTP\r\n'); - - let isESMTP = false; - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - if (isESMTP) { - socket.write('250 2.0.0 Message accepted\r\n'); - } else { - socket.write('250 Message accepted\r\n'); - } - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO')) { - isESMTP = true; - console.log(' [Server] ESMTP mode enabled'); - socket.write('250-compat.example.com\r\n'); - socket.write('250-SIZE 10485760\r\n'); - socket.write('250-8BITMIME\r\n'); - socket.write('250 ENHANCEDSTATUSCODES\r\n'); - } else if (command.startsWith('HELO')) { - isESMTP = false; - console.log(' [Server] Basic SMTP mode (RFC 821 compatibility)'); - socket.write('250 compat.example.com\r\n'); - } else if (command.startsWith('MAIL FROM:')) { - if (isESMTP) { - if (command.includes('SIZE=') || command.includes('BODY=')) { - console.log(' [Server] ESMTP parameters accepted'); - } - socket.write('250 2.1.0 Sender OK\r\n'); - } else { - if (command.includes('SIZE=') || command.includes('BODY=')) { - console.log(' [Server] ESMTP parameters rejected in basic mode'); - socket.write('501 5.5.4 Syntax error in parameters\r\n'); - } else { - socket.write('250 Sender OK\r\n'); - } - } - } else if (command.startsWith('RCPT TO:')) { - if (isESMTP) { - socket.write('250 2.1.5 Recipient OK\r\n'); - } else { - socket.write('250 Recipient OK\r\n'); - } - } else if (command === 'DATA') { - socket.write('354 Start mail input\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - if (isESMTP) { - socket.write('221 2.0.0 Service closing\r\n'); - } else { - socket.write('221 Service closing\r\n'); - } - socket.end(); - } - } - }); - } - }); - - // Test ESMTP mode - const esmtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - const esmtpEmail = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'ESMTP compatibility test', - text: 'Testing ESMTP mode with extensions' - }); - - const esmtpResult = await esmtpClient.sendMail(esmtpEmail); - console.log(' ESMTP mode negotiation successful'); - expect(esmtpResult).toBeDefined(); - expect(esmtpResult.success).toBeTruthy(); - // Per RFC 5321, successful mail transfer is indicated by 250 response - // Enhanced status codes (RFC 3463) are parsed separately by the client - expect(esmtpResult.response).toBeDefined(); - - await testServer.server.close(); - })(); - - // Scenario 6: Extension interdependencies - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing extension interdependencies`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 interdep.example.com ESMTP\r\n'); - - let tlsEnabled = false; - let authenticated = false; - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 OK\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - console.log(` [Server] Received: ${command} (TLS: ${tlsEnabled}, Auth: ${authenticated})`); - - if (command.startsWith('EHLO')) { - socket.write('250-interdep.example.com\r\n'); - - if (!tlsEnabled) { - socket.write('250-STARTTLS\r\n'); - socket.write('250-SIZE 1048576\r\n'); - } else { - socket.write('250-SIZE 52428800\r\n'); - socket.write('250-8BITMIME\r\n'); - socket.write('250-SMTPUTF8\r\n'); - socket.write('250-AUTH PLAIN LOGIN CRAM-MD5\r\n'); - - if (authenticated) { - socket.write('250-DSN\r\n'); - socket.write('250-DELIVERBY 86400\r\n'); - } - } - - socket.write('250 ENHANCEDSTATUSCODES\r\n'); - } else if (command === 'STARTTLS') { - if (!tlsEnabled) { - socket.write('220 2.0.0 Ready to start TLS\r\n'); - tlsEnabled = true; - console.log(' [Server] TLS enabled (simulated)'); - } else { - socket.write('503 5.5.1 TLS already active\r\n'); - } - } else if (command.startsWith('AUTH')) { - if (tlsEnabled) { - authenticated = true; - console.log(' [Server] Authentication successful (simulated)'); - socket.write('235 2.7.0 Authentication successful\r\n'); - } else { - console.log(' [Server] AUTH rejected - TLS required'); - socket.write('538 5.7.11 Encryption required for authentication\r\n'); - } - } else if (command.startsWith('MAIL FROM:')) { - if (command.includes('SMTPUTF8') && !tlsEnabled) { - console.log(' [Server] SMTPUTF8 requires TLS'); - socket.write('530 5.7.0 Must issue STARTTLS first\r\n'); - } else { - socket.write('250 OK\r\n'); - } - } else if (command.startsWith('RCPT TO:')) { - if (command.includes('NOTIFY=') && !authenticated) { - console.log(' [Server] DSN requires authentication'); - socket.write('530 5.7.0 Authentication required for DSN\r\n'); - } else { - socket.write('250 OK\r\n'); - } - } else if (command === 'DATA') { - socket.write('354 Start mail input\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } - } - }); - } - }); - - // Test extension dependencies - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - requireTLS: true, // This will trigger STARTTLS - auth: { - user: 'testuser', - pass: 'testpass' - } - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Extension interdependency test', - text: 'Testing SMTP extension interdependencies', - dsn: { - notify: ['SUCCESS'], - envid: 'interdep-test-123' - } - }); - - try { - const result = await smtpClient.sendMail(email); - console.log(' Extension interdependency handling successful'); - expect(result).toBeDefined(); - } catch (error) { - console.log(` Extension dependency error (expected in test): ${error.message}`); - // In test environment, STARTTLS won't actually work - } - - await testServer.server.close(); - })(); - - console.log(`\n${testId}: All ${scenarioCount} protocol negotiation scenarios tested ✓`); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_rfc-compliance/test.crfc-07.interoperability.ts b/test/suite/smtpclient_rfc-compliance/test.crfc-07.interoperability.ts deleted file mode 100644 index c5b2c21..0000000 --- a/test/suite/smtpclient_rfc-compliance/test.crfc-07.interoperability.ts +++ /dev/null @@ -1,830 +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/index.js'; - -tap.test('CRFC-07: should ensure SMTP interoperability (RFC 5321)', async (tools) => { - const testId = 'CRFC-07-interoperability'; - console.log(`\n${testId}: Testing SMTP interoperability compliance...`); - - let scenarioCount = 0; - - // Scenario 1: Different server implementations compatibility - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing different server implementations`); - - const serverImplementations = [ - { - name: 'Sendmail-style', - greeting: '220 mail.example.com ESMTP Sendmail 8.15.2/8.15.2; Date Time', - ehloResponse: [ - '250-mail.example.com Hello client.example.com [192.168.1.100]', - '250-ENHANCEDSTATUSCODES', - '250-PIPELINING', - '250-8BITMIME', - '250-SIZE 36700160', - '250-DSN', - '250-ETRN', - '250-DELIVERBY', - '250 HELP' - ], - quirks: { verboseResponses: true, includesTimestamp: true } - }, - { - name: 'Postfix-style', - greeting: '220 mail.example.com ESMTP Postfix', - ehloResponse: [ - '250-mail.example.com', - '250-PIPELINING', - '250-SIZE 10240000', - '250-VRFY', - '250-ETRN', - '250-ENHANCEDSTATUSCODES', - '250-8BITMIME', - '250-DSN', - '250 SMTPUTF8' - ], - quirks: { shortResponses: true, strictSyntax: true } - }, - { - name: 'Exchange-style', - greeting: '220 mail.example.com Microsoft ESMTP MAIL Service ready', - ehloResponse: [ - '250-mail.example.com Hello [192.168.1.100]', - '250-SIZE 37748736', - '250-PIPELINING', - '250-DSN', - '250-ENHANCEDSTATUSCODES', - '250-8BITMIME', - '250-BINARYMIME', - '250-CHUNKING', - '250 OK' - ], - quirks: { windowsLineEndings: true, detailedErrors: true } - } - ]; - - for (const impl of serverImplementations) { - console.log(`\n Testing with ${impl.name} server...`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(` [${impl.name}] Client connected`); - socket.write(impl.greeting + '\r\n'); - - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - const timestamp = impl.quirks.includesTimestamp ? - ` at ${new Date().toISOString()}` : ''; - socket.write(`250 2.0.0 Message accepted for delivery${timestamp}\r\n`); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [${impl.name}] Received: ${command}`); - - if (command.startsWith('EHLO')) { - impl.ehloResponse.forEach(respLine => { - socket.write(respLine + '\r\n'); - }); - } else if (command.startsWith('MAIL FROM:')) { - if (impl.quirks.strictSyntax && !command.includes('<')) { - socket.write('501 5.5.4 Syntax error in MAIL command\r\n'); - } else { - const response = impl.quirks.verboseResponses ? - '250 2.1.0 Sender OK' : '250 OK'; - socket.write(response + '\r\n'); - } - } else if (command.startsWith('RCPT TO:')) { - const response = impl.quirks.verboseResponses ? - '250 2.1.5 Recipient OK' : '250 OK'; - socket.write(response + '\r\n'); - } else if (command === 'DATA') { - const response = impl.quirks.detailedErrors ? - '354 Start mail input; end with .' : - '354 Enter message, ending with "." on a line by itself'; - socket.write(response + '\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - const response = impl.quirks.verboseResponses ? - '221 2.0.0 Service closing transmission channel' : - '221 Bye'; - socket.write(response + '\r\n'); - socket.end(); - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: `Interoperability test with ${impl.name}`, - text: `Testing compatibility with ${impl.name} server implementation` - }); - - const result = await smtpClient.sendMail(email); - console.log(` ${impl.name} compatibility: Success`); - expect(result).toBeDefined(); - expect(result.success).toBeTruthy(); - - await testServer.server.close(); - } - })(); - - // Scenario 2: Character encoding and internationalization - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing character encoding interoperability`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 international.example.com ESMTP\r\n'); - - let supportsUTF8 = false; - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 OK: International message accepted\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO')) { - socket.write('250-international.example.com\r\n'); - socket.write('250-8BITMIME\r\n'); - socket.write('250-SMTPUTF8\r\n'); - socket.write('250 OK\r\n'); - supportsUTF8 = true; - } else if (command.startsWith('MAIL FROM:')) { - // Check for non-ASCII characters - const hasNonASCII = /[^\x00-\x7F]/.test(command); - const hasUTF8Param = command.includes('SMTPUTF8'); - - console.log(` [Server] Non-ASCII: ${hasNonASCII}, UTF8 param: ${hasUTF8Param}`); - - if (hasNonASCII && !hasUTF8Param) { - socket.write('553 5.6.7 Non-ASCII addresses require SMTPUTF8\r\n'); - } else { - 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 Start mail input\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test various international character sets - const internationalTests = [ - { - desc: 'Latin characters with accents', - from: 'sénder@éxample.com', - to: 'récipient@éxample.com', - subject: 'Tëst with açcénts', - text: 'Café, naïve, résumé, piñata' - }, - { - desc: 'Cyrillic characters', - from: 'отправитель@пример.com', - to: 'получатель@пример.com', - subject: 'Тест с кириллицей', - text: 'Привет мир! Это тест с русскими буквами.' - }, - { - desc: 'Chinese characters', - from: 'sender@example.com', // ASCII for compatibility - to: 'recipient@example.com', - subject: '测试中文字符', - text: '你好世界!这是一个中文测试。' - }, - { - desc: 'Arabic characters', - from: 'sender@example.com', - to: 'recipient@example.com', - subject: 'اختبار النص العربي', - text: 'مرحبا بالعالم! هذا اختبار باللغة العربية.' - }, - { - desc: 'Emoji and symbols', - from: 'sender@example.com', - to: 'recipient@example.com', - subject: '🎉 Test with emojis 🌟', - text: 'Hello 👋 World 🌍! Testing emojis: 🚀 📧 ✨' - } - ]; - - for (const test of internationalTests) { - console.log(` Testing: ${test.desc}`); - - const email = new Email({ - from: test.from, - to: [test.to], - subject: test.subject, - text: test.text - }); - - try { - const result = await smtpClient.sendMail(email); - console.log(` ${test.desc}: Success`); - expect(result).toBeDefined(); - } catch (error) { - console.log(` ${test.desc}: Failed - ${error.message}`); - // Some may fail if server doesn't support international addresses - } - } - - await testServer.server.close(); - })(); - - // Scenario 3: Message format compatibility - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing message format compatibility`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 formats.example.com ESMTP\r\n'); - - let state = 'ready'; - let buffer = ''; - let messageContent = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - // Analyze message format - const headerEnd = messageContent.indexOf('\r\n\r\n'); - if (headerEnd !== -1) { - const headers = messageContent.substring(0, headerEnd); - const body = messageContent.substring(headerEnd + 4); - - console.log(' [Server] Message analysis:'); - console.log(` Header count: ${(headers.match(/\r\n/g) || []).length + 1}`); - console.log(` Body size: ${body.length} bytes`); - - // Check for proper header folding - const longHeaders = headers.split('\r\n').filter(h => h.length > 78); - if (longHeaders.length > 0) { - console.log(` Long headers detected: ${longHeaders.length}`); - } - - // Check for MIME structure - if (headers.includes('Content-Type:')) { - console.log(' MIME message detected'); - } - } - - socket.write('250 OK: Message format validated\r\n'); - messageContent = ''; - state = 'ready'; - } else { - messageContent += line + '\r\n'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO')) { - socket.write('250-formats.example.com\r\n'); - socket.write('250-8BITMIME\r\n'); - socket.write('250-BINARYMIME\r\n'); - socket.write('250 SIZE 52428800\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 Start mail input\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test different message formats - const formatTests = [ - { - desc: 'Plain text message', - email: new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Plain text test', - text: 'This is a simple plain text message.' - }) - }, - { - desc: 'HTML message', - email: new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'HTML test', - html: '

HTML Message

This is an HTML message.

' - }) - }, - { - desc: 'Multipart alternative', - email: new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Multipart test', - text: 'Plain text version', - html: '

HTML version

' - }) - }, - { - desc: 'Message with attachment', - email: new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Attachment test', - text: 'Message with attachment', - attachments: [{ - filename: 'test.txt', - content: 'This is a test attachment' - }] - }) - }, - { - desc: 'Message with custom headers', - email: new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Custom headers test', - text: 'Message with custom headers', - headers: { - 'X-Custom-Header': 'Custom value', - 'X-Mailer': 'Test Mailer 1.0', - 'Message-ID': '', - 'References': ' ' - } - }) - } - ]; - - for (const test of formatTests) { - console.log(` Testing: ${test.desc}`); - - const result = await smtpClient.sendMail(test.email); - console.log(` ${test.desc}: Success`); - expect(result).toBeDefined(); - expect(result.success).toBeTruthy(); - } - - await testServer.server.close(); - })(); - - // Scenario 4: Error handling interoperability - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing error handling interoperability`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 errors.example.com ESMTP\r\n'); - - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 OK\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO')) { - socket.write('250-errors.example.com\r\n'); - socket.write('250-ENHANCEDSTATUSCODES\r\n'); - socket.write('250 OK\r\n'); - } else if (command.startsWith('MAIL FROM:')) { - const address = command.match(/<(.+)>/)?.[1] || ''; - - if (address.includes('temp-fail')) { - // Temporary failure - client should retry - socket.write('451 4.7.1 Temporary system problem, try again later\r\n'); - } else if (address.includes('perm-fail')) { - // Permanent failure - client should not retry - socket.write('550 5.1.8 Invalid sender address format\r\n'); - } else if (address.includes('syntax-error')) { - // Syntax error - socket.write('501 5.5.4 Syntax error in MAIL command\r\n'); - } else { - socket.write('250 OK\r\n'); - } - } else if (command.startsWith('RCPT TO:')) { - const address = command.match(/<(.+)>/)?.[1] || ''; - - if (address.includes('unknown')) { - socket.write('550 5.1.1 User unknown in local recipient table\r\n'); - } else if (address.includes('temp-reject')) { - socket.write('450 4.2.1 Mailbox temporarily unavailable\r\n'); - } else if (address.includes('quota-exceeded')) { - socket.write('552 5.2.2 Mailbox over quota\r\n'); - } else { - socket.write('250 OK\r\n'); - } - } else if (command === 'DATA') { - socket.write('354 Start mail input\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } else { - // Unknown command - socket.write('500 5.5.1 Command unrecognized\r\n'); - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test various error scenarios - const errorTests = [ - { - desc: 'Temporary sender failure', - from: 'temp-fail@example.com', - to: 'valid@example.com', - expectError: true, - errorType: '4xx' - }, - { - desc: 'Permanent sender failure', - from: 'perm-fail@example.com', - to: 'valid@example.com', - expectError: true, - errorType: '5xx' - }, - { - desc: 'Unknown recipient', - from: 'valid@example.com', - to: 'unknown@example.com', - expectError: true, - errorType: '5xx' - }, - { - desc: 'Mixed valid/invalid recipients', - from: 'valid@example.com', - to: ['valid@example.com', 'unknown@example.com', 'temp-reject@example.com'], - expectError: false, // Partial success - errorType: 'mixed' - } - ]; - - for (const test of errorTests) { - console.log(` Testing: ${test.desc}`); - - const email = new Email({ - from: test.from, - to: Array.isArray(test.to) ? test.to : [test.to], - subject: `Error test: ${test.desc}`, - text: `Testing error handling for ${test.desc}` - }); - - try { - const result = await smtpClient.sendMail(email); - - if (test.expectError && test.errorType !== 'mixed') { - console.log(` Unexpected success for ${test.desc}`); - } else { - console.log(` ${test.desc}: Handled correctly`); - if (result.rejected && result.rejected.length > 0) { - console.log(` Rejected: ${result.rejected.length} recipients`); - } - if (result.accepted && result.accepted.length > 0) { - console.log(` Accepted: ${result.accepted.length} recipients`); - } - } - } catch (error) { - if (test.expectError) { - console.log(` ${test.desc}: Failed as expected (${error.responseCode})`); - if (test.errorType === '4xx') { - expect(error.responseCode).toBeGreaterThanOrEqual(400); - expect(error.responseCode).toBeLessThan(500); - } else if (test.errorType === '5xx') { - expect(error.responseCode).toBeGreaterThanOrEqual(500); - expect(error.responseCode).toBeLessThan(600); - } - } else { - console.log(` Unexpected error for ${test.desc}: ${error.message}`); - } - } - } - - await testServer.server.close(); - })(); - - // Scenario 5: Connection management interoperability - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing connection management interoperability`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - - let commandCount = 0; - let idleTime = Date.now(); - const maxIdleTime = 5000; // 5 seconds for testing - const maxCommands = 10; - let state = 'ready'; - let buffer = ''; - - socket.write('220 connection.example.com ESMTP\r\n'); - - // Set up idle timeout - const idleCheck = setInterval(() => { - if (Date.now() - idleTime > maxIdleTime) { - console.log(' [Server] Idle timeout - closing connection'); - socket.write('421 4.4.2 Idle timeout, closing connection\r\n'); - socket.end(); - clearInterval(idleCheck); - } - }, 1000); - - socket.on('data', (data) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - idleTime = Date.now(); - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 OK\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - commandCount++; - console.log(` [Server] Command ${commandCount}: ${command}`); - - if (commandCount > maxCommands) { - console.log(' [Server] Too many commands - closing connection'); - socket.write('421 4.7.0 Too many commands, closing connection\r\n'); - socket.end(); - clearInterval(idleCheck); - return; - } - - if (command.startsWith('EHLO')) { - socket.write('250-connection.example.com\r\n'); - socket.write('250-PIPELINING\r\n'); - 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 Start mail input\r\n'); - state = 'data'; - } else if (command === 'RSET') { - socket.write('250 OK\r\n'); - } else if (command === 'NOOP') { - socket.write('250 OK\r\n'); - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - clearInterval(idleCheck); - } - } - }); - - socket.on('close', () => { - clearInterval(idleCheck); - console.log(` [Server] Connection closed after ${commandCount} commands`); - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - pool: true, - maxConnections: 1 - }); - - // Test connection reuse - console.log(' Testing connection reuse...'); - - for (let i = 1; i <= 3; i++) { - const email = new Email({ - from: 'sender@example.com', - to: [`recipient${i}@example.com`], - subject: `Connection test ${i}`, - text: `Testing connection management - email ${i}` - }); - - const result = await smtpClient.sendMail(email); - console.log(` Email ${i} sent successfully`); - expect(result).toBeDefined(); - - // Small delay to test connection persistence - await new Promise(resolve => setTimeout(resolve, 500)); - } - - // Test NOOP for keeping connection alive - console.log(' Testing connection keep-alive...'); - - await smtpClient.verify(); // This might send NOOP - console.log(' Connection verified (keep-alive)'); - - await smtpClient.close(); - await testServer.server.close(); - })(); - - // Scenario 6: Legacy SMTP compatibility - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing legacy SMTP compatibility`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Legacy SMTP server'); - - let state = 'ready'; - let buffer = ''; - - // Old-style greeting without ESMTP - socket.write('220 legacy.example.com Simple Mail Transfer Service Ready\r\n'); - - socket.on('data', (data) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 Message accepted for delivery\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO')) { - // Legacy server doesn't understand EHLO - socket.write('500 Command unrecognized\r\n'); - } else if (command.startsWith('HELO')) { - socket.write('250 legacy.example.com\r\n'); - } else if (command.startsWith('MAIL FROM:')) { - // Very strict syntax checking - if (!command.match(/^MAIL FROM:\s*<[^>]+>\s*$/)) { - socket.write('501 Syntax error\r\n'); - } else { - socket.write('250 Sender OK\r\n'); - } - } else if (command.startsWith('RCPT TO:')) { - if (!command.match(/^RCPT TO:\s*<[^>]+>\s*$/)) { - socket.write('501 Syntax error\r\n'); - } else { - socket.write('250 Recipient OK\r\n'); - } - } else if (command === 'DATA') { - socket.write('354 Enter mail, end with "." on a line by itself\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - socket.write('221 Service closing transmission channel\r\n'); - socket.end(); - } else if (command === 'HELP') { - socket.write('214-Commands supported:\r\n'); - socket.write('214-HELO MAIL RCPT DATA QUIT HELP\r\n'); - socket.write('214 End of HELP info\r\n'); - } else { - socket.write('500 Command unrecognized\r\n'); - } - } - }); - } - }); - - // Test with client - modern clients may not support legacy SMTP fallback - const legacyClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Legacy compatibility test', - text: 'Testing compatibility with legacy SMTP servers' - }); - - const result = await legacyClient.sendMail(email); - expect(result).toBeDefined(); - if (result.success) { - console.log(' Legacy SMTP compatibility: Success'); - } else { - // Modern SMTP clients may not support fallback from EHLO to HELO - // This is acceptable behavior - log and continue - console.log(' Legacy SMTP fallback not supported (client requires ESMTP)'); - console.log(' (This is expected for modern SMTP clients)'); - } - - await testServer.server.close(); - })(); - - console.log(`\n${testId}: All ${scenarioCount} interoperability scenarios tested ✓`); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_rfc-compliance/test.crfc-08.smtp-extensions.ts b/test/suite/smtpclient_rfc-compliance/test.crfc-08.smtp-extensions.ts deleted file mode 100644 index 28bcf16..0000000 --- a/test/suite/smtpclient_rfc-compliance/test.crfc-08.smtp-extensions.ts +++ /dev/null @@ -1,760 +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/index.js'; - -tap.test('CRFC-08: should handle SMTP extensions correctly (Various RFCs)', async (tools) => { - const testId = 'CRFC-08-smtp-extensions'; - console.log(`\n${testId}: Testing SMTP extensions compliance...`); - - let scenarioCount = 0; - - // Scenario 1: CHUNKING extension (RFC 3030) - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing CHUNKING extension (RFC 3030)`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 chunking.example.com ESMTP\r\n'); - - let chunkingMode = false; - let totalChunks = 0; - let totalBytes = 0; - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - if (chunkingMode) { - // In chunking mode, all data is message content - totalBytes += data.length; - console.log(` [Server] Received chunk: ${data.length} bytes`); - return; - } - - buffer += data.toString(); - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 OK\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO')) { - socket.write('250-chunking.example.com\r\n'); - socket.write('250-CHUNKING\r\n'); - socket.write('250-8BITMIME\r\n'); - socket.write('250-BINARYMIME\r\n'); - socket.write('250 OK\r\n'); - } else if (command.startsWith('MAIL FROM:')) { - if (command.includes('BODY=BINARYMIME')) { - console.log(' [Server] Binary MIME body declared'); - } - socket.write('250 OK\r\n'); - } else if (command.startsWith('RCPT TO:')) { - socket.write('250 OK\r\n'); - } else if (command.startsWith('BDAT ')) { - // BDAT command format: BDAT [LAST] - const parts = command.split(' '); - const chunkSize = parseInt(parts[1]); - const isLast = parts.includes('LAST'); - - totalChunks++; - console.log(` [Server] BDAT chunk ${totalChunks}: ${chunkSize} bytes${isLast ? ' (LAST)' : ''}`); - - if (isLast) { - socket.write(`250 OK: Message accepted, ${totalChunks} chunks, ${totalBytes} total bytes\r\n`); - chunkingMode = false; - totalChunks = 0; - totalBytes = 0; - } else { - socket.write('250 OK: Chunk accepted\r\n'); - chunkingMode = true; - } - } else if (command === 'DATA') { - // Accept DATA as fallback if client doesn't support BDAT - socket.write('354 Start mail input\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test with binary content that would benefit from chunking - const binaryContent = Buffer.alloc(1024); - for (let i = 0; i < binaryContent.length; i++) { - binaryContent[i] = i % 256; - } - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'CHUNKING test', - text: 'Testing CHUNKING extension with binary data', - attachments: [{ - filename: 'binary-data.bin', - content: binaryContent - }] - }); - - const result = await smtpClient.sendMail(email); - console.log(' CHUNKING extension handled (if supported by client)'); - expect(result).toBeDefined(); - expect(result.success).toBeTruthy(); - - await testServer.server.close(); - })(); - - // Scenario 2: DELIVERBY extension (RFC 2852) - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing DELIVERBY extension (RFC 2852)`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 deliverby.example.com ESMTP\r\n'); - - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 OK: Message queued with delivery deadline\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO')) { - socket.write('250-deliverby.example.com\r\n'); - socket.write('250-DELIVERBY 86400\r\n'); // 24 hours max - socket.write('250 OK\r\n'); - } else if (command.startsWith('MAIL FROM:')) { - // Check for DELIVERBY parameter - const deliverByMatch = command.match(/DELIVERBY=(\d+)([RN]?)/i); - if (deliverByMatch) { - const seconds = parseInt(deliverByMatch[1]); - const mode = deliverByMatch[2] || 'R'; // R=return, N=notify - - console.log(` [Server] DELIVERBY: ${seconds} seconds, mode: ${mode}`); - - if (seconds > 86400) { - socket.write('501 5.5.4 DELIVERBY time exceeds maximum\r\n'); - } else if (seconds < 0) { - socket.write('501 5.5.4 Invalid DELIVERBY time\r\n'); - } else { - socket.write('250 OK: Delivery deadline accepted\r\n'); - } - } else { - 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 Start mail input\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test with delivery deadline - const email = new Email({ - from: 'sender@example.com', - to: ['urgent@example.com'], - subject: 'Urgent delivery test', - text: 'This message has a delivery deadline', - // Note: Most SMTP clients don't expose DELIVERBY directly - // but we can test server handling - }); - - const result = await smtpClient.sendMail(email); - console.log(' DELIVERBY extension supported by server'); - expect(result).toBeDefined(); - - await testServer.server.close(); - })(); - - // Scenario 3: ETRN extension (RFC 1985) - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing ETRN extension (RFC 1985)`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 etrn.example.com ESMTP\r\n'); - - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 OK\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO')) { - socket.write('250-etrn.example.com\r\n'); - socket.write('250-ETRN\r\n'); - socket.write('250 OK\r\n'); - } else if (command.startsWith('ETRN ')) { - const domain = command.substring(5); - console.log(` [Server] ETRN request for domain: ${domain}`); - - if (domain === '@example.com') { - socket.write('250 OK: Queue processing started for example.com\r\n'); - } else if (domain === '#urgent') { - socket.write('250 OK: Urgent queue processing started\r\n'); - } else if (domain.includes('unknown')) { - socket.write('458 Unable to queue messages for node\r\n'); - } else { - socket.write('250 OK: Queue processing started\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 Start mail input\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } - } - }); - } - }); - - // ETRN is typically used by mail servers, not clients - // We'll test the server's ETRN capability manually - const net = await import('net'); - const client = net.createConnection(testServer.port, testServer.hostname); - - const commands = [ - 'EHLO client.example.com', - 'ETRN @example.com', // Request queue processing for domain - 'ETRN #urgent', // Request urgent queue processing - 'ETRN unknown.domain.com', // Test error handling - 'QUIT' - ]; - - let commandIndex = 0; - - client.on('data', (data) => { - const response = data.toString().trim(); - console.log(` [Client] Response: ${response}`); - - if (commandIndex < commands.length) { - setTimeout(() => { - const command = commands[commandIndex]; - console.log(` [Client] Sending: ${command}`); - client.write(command + '\r\n'); - commandIndex++; - }, 100); - } else { - client.end(); - } - }); - - await new Promise((resolve, reject) => { - client.on('end', () => { - console.log(' ETRN extension testing completed'); - resolve(void 0); - }); - client.on('error', reject); - }); - - await testServer.server.close(); - })(); - - // Scenario 4: VRFY and EXPN extensions (RFC 5321) - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing VRFY and EXPN extensions`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 verify.example.com ESMTP\r\n'); - - // Simulated user database - const users = new Map([ - ['admin', { email: 'admin@example.com', fullName: 'Administrator' }], - ['john', { email: 'john.doe@example.com', fullName: 'John Doe' }], - ['support', { email: 'support@example.com', fullName: 'Support Team' }] - ]); - - const mailingLists = new Map([ - ['staff', ['admin@example.com', 'john.doe@example.com']], - ['support-team', ['support@example.com', 'admin@example.com']] - ]); - - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 OK\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO')) { - socket.write('250-verify.example.com\r\n'); - socket.write('250-VRFY\r\n'); - socket.write('250-EXPN\r\n'); - socket.write('250 OK\r\n'); - } else if (command.startsWith('VRFY ')) { - const query = command.substring(5); - console.log(` [Server] VRFY query: ${query}`); - - // Look up user - const user = users.get(query.toLowerCase()); - if (user) { - socket.write(`250 ${user.fullName} <${user.email}>\r\n`); - } else { - // Check if it's an email address - const emailMatch = Array.from(users.values()).find(u => - u.email.toLowerCase() === query.toLowerCase() - ); - if (emailMatch) { - socket.write(`250 ${emailMatch.fullName} <${emailMatch.email}>\r\n`); - } else { - socket.write('550 5.1.1 User unknown\r\n'); - } - } - } else if (command.startsWith('EXPN ')) { - const listName = command.substring(5); - console.log(` [Server] EXPN query: ${listName}`); - - const list = mailingLists.get(listName.toLowerCase()); - if (list) { - socket.write(`250-Mailing list ${listName}:\r\n`); - list.forEach((email, index) => { - const prefix = index < list.length - 1 ? '250-' : '250 '; - socket.write(`${prefix}${email}\r\n`); - }); - } else { - socket.write('550 5.1.1 Mailing list not found\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 Start mail input\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } - } - }); - } - }); - - // Test VRFY and EXPN commands - const net = await import('net'); - const client = net.createConnection(testServer.port, testServer.hostname); - - const commands = [ - 'EHLO client.example.com', - 'VRFY admin', // Verify user by username - 'VRFY john.doe@example.com', // Verify user by email - 'VRFY nonexistent', // Test unknown user - 'EXPN staff', // Expand mailing list - 'EXPN nonexistent-list', // Test unknown list - 'QUIT' - ]; - - let commandIndex = 0; - - client.on('data', (data) => { - const response = data.toString().trim(); - console.log(` [Client] Response: ${response}`); - - if (commandIndex < commands.length) { - setTimeout(() => { - const command = commands[commandIndex]; - console.log(` [Client] Sending: ${command}`); - client.write(command + '\r\n'); - commandIndex++; - }, 200); - } else { - client.end(); - } - }); - - await new Promise((resolve, reject) => { - client.on('end', () => { - console.log(' VRFY and EXPN testing completed'); - resolve(void 0); - }); - client.on('error', reject); - }); - - await testServer.server.close(); - })(); - - // Scenario 5: HELP extension - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing HELP extension`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 help.example.com ESMTP\r\n'); - - const helpTopics = new Map([ - ['commands', [ - 'Available commands:', - 'EHLO - Extended HELLO', - 'MAIL FROM: - Specify sender', - 'RCPT TO: - Specify recipient', - 'DATA - Start message text', - 'QUIT - Close connection' - ]], - ['extensions', [ - 'Supported extensions:', - 'SIZE - Message size declaration', - '8BITMIME - 8-bit MIME transport', - 'STARTTLS - Start TLS negotiation', - 'AUTH - SMTP Authentication', - 'DSN - Delivery Status Notifications' - ]], - ['syntax', [ - 'Command syntax:', - 'Commands are case-insensitive', - 'Lines end with CRLF', - 'Email addresses must be in <> brackets', - 'Parameters are space-separated' - ]] - ]); - - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 OK\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO')) { - socket.write('250-help.example.com\r\n'); - socket.write('250-HELP\r\n'); - socket.write('250 OK\r\n'); - } else if (command === 'HELP' || command === 'HELP HELP') { - socket.write('214-This server provides HELP for the following topics:\r\n'); - socket.write('214-COMMANDS - List of available commands\r\n'); - socket.write('214-EXTENSIONS - List of supported extensions\r\n'); - socket.write('214-SYNTAX - Command syntax rules\r\n'); - socket.write('214 Use HELP for specific information\r\n'); - } else if (command.startsWith('HELP ')) { - const topic = command.substring(5).toLowerCase(); - const helpText = helpTopics.get(topic); - - if (helpText) { - helpText.forEach((line, index) => { - const prefix = index < helpText.length - 1 ? '214-' : '214 '; - socket.write(`${prefix}${line}\r\n`); - }); - } else { - socket.write('504 5.3.0 HELP topic not available\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 Start mail input\r\n'); - state = 'data'; - } else if (command === 'QUIT') { - socket.write('221 Bye\r\n'); - socket.end(); - } - } - }); - } - }); - - // Test HELP command - const net = await import('net'); - const client = net.createConnection(testServer.port, testServer.hostname); - - const commands = [ - 'EHLO client.example.com', - 'HELP', // General help - 'HELP COMMANDS', // Specific topic - 'HELP EXTENSIONS', // Another topic - 'HELP NONEXISTENT', // Unknown topic - 'QUIT' - ]; - - let commandIndex = 0; - - client.on('data', (data) => { - const response = data.toString().trim(); - console.log(` [Client] Response: ${response}`); - - if (commandIndex < commands.length) { - setTimeout(() => { - const command = commands[commandIndex]; - console.log(` [Client] Sending: ${command}`); - client.write(command + '\r\n'); - commandIndex++; - }, 200); - } else { - client.end(); - } - }); - - await new Promise((resolve, reject) => { - client.on('end', () => { - console.log(' HELP extension testing completed'); - resolve(void 0); - }); - client.on('error', reject); - }); - - await testServer.server.close(); - })(); - - // Scenario 6: Extension combination and interaction - await (async () => { - scenarioCount++; - console.log(`\nScenario ${scenarioCount}: Testing extension combinations`); - - const testServer = await createTestServer({ - onConnection: async (socket) => { - console.log(' [Server] Client connected'); - socket.write('220 combined.example.com ESMTP\r\n'); - - let activeExtensions: string[] = []; - let state = 'ready'; - let buffer = ''; - - socket.on('data', (data) => { - buffer += data.toString(); - let lines = buffer.split('\r\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (state === 'data') { - if (line === '.') { - socket.write('250 2.0.0 Message accepted\r\n'); - state = 'ready'; - } - continue; - } - - const command = line.trim(); - if (!command) continue; - - console.log(` [Server] Received: ${command}`); - - if (command.startsWith('EHLO')) { - socket.write('250-combined.example.com\r\n'); - - // Announce multiple extensions - const extensions = [ - 'SIZE 52428800', - '8BITMIME', - 'SMTPUTF8', - 'ENHANCEDSTATUSCODES', - 'PIPELINING', - 'DSN', - 'DELIVERBY 86400', - 'CHUNKING', - 'BINARYMIME', - 'HELP' - ]; - - extensions.forEach(ext => { - socket.write(`250-${ext}\r\n`); - activeExtensions.push(ext.split(' ')[0]); - }); - - socket.write('250 OK\r\n'); - console.log(` [Server] Active extensions: ${activeExtensions.join(', ')}`); - } else if (command.startsWith('MAIL FROM:')) { - // Check for multiple extension parameters - const params = []; - - if (command.includes('SIZE=')) { - const sizeMatch = command.match(/SIZE=(\d+)/); - if (sizeMatch) params.push(`SIZE=${sizeMatch[1]}`); - } - - if (command.includes('BODY=')) { - const bodyMatch = command.match(/BODY=(\w+)/); - if (bodyMatch) params.push(`BODY=${bodyMatch[1]}`); - } - - if (command.includes('SMTPUTF8')) { - params.push('SMTPUTF8'); - } - - if (command.includes('DELIVERBY=')) { - const deliverByMatch = command.match(/DELIVERBY=(\d+)/); - if (deliverByMatch) params.push(`DELIVERBY=${deliverByMatch[1]}`); - } - - if (params.length > 0) { - console.log(` [Server] Extension parameters: ${params.join(', ')}`); - } - - socket.write('250 2.1.0 Sender OK\r\n'); - } else if (command.startsWith('RCPT TO:')) { - // Check for DSN parameters - if (command.includes('NOTIFY=')) { - const notifyMatch = command.match(/NOTIFY=([^,\s]+)/); - if (notifyMatch) { - console.log(` [Server] DSN NOTIFY: ${notifyMatch[1]}`); - } - } - - socket.write('250 2.1.5 Recipient OK\r\n'); - } else if (command === 'DATA') { - // Accept DATA as fallback even when CHUNKING is advertised - // Most clients don't support BDAT - socket.write('354 Start mail input\r\n'); - state = 'data'; - } else if (command.startsWith('BDAT ')) { - if (activeExtensions.includes('CHUNKING')) { - const parts = command.split(' '); - const size = parts[1]; - const isLast = parts.includes('LAST'); - console.log(` [Server] BDAT chunk: ${size} bytes${isLast ? ' (LAST)' : ''}`); - - if (isLast) { - socket.write('250 2.0.0 Message accepted\r\n'); - } else { - socket.write('250 2.0.0 Chunk accepted\r\n'); - } - } else { - socket.write('500 5.5.1 CHUNKING not available\r\n'); - } - } else if (command === 'QUIT') { - socket.write('221 2.0.0 Bye\r\n'); - socket.end(); - } - } - }); - } - }); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test email that could use multiple extensions - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Extension combination test with UTF-8: 测试', - text: 'Testing multiple SMTP extensions together', - dsn: { - notify: ['SUCCESS', 'FAILURE'], - envid: 'multi-ext-test-123' - } - }); - - const result = await smtpClient.sendMail(email); - console.log(' Multiple extension combination handled'); - expect(result).toBeDefined(); - expect(result.success).toBeTruthy(); - - await testServer.server.close(); - })(); - - console.log(`\n${testId}: All ${scenarioCount} SMTP extension scenarios tested ✓`); -}); - -tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_security/test.csec-01.tls-verification.ts b/test/suite/smtpclient_security/test.csec-01.tls-verification.ts deleted file mode 100644 index 2786bb8..0000000 --- a/test/suite/smtpclient_security/test.csec-01.tls-verification.ts +++ /dev/null @@ -1,88 +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('CSEC-01: TLS Security Tests', async () => { - console.log('\n🔒 Testing SMTP Client TLS Security'); - console.log('=' .repeat(60)); - - // Test 1: Basic secure connection - console.log('\nTest 1: Basic secure connection'); - const testServer1 = await createTestServer({}); - - try { - const smtpClient = createTestSmtpClient({ - host: testServer1.hostname, - port: testServer1.port, - secure: false // Using STARTTLS instead of direct TLS - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'TLS Test', - text: 'Testing secure connection' - }); - - const result = await smtpClient.sendMail(email); - console.log(' ✓ Email sent over secure connection'); - expect(result).toBeDefined(); - - } finally { - testServer1.server.close(); - } - - // Test 2: Connection with security options - console.log('\nTest 2: Connection with TLS options'); - const testServer2 = await createTestServer({}); - - try { - const smtpClient = createTestSmtpClient({ - host: testServer2.hostname, - port: testServer2.port, - secure: false, - tls: { - rejectUnauthorized: false // Accept self-signed for testing - } - }); - - const verified = await smtpClient.verify(); - console.log(' ✓ TLS connection established with custom options'); - expect(verified).toBeDefined(); - - } finally { - testServer2.server.close(); - } - - // Test 3: Multiple secure emails - console.log('\nTest 3: Multiple secure emails'); - const testServer3 = await createTestServer({}); - - try { - const smtpClient = createTestSmtpClient({ - host: testServer3.hostname, - port: testServer3.port - }); - - for (let i = 0; i < 3; i++) { - const email = new Email({ - from: 'sender@secure.com', - to: [`recipient${i}@secure.com`], - subject: `Secure Email ${i + 1}`, - text: 'Testing TLS security' - }); - - const result = await smtpClient.sendMail(email); - console.log(` ✓ Secure email ${i + 1} sent`); - expect(result).toBeDefined(); - } - - } finally { - testServer3.server.close(); - } - - console.log('\n✅ CSEC-01: TLS security tests completed'); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_security/test.csec-02.oauth2-authentication.ts b/test/suite/smtpclient_security/test.csec-02.oauth2-authentication.ts deleted file mode 100644 index da2707d..0000000 --- a/test/suite/smtpclient_security/test.csec-02.oauth2-authentication.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -import { createTestSmtpClient } from '../../helpers/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: 2562, - tlsEnabled: false, - authRequired: true - }); - expect(testServer).toBeTruthy(); - expect(testServer.port).toBeGreaterThan(0); -}); - -tap.test('CSEC-02: OAuth2 authentication configuration', async () => { - // Test client with OAuth2 configuration - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - auth: { - oauth2: { - user: 'oauth.user@example.com', - clientId: 'client-id-12345', - clientSecret: 'client-secret-67890', - accessToken: 'access-token-abcdef', - refreshToken: 'refresh-token-ghijkl' - } - }, - connectionTimeout: 5000, - debug: true - }); - - // Test that OAuth2 config doesn't break the client - try { - const verified = await smtpClient.verify(); - console.log('Client with OAuth2 config created successfully'); - console.log('Note: Server does not support OAuth2, so auth will fail'); - expect(verified).toBeFalsy(); // Expected to fail without OAuth2 support - } catch (error) { - console.log('OAuth2 authentication attempt:', error.message); - } - - await smtpClient.close(); -}); - -tap.test('CSEC-02: OAuth2 vs regular auth', async () => { - // Test regular auth (should work) - const regularClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - auth: { - user: 'testuser', - pass: 'testpass' - }, - connectionTimeout: 5000, - debug: false - }); - - try { - const verified = await regularClient.verify(); - console.log('Regular auth verification:', verified); - - if (verified) { - // Send test email - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Test with regular auth', - text: 'This uses regular PLAIN/LOGIN auth' - }); - - const result = await regularClient.sendMail(email); - expect(result.success).toBeTruthy(); - console.log('Email sent with regular auth'); - } - } catch (error) { - console.log('Regular auth error:', error.message); - } - - await regularClient.close(); -}); - -tap.test('CSEC-02: OAuth2 error handling', async () => { - // Test OAuth2 with invalid token - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - auth: { - method: 'OAUTH2', - oauth2: { - user: 'user@example.com', - clientId: 'test-client', - clientSecret: 'test-secret', - refreshToken: 'refresh-token', - accessToken: 'invalid-token' - } - }, - connectionTimeout: 5000, - debug: false - }); - - try { - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'OAuth2 test', - text: 'Testing OAuth2 authentication' - }); - - const result = await smtpClient.sendMail(email); - console.log('OAuth2 send result:', result.success); - } catch (error) { - console.log('OAuth2 error (expected):', error.message); - expect(error.message).toInclude('auth'); - } - - await smtpClient.close(); -}); - -tap.test('cleanup test SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } -}); - -tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_security/test.csec-03.dkim-signing.ts b/test/suite/smtpclient_security/test.csec-03.dkim-signing.ts deleted file mode 100644 index 9044fe2..0000000 --- a/test/suite/smtpclient_security/test.csec-03.dkim-signing.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -import { createTestSmtpClient } from '../../helpers/smtp.client.js'; -import { Email } from '../../../ts/mail/core/classes.email.js'; -import * as crypto from 'crypto'; - -let testServer: ITestServer; - -tap.test('setup test SMTP server', async () => { - testServer = await startTestServer({ - port: 2563, - tlsEnabled: false, - authRequired: false - }); - expect(testServer).toBeTruthy(); - expect(testServer.port).toBeGreaterThan(0); -}); - -tap.test('CSEC-03: Basic DKIM signature structure', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - connectionTimeout: 5000, - debug: true - }); - - // Create email with DKIM configuration - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'DKIM Signed Email', - text: 'This email should be DKIM signed' - }); - - // Note: DKIM signing would be handled by the Email class or SMTP client - // This test verifies the structure when it's implemented - const result = await smtpClient.sendMail(email); - expect(result.success).toBeTruthy(); - - console.log('Email sent successfully'); - console.log('Note: DKIM signing functionality would be applied here'); - - await smtpClient.close(); -}); - -tap.test('CSEC-03: DKIM with RSA key generation', async () => { - // Generate a test RSA key pair - const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { - modulusLength: 2048, - publicKeyEncoding: { - type: 'spki', - format: 'pem' - }, - privateKeyEncoding: { - type: 'pkcs8', - format: 'pem' - } - }); - - console.log('Generated RSA key pair for DKIM:'); - console.log('Public key (first line):', publicKey.split('\n')[1].substring(0, 50) + '...'); - - // Create DNS TXT record format - const publicKeyBase64 = publicKey - .replace(/-----BEGIN PUBLIC KEY-----/, '') - .replace(/-----END PUBLIC KEY-----/, '') - .replace(/\s/g, ''); - - console.log('\nDNS TXT record for default._domainkey.example.com:'); - console.log(`v=DKIM1; k=rsa; p=${publicKeyBase64.substring(0, 50)}...`); - - const smtpClient = createTestSmtpClient({ - 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: 'DKIM with Real RSA Key', - text: 'This email is signed with a real RSA key' - }); - - const result = await smtpClient.sendMail(email); - expect(result.success).toBeTruthy(); - - await smtpClient.close(); -}); - -tap.test('CSEC-03: DKIM body hash calculation', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - connectionTimeout: 5000, - debug: false - }); - - // Test body hash with different content - const testBodies = [ - { name: 'Simple text', body: 'Hello World' }, - { name: 'Multi-line text', body: 'Line 1\r\nLine 2\r\nLine 3' }, - { name: 'Empty body', body: '' } - ]; - - for (const test of testBodies) { - console.log(`\nTesting body hash for: ${test.name}`); - - // Calculate expected body hash - const canonicalBody = test.body.replace(/\r\n/g, '\n').trimEnd() + '\n'; - const bodyHash = crypto.createHash('sha256').update(canonicalBody).digest('base64'); - console.log(` Expected hash: ${bodyHash.substring(0, 20)}...`); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: `Body Hash Test: ${test.name}`, - text: test.body - }); - - const result = await smtpClient.sendMail(email); - expect(result.success).toBeTruthy(); - } - - await smtpClient.close(); -}); - -tap.test('cleanup test SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } -}); - -tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_security/test.csec-04.spf-compliance.ts b/test/suite/smtpclient_security/test.csec-04.spf-compliance.ts deleted file mode 100644 index 398cbb9..0000000 --- a/test/suite/smtpclient_security/test.csec-04.spf-compliance.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -import { createTestSmtpClient } from '../../helpers/smtp.client.js'; -import { Email } from '../../../ts/mail/core/classes.email.js'; -import * as dns from 'dns'; -import { promisify } from 'util'; - -const resolveTxt = promisify(dns.resolveTxt); - -let testServer: ITestServer; - -tap.test('setup test SMTP server', async () => { - testServer = await startTestServer({ - port: 2564, - tlsEnabled: false, - authRequired: false - }); - expect(testServer).toBeTruthy(); - expect(testServer.port).toBeGreaterThan(0); -}); - -tap.test('CSEC-04: SPF record parsing', async () => { - // Test SPF record parsing - const testSpfRecords = [ - { - domain: 'example.com', - record: 'v=spf1 ip4:192.168.1.0/24 ip6:2001:db8::/32 include:_spf.google.com ~all', - description: 'Standard SPF with IP ranges and include' - }, - { - domain: 'strict.com', - record: 'v=spf1 mx a -all', - description: 'Strict SPF with MX and A records' - }, - { - domain: 'softfail.com', - record: 'v=spf1 ip4:10.0.0.1 ~all', - description: 'Soft fail SPF' - } - ]; - - console.log('SPF Record Analysis:\n'); - - for (const test of testSpfRecords) { - console.log(`Domain: ${test.domain}`); - console.log(`Record: ${test.record}`); - console.log(`Description: ${test.description}`); - - // Parse SPF mechanisms - const mechanisms = test.record.match(/(\+|-|~|\?)?(\w+)(:[^\s]+)?/g); - if (mechanisms) { - console.log('Mechanisms found:', mechanisms.length); - } - console.log(''); - } -}); - -tap.test('CSEC-04: SPF alignment check', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - connectionTimeout: 5000, - debug: true - }); - - // Test SPF alignment scenarios - const alignmentTests = [ - { - name: 'Aligned', - from: 'sender@example.com', - expectedAlignment: true - }, - { - name: 'Different domain', - from: 'sender@otherdomain.com', - expectedAlignment: false - } - ]; - - for (const test of alignmentTests) { - console.log(`\nTesting SPF alignment: ${test.name}`); - console.log(` From: ${test.from}`); - - const email = new Email({ - from: test.from, - to: ['recipient@example.com'], - subject: `SPF Alignment Test: ${test.name}`, - text: 'Testing SPF alignment' - }); - - const result = await smtpClient.sendMail(email); - expect(result.success).toBeTruthy(); - - console.log(` Email sent successfully`); - } - - await smtpClient.close(); -}); - -tap.test('CSEC-04: SPF lookup simulation', async () => { - // Simulate SPF record lookups - const testDomains = ['gmail.com']; - - console.log('\nSPF Record Lookups:\n'); - - for (const domain of testDomains) { - console.log(`Domain: ${domain}`); - - try { - const txtRecords = await resolveTxt(domain); - const spfRecords = txtRecords - .map(record => record.join('')) - .filter(record => record.startsWith('v=spf1')); - - if (spfRecords.length > 0) { - console.log(`SPF Record found: ${spfRecords[0].substring(0, 50)}...`); - - // Count mechanisms - const includes = (spfRecords[0].match(/include:/g) || []).length; - console.log(` Include count: ${includes}`); - } else { - console.log(' No SPF record found'); - } - } catch (error) { - console.log(` Lookup failed: ${error.message}`); - } - console.log(''); - } -}); - -tap.test('CSEC-04: SPF best practices', async () => { - // Test SPF best practices - const bestPractices = [ - { - practice: 'Use -all instead of ~all', - good: 'v=spf1 include:_spf.example.com -all', - bad: 'v=spf1 include:_spf.example.com ~all' - }, - { - practice: 'Avoid +all', - good: 'v=spf1 ip4:192.168.1.0/24 -all', - bad: 'v=spf1 +all' - } - ]; - - console.log('\nSPF Best Practices:\n'); - - for (const bp of bestPractices) { - console.log(`${bp.practice}:`); - console.log(` ✓ Good: ${bp.good}`); - console.log(` ✗ Bad: ${bp.bad}`); - console.log(''); - } -}); - -tap.test('cleanup test SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } -}); - -tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_security/test.csec-05.dmarc-policy.ts b/test/suite/smtpclient_security/test.csec-05.dmarc-policy.ts deleted file mode 100644 index a750f07..0000000 --- a/test/suite/smtpclient_security/test.csec-05.dmarc-policy.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -import { createTestSmtpClient } from '../../helpers/smtp.client.js'; -import { Email } from '../../../ts/mail/core/classes.email.js'; -import * as dns from 'dns'; -import { promisify } from 'util'; - -const resolveTxt = promisify(dns.resolveTxt); - -let testServer: ITestServer; - -tap.test('setup test SMTP server', async () => { - testServer = await startTestServer({ - port: 2565, - tlsEnabled: false, - authRequired: false - }); - expect(testServer).toBeTruthy(); - expect(testServer.port).toBeGreaterThan(0); -}); - -tap.test('CSEC-05: DMARC record parsing', async () => { - // Test DMARC record parsing - const testDmarcRecords = [ - { - domain: 'example.com', - record: 'v=DMARC1; p=reject; rua=mailto:dmarc@example.com; ruf=mailto:forensics@example.com; adkim=s; aspf=s; pct=100', - description: 'Strict DMARC with reporting' - }, - { - domain: 'relaxed.com', - record: 'v=DMARC1; p=quarantine; adkim=r; aspf=r; pct=50', - description: 'Relaxed alignment, 50% quarantine' - }, - { - domain: 'monitoring.com', - record: 'v=DMARC1; p=none; rua=mailto:reports@monitoring.com', - description: 'Monitor only mode' - } - ]; - - console.log('DMARC Record Analysis:\n'); - - for (const test of testDmarcRecords) { - console.log(`Domain: _dmarc.${test.domain}`); - console.log(`Record: ${test.record}`); - console.log(`Description: ${test.description}`); - - // Parse DMARC tags - const tags = test.record.match(/(\w+)=([^;]+)/g); - if (tags) { - console.log(`Tags found: ${tags.length}`); - } - console.log(''); - } -}); - -tap.test('CSEC-05: DMARC alignment testing', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - connectionTimeout: 5000, - debug: true - }); - - // Test DMARC alignment scenarios - const alignmentTests = [ - { - name: 'Fully aligned', - fromHeader: 'sender@example.com', - expectedResult: 'pass' - }, - { - name: 'Different domain', - fromHeader: 'sender@otherdomain.com', - expectedResult: 'fail' - } - ]; - - for (const test of alignmentTests) { - console.log(`\nTesting DMARC alignment: ${test.name}`); - console.log(` From header: ${test.fromHeader}`); - - const email = new Email({ - from: test.fromHeader, - to: ['recipient@example.com'], - subject: `DMARC Test: ${test.name}`, - text: 'Testing DMARC alignment' - }); - - const result = await smtpClient.sendMail(email); - expect(result.success).toBeTruthy(); - - console.log(` Email sent successfully`); - console.log(` Expected result: ${test.expectedResult}`); - } - - await smtpClient.close(); -}); - -tap.test('CSEC-05: DMARC policy enforcement', async () => { - // Test different DMARC policies - const policies = [ - { - policy: 'none', - description: 'Monitor only - no action taken', - action: 'Deliver normally, send reports' - }, - { - policy: 'quarantine', - description: 'Quarantine failing messages', - action: 'Move to spam/junk folder' - }, - { - policy: 'reject', - description: 'Reject failing messages', - action: 'Bounce the message' - } - ]; - - console.log('\nDMARC Policy Actions:\n'); - - for (const p of policies) { - console.log(`Policy: p=${p.policy}`); - console.log(` Description: ${p.description}`); - console.log(` Action: ${p.action}`); - console.log(''); - } -}); - -tap.test('CSEC-05: DMARC deployment best practices', async () => { - // DMARC deployment phases - const deploymentPhases = [ - { - phase: 1, - policy: 'p=none; rua=mailto:dmarc@example.com', - description: 'Monitor only - collect data' - }, - { - phase: 2, - policy: 'p=quarantine; pct=10; rua=mailto:dmarc@example.com', - description: 'Quarantine 10% of failing messages' - }, - { - phase: 3, - policy: 'p=reject; rua=mailto:dmarc@example.com', - description: 'Reject all failing messages' - } - ]; - - console.log('\nDMARC Deployment Best Practices:\n'); - - for (const phase of deploymentPhases) { - console.log(`Phase ${phase.phase}: ${phase.description}`); - console.log(` Record: v=DMARC1; ${phase.policy}`); - console.log(''); - } -}); - -tap.test('CSEC-05: DMARC record lookup', async () => { - // Test real DMARC record lookups - const testDomains = ['paypal.com']; - - console.log('\nReal DMARC Record Lookups:\n'); - - for (const domain of testDomains) { - const dmarcDomain = `_dmarc.${domain}`; - console.log(`Domain: ${domain}`); - - try { - const txtRecords = await resolveTxt(dmarcDomain); - const dmarcRecords = txtRecords - .map(record => record.join('')) - .filter(record => record.startsWith('v=DMARC1')); - - if (dmarcRecords.length > 0) { - const record = dmarcRecords[0]; - console.log(` Record found: ${record.substring(0, 50)}...`); - - // Parse key elements - const policyMatch = record.match(/p=(\w+)/); - if (policyMatch) console.log(` Policy: ${policyMatch[1]}`); - } else { - console.log(' No DMARC record found'); - } - } catch (error) { - console.log(` Lookup failed: ${error.message}`); - } - console.log(''); - } -}); - -tap.test('cleanup test SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } -}); - -tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_security/test.csec-06.certificate-validation.ts b/test/suite/smtpclient_security/test.csec-06.certificate-validation.ts deleted file mode 100644 index 2dfd454..0000000 --- a/test/suite/smtpclient_security/test.csec-06.certificate-validation.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer, createTestServer as createSimpleTestServer } from '../../helpers/server.loader.js'; -import { createTestSmtpClient } from '../../helpers/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: 2566, - tlsEnabled: true, - authRequired: false - }); - expect(testServer).toBeTruthy(); - expect(testServer.port).toBeGreaterThan(0); -}); - -tap.test('CSEC-06: Valid certificate acceptance', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, // Use STARTTLS instead of direct TLS - tls: { - rejectUnauthorized: false // Accept self-signed for test - } - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Valid certificate test', - text: 'Testing with valid TLS connection' - }); - - const result = await smtpClient.sendMail(email); - console.log(`Result: ${result.success ? 'Success' : 'Failed'}`); - console.log('Certificate accepted for secure connection'); - expect(result.success).toBeTruthy(); - - await smtpClient.close(); -}); - -tap.test('CSEC-06: Self-signed certificate handling', async () => { - // Test with strict validation (should fail) - const strictClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, // Use STARTTLS - tls: { - rejectUnauthorized: true // Reject self-signed - } - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Self-signed cert test', - text: 'Testing self-signed certificate rejection' - }); - - try { - await strictClient.sendMail(email); - console.log('Unexpected: Self-signed cert was accepted'); - } catch (error) { - console.log(`Expected error: ${error.message}`); - expect(error.message).toInclude('self'); - } - - await strictClient.close(); - - // Test with relaxed validation (should succeed) - const relaxedClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, // Use STARTTLS - tls: { - rejectUnauthorized: false // Accept self-signed - } - }); - - const result = await relaxedClient.sendMail(email); - console.log('Self-signed cert accepted with relaxed validation'); - expect(result.success).toBeTruthy(); - - await relaxedClient.close(); -}); - -tap.test('CSEC-06: Certificate hostname verification', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, // Use STARTTLS - tls: { - rejectUnauthorized: false, // For self-signed - servername: testServer.hostname // Verify hostname - } - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Hostname verification test', - text: 'Testing certificate hostname matching' - }); - - const result = await smtpClient.sendMail(email); - console.log('Hostname verification completed'); - expect(result.success).toBeTruthy(); - - await smtpClient.close(); -}); - -tap.test('CSEC-06: Certificate validation with custom CA', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, // Use STARTTLS - tls: { - rejectUnauthorized: false, - // In production, would specify CA certificates - ca: undefined - } - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Certificate chain test', - text: 'Testing certificate chain validation' - }); - - const result = await smtpClient.sendMail(email); - console.log('Certificate chain validation completed'); - expect(result.success).toBeTruthy(); - - await smtpClient.close(); -}); - -tap.test('cleanup test SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } -}); - -tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_security/test.csec-07.cipher-suites.ts b/test/suite/smtpclient_security/test.csec-07.cipher-suites.ts deleted file mode 100644 index 317ef83..0000000 --- a/test/suite/smtpclient_security/test.csec-07.cipher-suites.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -import { createTestSmtpClient } from '../../helpers/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: 2567, - tlsEnabled: true, - authRequired: false - }); - expect(testServer).toBeTruthy(); - expect(testServer.port).toBeGreaterThan(0); -}); - -tap.test('CSEC-07: Strong cipher suite negotiation', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, // Use STARTTLS - tls: { - rejectUnauthorized: false, - // Prefer strong ciphers - ciphers: 'HIGH:!aNULL:!MD5:!3DES', - minVersion: 'TLSv1.2' - } - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Strong cipher test', - text: 'Testing with strong cipher suites' - }); - - try { - const result = await smtpClient.sendMail(email); - console.log('Successfully negotiated strong cipher'); - expect(result.success).toBeTruthy(); - } catch (error) { - // Cipher negotiation may fail with self-signed test certs - console.log(`Strong cipher negotiation not supported: ${error.message}`); - } - - await smtpClient.close(); -}); - -tap.test('CSEC-07: Cipher suite configuration', async () => { - // Test with specific cipher configuration - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, // Use STARTTLS - tls: { - rejectUnauthorized: false, - // Specify allowed ciphers - ciphers: 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256', - honorCipherOrder: true - } - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Cipher configuration test', - text: 'Testing specific cipher suite configuration' - }); - - const result = await smtpClient.sendMail(email); - console.log('Cipher configuration test completed'); - expect(result.success).toBeTruthy(); - - await smtpClient.close(); -}); - -tap.test('CSEC-07: Perfect Forward Secrecy ciphers', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, // Use STARTTLS - tls: { - rejectUnauthorized: false, - // Prefer PFS ciphers - ciphers: 'ECDHE:DHE:!aNULL:!MD5', - ecdhCurve: 'auto' - } - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'PFS cipher test', - text: 'Testing Perfect Forward Secrecy' - }); - - try { - const result = await smtpClient.sendMail(email); - console.log('Successfully used PFS cipher'); - expect(result.success).toBeTruthy(); - } catch (error) { - // PFS cipher negotiation may fail with self-signed test certs - console.log(`PFS cipher negotiation not supported: ${error.message}`); - } - - await smtpClient.close(); -}); - -tap.test('CSEC-07: Cipher compatibility testing', async () => { - const cipherConfigs = [ - { - name: 'TLS 1.2 compatible', - ciphers: 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256', - minVersion: 'TLSv1.2' - }, - { - name: 'Broad compatibility', - ciphers: 'HIGH:MEDIUM:!aNULL:!MD5:!3DES', - minVersion: 'TLSv1.2' - } - ]; - - for (const config of cipherConfigs) { - console.log(`\nTesting ${config.name}...`); - - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, // Use STARTTLS - tls: { - rejectUnauthorized: false, - ciphers: config.ciphers, - minVersion: config.minVersion as any - } - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: `${config.name} test`, - text: `Testing ${config.name} cipher configuration` - }); - - try { - const result = await smtpClient.sendMail(email); - console.log(` Success with ${config.name}`); - expect(result.success).toBeTruthy(); - } catch (error) { - console.log(` ${config.name} not supported in this environment`); - } - - await smtpClient.close(); - } -}); - -tap.test('cleanup test SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } -}); - -tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_security/test.csec-08.authentication-fallback.ts b/test/suite/smtpclient_security/test.csec-08.authentication-fallback.ts deleted file mode 100644 index d48eaae..0000000 --- a/test/suite/smtpclient_security/test.csec-08.authentication-fallback.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -import { createTestSmtpClient } from '../../helpers/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: 2568, - tlsEnabled: false, - authRequired: true - }); - expect(testServer).toBeTruthy(); - expect(testServer.port).toBeGreaterThan(0); -}); - -tap.test('CSEC-08: Multiple authentication methods', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - auth: { - user: 'testuser', - pass: 'testpass' - } - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Multi-auth test', - text: 'Testing multiple authentication methods' - }); - - const result = await smtpClient.sendMail(email); - console.log('Authentication successful'); - expect(result.success).toBeTruthy(); - - await smtpClient.close(); -}); - -tap.test('CSEC-08: OAuth2 fallback to password auth', async () => { - // Test with OAuth2 token (will fail and fallback) - const oauthClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - auth: { - oauth2: { - user: 'user@example.com', - clientId: 'test-client', - clientSecret: 'test-secret', - refreshToken: 'refresh-token', - accessToken: 'invalid-token' - } - } - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'OAuth2 fallback test', - text: 'Testing OAuth2 authentication fallback' - }); - - try { - await oauthClient.sendMail(email); - console.log('OAuth2 authentication attempted'); - } catch (error) { - console.log(`OAuth2 failed as expected: ${error.message}`); - } - - await oauthClient.close(); - - // Test fallback to password auth - const fallbackClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - auth: { - user: 'testuser', - pass: 'testpass' - } - }); - - const result = await fallbackClient.sendMail(email); - console.log('Fallback authentication successful'); - expect(result.success).toBeTruthy(); - - await fallbackClient.close(); -}); - -tap.test('CSEC-08: Auth method preference', async () => { - // Test with specific auth method preference - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - auth: { - user: 'testuser', - pass: 'testpass', - method: 'PLAIN' // Prefer PLAIN auth - } - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Auth preference test', - text: 'Testing authentication method preference' - }); - - const result = await smtpClient.sendMail(email); - console.log('Authentication with preferred method successful'); - expect(result.success).toBeTruthy(); - - await smtpClient.close(); -}); - -tap.test('CSEC-08: Secure auth requirements', async () => { - // Test authentication behavior with security requirements - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - auth: { - user: 'testuser', - pass: 'testpass' - }, - requireTLS: false // Allow auth over plain connection for test - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Secure auth test', - text: 'Testing secure authentication requirements' - }); - - const result = await smtpClient.sendMail(email); - console.log('Authentication completed'); - expect(result.success).toBeTruthy(); - - await smtpClient.close(); -}); - -tap.test('cleanup test SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } -}); - -tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_security/test.csec-09.relay-restrictions.ts b/test/suite/smtpclient_security/test.csec-09.relay-restrictions.ts deleted file mode 100644 index 441e5db..0000000 --- a/test/suite/smtpclient_security/test.csec-09.relay-restrictions.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -import { createTestSmtpClient } from '../../helpers/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: 2569, - tlsEnabled: false, - authRequired: false - }); - expect(testServer).toBeTruthy(); - expect(testServer.port).toBeGreaterThan(0); -}); - -tap.test('CSEC-09: Open relay prevention', async () => { - // Test unauthenticated relay attempt (should succeed for test server) - const unauthClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - const relayEmail = new Email({ - from: 'external@untrusted.com', - to: ['recipient@another-external.com'], - subject: 'Relay test', - text: 'Testing open relay prevention' - }); - - const result = await unauthClient.sendMail(relayEmail); - console.log('Test server allows relay for testing purposes'); - expect(result.success).toBeTruthy(); - - await unauthClient.close(); -}); - -tap.test('CSEC-09: Authenticated relay', async () => { - // Test authenticated relay (should succeed) - // Note: Test server may not advertise AUTH, so try with and without - const authClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false, - auth: { - user: 'testuser', - pass: 'testpass' - } - }); - - const relayEmail = new Email({ - from: 'sender@example.com', - to: ['recipient@external.com'], - subject: 'Authenticated relay test', - text: 'Testing authenticated relay' - }); - - try { - const result = await authClient.sendMail(relayEmail); - if (result.success) { - console.log('Authenticated relay allowed'); - } else { - // Auth may not be advertised by test server, try without auth - console.log('Auth not available, testing relay without authentication'); - const noAuthClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - const noAuthResult = await noAuthClient.sendMail(relayEmail); - console.log('Relay without auth:', noAuthResult.success ? 'allowed' : 'rejected'); - expect(noAuthResult.success).toBeTruthy(); - await noAuthClient.close(); - } - } catch (error) { - console.log(`Auth test error: ${error.message}`); - // Try without auth as fallback - const noAuthClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - const noAuthResult = await noAuthClient.sendMail(relayEmail); - console.log('Relay without auth:', noAuthResult.success ? 'allowed' : 'rejected'); - expect(noAuthResult.success).toBeTruthy(); - await noAuthClient.close(); - } - - await authClient.close(); -}); - -tap.test('CSEC-09: Recipient count limits', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test with multiple recipients - const manyRecipients = Array(10).fill(null).map((_, i) => `recipient${i + 1}@example.com`); - - const bulkEmail = new Email({ - from: 'sender@example.com', - to: manyRecipients, - subject: 'Recipient limit test', - text: 'Testing recipient count limits' - }); - - const result = await smtpClient.sendMail(bulkEmail); - console.log(`Sent to ${result.acceptedRecipients.length} recipients`); - expect(result.success).toBeTruthy(); - - // Check if any recipients were rejected - if (result.rejectedRecipients.length > 0) { - console.log(`${result.rejectedRecipients.length} recipients rejected`); - } - - await smtpClient.close(); -}); - -tap.test('CSEC-09: Sender domain verification', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test with various sender domains - const senderTests = [ - { from: 'sender@example.com', expected: true }, - { from: 'sender@trusted.com', expected: true }, - { from: 'sender@untrusted.com', expected: true } // Test server accepts all - ]; - - for (const test of senderTests) { - const email = new Email({ - from: test.from, - to: ['recipient@example.com'], - subject: `Sender test from ${test.from}`, - text: 'Testing sender domain restrictions' - }); - - const result = await smtpClient.sendMail(email); - console.log(`Sender ${test.from}: ${result.success ? 'accepted' : 'rejected'}`); - expect(result.success).toEqual(test.expected); - } - - await smtpClient.close(); -}); - -tap.test('CSEC-09: Rate limiting simulation', async () => { - // Send multiple messages to test rate limiting - const results: boolean[] = []; - - for (let i = 0; i < 5; i++) { - const client = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: `Rate test ${i + 1}`, - text: `Testing rate limits - message ${i + 1}` - }); - - try { - const result = await client.sendMail(email); - console.log(`Message ${i + 1}: Sent successfully`); - results.push(result.success); - } catch (error) { - console.log(`Message ${i + 1}: Failed`); - results.push(false); - } - - await client.close(); - } - - const successCount = results.filter(r => r).length; - console.log(`Sent ${successCount}/${results.length} messages`); - expect(successCount).toBeGreaterThan(0); -}); - -tap.test('cleanup test SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } -}); - -tap.start(); \ No newline at end of file diff --git a/test/suite/smtpclient_security/test.csec-10.anti-spam-measures.ts b/test/suite/smtpclient_security/test.csec-10.anti-spam-measures.ts deleted file mode 100644 index cdd02e5..0000000 --- a/test/suite/smtpclient_security/test.csec-10.anti-spam-measures.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -import { createTestSmtpClient } from '../../helpers/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: 2570, - tlsEnabled: false, - authRequired: false - }); - expect(testServer).toBeTruthy(); - expect(testServer.port).toBeGreaterThan(0); -}); - -tap.test('CSEC-10: Reputation-based filtering', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Reputation test', - text: 'Testing reputation-based filtering' - }); - - const result = await smtpClient.sendMail(email); - console.log('Good reputation: Message accepted'); - expect(result.success).toBeTruthy(); - - await smtpClient.close(); -}); - -tap.test('CSEC-10: Content filtering and spam scoring', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test 1: Clean email - const cleanEmail = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Business proposal', - text: 'I would like to discuss our upcoming project. Please let me know your availability.' - }); - - const cleanResult = await smtpClient.sendMail(cleanEmail); - console.log('Clean email: Accepted'); - expect(cleanResult.success).toBeTruthy(); - - // Test 2: Email with spam-like content - const spamEmail = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'You are a WINNER!', - text: 'Click here to claim your lottery prize! Act now! 100% guarantee!' - }); - - const spamResult = await smtpClient.sendMail(spamEmail); - console.log('Spam-like email: Processed by server'); - expect(spamResult.success).toBeTruthy(); - - await smtpClient.close(); -}); - -tap.test('CSEC-10: Greylisting simulation', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Greylist test', - text: 'Testing greylisting mechanism' - }); - - // Test server doesn't implement greylisting, so this should succeed - const result = await smtpClient.sendMail(email); - console.log('Email sent (greylisting not active on test server)'); - expect(result.success).toBeTruthy(); - - await smtpClient.close(); -}); - -tap.test('CSEC-10: DNS blacklist checking', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test with various domains - const testDomains = [ - { from: 'sender@clean-domain.com', expected: true }, - { from: 'sender@spam-domain.com', expected: true } // Test server accepts all - ]; - - for (const test of testDomains) { - const email = new Email({ - from: test.from, - to: ['recipient@example.com'], - subject: 'DNSBL test', - text: 'Testing DNSBL checking' - }); - - const result = await smtpClient.sendMail(email); - console.log(`Sender ${test.from}: ${result.success ? 'accepted' : 'rejected'}`); - expect(result.success).toBeTruthy(); - } - - await smtpClient.close(); -}); - -tap.test('CSEC-10: Connection behavior analysis', async () => { - // Test normal behavior - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - const email = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Behavior test', - text: 'Testing normal email sending behavior' - }); - - const result = await smtpClient.sendMail(email); - console.log('Normal behavior: Accepted'); - expect(result.success).toBeTruthy(); - - await smtpClient.close(); -}); - -tap.test('CSEC-10: Attachment scanning', async () => { - const smtpClient = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - secure: false - }); - - // Test 1: Safe attachment - const safeEmail = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Document for review', - text: 'Please find the attached document.', - attachments: [{ - filename: 'report.pdf', - content: Buffer.from('PDF content here'), - contentType: 'application/pdf' - }] - }); - - const safeResult = await smtpClient.sendMail(safeEmail); - console.log('Safe attachment: Accepted'); - expect(safeResult.success).toBeTruthy(); - - // Test 2: Potentially dangerous attachment (test server accepts all) - const exeEmail = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Important update', - text: 'Please run the attached file', - attachments: [{ - filename: 'update.exe', - content: Buffer.from('MZ\x90\x00\x03'), // Fake executable header - contentType: 'application/octet-stream' - }] - }); - - const exeResult = await smtpClient.sendMail(exeEmail); - console.log('Executable attachment: Processed by server'); - expect(exeResult.success).toBeTruthy(); - - await smtpClient.close(); -}); - -tap.test('cleanup test SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } -}); - -tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_commands/test.cmd-01.ehlo-command.ts b/test/suite/smtpserver_commands/test.cmd-01.ehlo-command.ts deleted file mode 100644 index 6b8395b..0000000 --- a/test/suite/smtpserver_commands/test.cmd-01.ehlo-command.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; -const TEST_TIMEOUT = 10000; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('CMD-01: EHLO Command - server responds with proper capabilities', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; // Clear buffer - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - // Parse response - only lines that start with 250 - const lines = receivedData.split('\r\n') - .filter(line => line.startsWith('250')) - .filter(line => line.length > 0); - - // Check for required ESMTP extensions - const capabilities = lines.map(line => line.substring(4).trim()); - console.log('📋 Server capabilities:', capabilities); - - // Verify essential capabilities - expect(capabilities.some(cap => cap.includes('SIZE'))).toBeTruthy(); - expect(capabilities.some(cap => cap.includes('8BITMIME'))).toBeTruthy(); - - // The last line should be "250 " (without hyphen) - const lastLine = lines[lines.length - 1]; - expect(lastLine.startsWith('250 ')).toBeTruthy(); - - currentStep = 'quit'; - receivedData = ''; // Clear buffer - socket.write('QUIT\r\n'); - } else if (currentStep === 'quit' && receivedData.includes('221')) { - socket.destroy(); - done.resolve(); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('CMD-01: EHLO with invalid hostname - server handles gracefully', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let testIndex = 0; - - const invalidHostnames = [ - '', // Empty hostname - ' ', // Whitespace only - 'invalid..hostname', // Double dots - '.invalid', // Leading dot - 'invalid.', // Trailing dot - 'very-long-hostname-that-exceeds-reasonable-limits-' + 'x'.repeat(200) - ]; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'testing'; - receivedData = ''; // Clear buffer - console.log(`Testing invalid hostname: "${invalidHostnames[testIndex]}"`); - socket.write(`EHLO ${invalidHostnames[testIndex]}\r\n`); - } else if (currentStep === 'testing' && (receivedData.includes('250') || receivedData.includes('5'))) { - // Server should either accept with warning or reject with 5xx - expect(receivedData).toMatch(/^(250|5\d\d)/); - - testIndex++; - if (testIndex < invalidHostnames.length) { - currentStep = 'reset'; - receivedData = ''; // Clear buffer - socket.write('RSET\r\n'); - } else { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - } else if (currentStep === 'reset' && receivedData.includes('250')) { - currentStep = 'testing'; - receivedData = ''; // Clear buffer - console.log(`Testing invalid hostname: "${invalidHostnames[testIndex]}"`); - socket.write(`EHLO ${invalidHostnames[testIndex]}\r\n`); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('CMD-01: EHLO command pipelining - multiple EHLO commands', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'first_ehlo'; - receivedData = ''; // Clear buffer - socket.write('EHLO first.example.com\r\n'); - } else if (currentStep === 'first_ehlo' && receivedData.includes('250 ')) { - currentStep = 'second_ehlo'; - receivedData = ''; // Clear buffer - // Second EHLO (should reset session) - socket.write('EHLO second.example.com\r\n'); - } else if (currentStep === 'second_ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; // Clear buffer - // Verify session was reset by trying MAIL FROM - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_commands/test.cmd-02.mail-from.ts b/test/suite/smtpserver_commands/test.cmd-02.mail-from.ts deleted file mode 100644 index 3e59dc3..0000000 --- a/test/suite/smtpserver_commands/test.cmd-02.mail-from.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; -const TEST_TIMEOUT = 10000; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('CMD-02: MAIL FROM - accepts valid sender addresses', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let testIndex = 0; - - const validAddresses = [ - 'sender@example.com', - 'test.user+tag@example.com', - 'user@[192.168.1.1]', // IP literal - 'user@subdomain.example.com', - 'user@very-long-domain-name-that-is-still-valid.example.com', - 'test_user@example.com' // underscore in local part - ]; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - console.log(`Testing valid address: ${validAddresses[testIndex]}`); - socket.write(`MAIL FROM:<${validAddresses[testIndex]}>\r\n`); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - testIndex++; - if (testIndex < validAddresses.length) { - currentStep = 'rset'; - receivedData = ''; - socket.write('RSET\r\n'); - } else { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - } else if (currentStep === 'rset' && receivedData.includes('250')) { - currentStep = 'mail_from'; - receivedData = ''; - console.log(`Testing valid address: ${validAddresses[testIndex]}`); - socket.write(`MAIL FROM:<${validAddresses[testIndex]}>\r\n`); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('CMD-02: MAIL FROM - rejects invalid sender addresses', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let testIndex = 0; - - const invalidAddresses = [ - 'notanemail', // No @ symbol - '@example.com', // Missing local part - 'user@', // Missing domain - 'user@.com', // Invalid domain - 'user@domain..com', // Double dot - 'user with spaces@example.com', // Unquoted spaces - 'user@', // Invalid characters - 'user@@example.com', // Double @ - 'user@localhost' // localhost not valid domain - ]; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - console.log(`Testing invalid address: "${invalidAddresses[testIndex]}"`); - socket.write(`MAIL FROM:<${invalidAddresses[testIndex]}>\r\n`); - } else if (currentStep === 'mail_from' && (receivedData.includes('250') || receivedData.includes('5'))) { - // Server might accept some addresses or reject with 5xx error - // For this test, we just verify the server responds appropriately - console.log(` Response: ${receivedData.trim()}`); - - testIndex++; - if (testIndex < invalidAddresses.length) { - currentStep = 'rset'; - receivedData = ''; - socket.write('RSET\r\n'); - } else { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - } else if (currentStep === 'rset' && receivedData.includes('250')) { - currentStep = 'mail_from'; - receivedData = ''; - console.log(`Testing invalid address: "${invalidAddresses[testIndex]}"`); - socket.write(`MAIL FROM:<${invalidAddresses[testIndex]}>\r\n`); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('CMD-02: MAIL FROM with SIZE parameter', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from_small'; - receivedData = ''; - // Test small size - socket.write('MAIL FROM: SIZE=1024\r\n'); - } else if (currentStep === 'mail_from_small' && receivedData.includes('250')) { - currentStep = 'rset'; - receivedData = ''; - socket.write('RSET\r\n'); - } else if (currentStep === 'rset' && receivedData.includes('250')) { - currentStep = 'mail_from_large'; - receivedData = ''; - // Test large size (should be rejected if exceeds limit) - socket.write('MAIL FROM: SIZE=99999999\r\n'); - } else if (currentStep === 'mail_from_large') { - // Should get either 250 (accepted) or 552 (message size exceeds limit) - expect(receivedData).toMatch(/^(250|552)/); - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('CMD-02: MAIL FROM with parameters', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from_8bitmime'; - receivedData = ''; - // Test BODY=8BITMIME - socket.write('MAIL FROM: BODY=8BITMIME\r\n'); - } else if (currentStep === 'mail_from_8bitmime' && receivedData.includes('250')) { - currentStep = 'rset'; - receivedData = ''; - socket.write('RSET\r\n'); - } else if (currentStep === 'rset' && receivedData.includes('250')) { - currentStep = 'mail_from_unknown'; - receivedData = ''; - // Test unknown parameter (should be ignored or rejected) - socket.write('MAIL FROM: UNKNOWN=value\r\n'); - } else if (currentStep === 'mail_from_unknown') { - // Should get either 250 (ignored) or 555 (parameter not recognized) - expect(receivedData).toMatch(/^(250|555|501)/); - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('CMD-02: MAIL FROM sequence violations', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'mail_without_ehlo'; - receivedData = ''; - // Try MAIL FROM without EHLO/HELO first - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_without_ehlo' && receivedData.includes('503')) { - // Should get 503 (bad sequence) - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'first_mail'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'first_mail' && receivedData.includes('250')) { - currentStep = 'second_mail'; - receivedData = ''; - // Try second MAIL FROM without RSET - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'second_mail' && (receivedData.includes('503') || receivedData.includes('250'))) { - // Server might accept or reject the second MAIL FROM - // Some servers allow resetting the sender, others require RSET - console.log(`Second MAIL FROM response: ${receivedData.trim()}`); - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_commands/test.cmd-03.rcpt-to.ts b/test/suite/smtpserver_commands/test.cmd-03.rcpt-to.ts deleted file mode 100644 index 27132f1..0000000 --- a/test/suite/smtpserver_commands/test.cmd-03.rcpt-to.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; -const TEST_TIMEOUT = 10000; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('RCPT TO - should accept valid recipient after MAIL FROM', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - receivedData = ''; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - expect(receivedData).toInclude('250'); - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('RCPT TO - should reject without MAIL FROM', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'rcpt_to_without_mail'; - receivedData = ''; - // Try RCPT TO without MAIL FROM - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to_without_mail' && receivedData.includes('503')) { - // Should get 503 (bad sequence) - expect(receivedData).toInclude('503'); - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('RCPT TO - should accept multiple recipients', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let recipientCount = 0; - const maxRecipients = 3; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - receivedData = ''; - socket.write(`RCPT TO:\r\n`); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - recipientCount++; - receivedData = ''; - - if (recipientCount < maxRecipients) { - socket.write(`RCPT TO:\r\n`); - } else { - expect(recipientCount).toEqual(maxRecipients); - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('RCPT TO - should reject invalid email format', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let testIndex = 0; - - const invalidRecipients = [ - 'notanemail', - '@example.com', - 'user@', - 'user@.com', - 'user@domain..com' - ]; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - receivedData = ''; - console.log(`Testing invalid recipient: "${invalidRecipients[testIndex]}"`); - socket.write(`RCPT TO:<${invalidRecipients[testIndex]}>\r\n`); - } else if (currentStep === 'rcpt_to' && (receivedData.includes('501') || receivedData.includes('5'))) { - // Should reject with 5xx error - console.log(` Response: ${receivedData.trim()}`); - - testIndex++; - if (testIndex < invalidRecipients.length) { - currentStep = 'rset'; - receivedData = ''; - socket.write('RSET\r\n'); - } else { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - } else if (currentStep === 'rset' && receivedData.includes('250')) { - currentStep = 'mail_from'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('RCPT TO - should handle SIZE parameter', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to_with_size'; - receivedData = ''; - // RCPT TO doesn't typically have SIZE parameter, but test server response - socket.write('RCPT TO: SIZE=1024\r\n'); - } else if (currentStep === 'rcpt_to_with_size') { - // Server might accept or reject the parameter - expect(receivedData).toMatch(/^(250|555|501)/); - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_commands/test.cmd-04.data-command.ts b/test/suite/smtpserver_commands/test.cmd-04.data-command.ts deleted file mode 100644 index c9a3fca..0000000 --- a/test/suite/smtpserver_commands/test.cmd-04.data-command.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; -const TEST_TIMEOUT = 15000; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('DATA - should accept email data after RCPT TO', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - receivedData = ''; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data_command'; - receivedData = ''; - socket.write('DATA\r\n'); - } else if (currentStep === 'data_command' && receivedData.includes('354')) { - currentStep = 'message_body'; - receivedData = ''; - // Send email content - socket.write('From: sender@example.com\r\n'); - socket.write('To: recipient@example.com\r\n'); - socket.write('Subject: Test message\r\n'); - socket.write('\r\n'); // Empty line to separate headers from body - socket.write('This is a test message.\r\n'); - socket.write('.\r\n'); // End of message - } else if (currentStep === 'message_body' && receivedData.includes('250')) { - expect(receivedData).toInclude('250'); - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('DATA - should reject without RCPT TO', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'data_without_rcpt'; - receivedData = ''; - // Try DATA without MAIL FROM or RCPT TO - socket.write('DATA\r\n'); - } else if (currentStep === 'data_without_rcpt' && receivedData.includes('503')) { - // Should get 503 (bad sequence) - expect(receivedData).toInclude('503'); - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('DATA - should accept empty message body', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - receivedData = ''; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data_command'; - receivedData = ''; - socket.write('DATA\r\n'); - } else if (currentStep === 'data_command' && receivedData.includes('354')) { - currentStep = 'empty_message'; - receivedData = ''; - // Send only the terminator - socket.write('.\r\n'); - } else if (currentStep === 'empty_message') { - // Server should accept empty message - expect(receivedData).toMatch(/^(250|5\d\d)/); - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('DATA - should handle dot stuffing correctly', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - receivedData = ''; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data_command'; - receivedData = ''; - socket.write('DATA\r\n'); - } else if (currentStep === 'data_command' && receivedData.includes('354')) { - currentStep = 'dot_stuffed_message'; - receivedData = ''; - // Send message with dots that need stuffing - socket.write('This line is normal.\r\n'); - socket.write('..This line starts with two dots (one will be removed).\r\n'); - socket.write('.This line starts with a single dot.\r\n'); - socket.write('...This line starts with three dots.\r\n'); - socket.write('.\r\n'); // End of message - } else if (currentStep === 'dot_stuffed_message' && receivedData.includes('250')) { - expect(receivedData).toInclude('250'); - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('DATA - should handle large messages', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - receivedData = ''; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data_command'; - receivedData = ''; - socket.write('DATA\r\n'); - } else if (currentStep === 'data_command' && receivedData.includes('354')) { - currentStep = 'large_message'; - receivedData = ''; - // Send a large message (100KB) - socket.write('From: sender@example.com\r\n'); - socket.write('To: recipient@example.com\r\n'); - socket.write('Subject: Large test message\r\n'); - socket.write('\r\n'); - - // Generate 100KB of data - const lineContent = 'This is a test line that will be repeated many times. '; - const linesNeeded = Math.ceil(100000 / lineContent.length); - - for (let i = 0; i < linesNeeded; i++) { - socket.write(lineContent + '\r\n'); - } - - socket.write('.\r\n'); // End of message - } else if (currentStep === 'large_message' && receivedData.includes('250')) { - expect(receivedData).toInclude('250'); - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('DATA - should handle binary data in message', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - receivedData = ''; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data_command'; - receivedData = ''; - socket.write('DATA\r\n'); - } else if (currentStep === 'data_command' && receivedData.includes('354')) { - currentStep = 'binary_message'; - receivedData = ''; - // Send message with binary data (base64 encoded attachment) - socket.write('From: sender@example.com\r\n'); - socket.write('To: recipient@example.com\r\n'); - socket.write('Subject: Binary test message\r\n'); - socket.write('MIME-Version: 1.0\r\n'); - socket.write('Content-Type: multipart/mixed; boundary="boundary123"\r\n'); - socket.write('\r\n'); - socket.write('--boundary123\r\n'); - socket.write('Content-Type: text/plain\r\n'); - socket.write('\r\n'); - socket.write('This message contains binary data.\r\n'); - socket.write('--boundary123\r\n'); - socket.write('Content-Type: application/octet-stream\r\n'); - socket.write('Content-Transfer-Encoding: base64\r\n'); - socket.write('\r\n'); - socket.write('SGVsbG8gV29ybGQhIFRoaXMgaXMgYmluYXJ5IGRhdGEu\r\n'); - socket.write('--boundary123--\r\n'); - socket.write('.\r\n'); // End of message - } else if (currentStep === 'binary_message' && receivedData.includes('250')) { - expect(receivedData).toInclude('250'); - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_commands/test.cmd-05.noop-command.ts b/test/suite/smtpserver_commands/test.cmd-05.noop-command.ts deleted file mode 100644 index d5de611..0000000 --- a/test/suite/smtpserver_commands/test.cmd-05.noop-command.ts +++ /dev/null @@ -1,320 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; -const TEST_TIMEOUT = 10000; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -// Test: Basic NOOP command -tap.test('NOOP - should accept NOOP command', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'noop'; - socket.write('NOOP\r\n'); - } else if (currentStep === 'noop' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); // NOOP response - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Multiple NOOP commands -tap.test('NOOP - should handle multiple consecutive NOOP commands', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let noopCount = 0; - const maxNoops = 3; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; // Clear buffer after processing - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'noop'; - receivedData = ''; // Clear buffer after processing - socket.write('NOOP\r\n'); - } else if (currentStep === 'noop' && receivedData.includes('250 OK')) { - noopCount++; - receivedData = ''; // Clear buffer after processing - - if (noopCount < maxNoops) { - // Send another NOOP command - socket.write('NOOP\r\n'); - } else { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(noopCount).toEqual(maxNoops); - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: NOOP during transaction -tap.test('NOOP - should work during email transaction', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'noop_after_mail'; - socket.write('NOOP\r\n'); - } else if (currentStep === 'noop_after_mail' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'noop_after_rcpt'; - socket.write('NOOP\r\n'); - } else if (currentStep === 'noop_after_rcpt' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: NOOP with parameter (should be ignored) -tap.test('NOOP - should handle NOOP with parameters', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'noop_with_param'; - socket.write('NOOP ignored parameter\r\n'); // Parameters should be ignored - } else if (currentStep === 'noop_with_param' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: NOOP before EHLO/HELO -tap.test('NOOP - should work before EHLO', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'noop_before_ehlo'; - socket.write('NOOP\r\n'); - } else if (currentStep === 'noop_before_ehlo' && receivedData.includes('250')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Rapid NOOP commands (stress test) -tap.test('NOOP - should handle rapid NOOP commands', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let noopsSent = 0; - let noopsReceived = 0; - const rapidNoops = 10; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'rapid_noop'; - // Send multiple NOOPs rapidly - for (let i = 0; i < rapidNoops; i++) { - socket.write('NOOP\r\n'); - noopsSent++; - } - } else if (currentStep === 'rapid_noop') { - // Count 250 responses - const matches = receivedData.match(/250 /g); - if (matches) { - noopsReceived = matches.length - 1; // -1 for EHLO response - } - - if (noopsReceived >= rapidNoops) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(noopsReceived).toBeGreaterThan(rapidNoops - 1); - done.resolve(); - }, 500); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_commands/test.cmd-06.rset-command.ts b/test/suite/smtpserver_commands/test.cmd-06.rset-command.ts deleted file mode 100644 index 292e68b..0000000 --- a/test/suite/smtpserver_commands/test.cmd-06.rset-command.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -// Test configuration -const TEST_PORT = 2525; - -let testServer; -const TEST_TIMEOUT = 10000; - -// Setup -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -// Test: Basic RSET command -tap.test('RSET - should reset transaction after MAIL FROM', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rset'; - socket.write('RSET\r\n'); - } else if (currentStep === 'rset' && receivedData.includes('250')) { - // RSET successful, try to send MAIL FROM again to verify reset - currentStep = 'mail_from_after_rset'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from_after_rset' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250 OK'); // RSET response - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: RSET after RCPT TO -tap.test('RSET - should reset transaction after RCPT TO', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'rset'; - socket.write('RSET\r\n'); - } else if (currentStep === 'rset' && receivedData.includes('250')) { - // After RSET, should need MAIL FROM before RCPT TO - currentStep = 'rcpt_to_after_rset'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to_after_rset' && receivedData.includes('503')) { - // Should get 503 bad sequence - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('503'); // Bad sequence after RSET - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: RSET during DATA -tap.test('RSET - should reset transaction during DATA phase', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - // Start sending data but then RSET - currentStep = 'rset_during_data'; - socket.write('Subject: Test\r\n\r\nPartial message...\r\n'); - socket.write('RSET\r\n'); // This should be treated as part of data - socket.write('\r\n.\r\n'); // End data - } else if (currentStep === 'rset_during_data' && receivedData.includes('250')) { - // Message accepted, now send actual RSET - currentStep = 'rset_after_data'; - socket.write('RSET\r\n'); - } else if (currentStep === 'rset_after_data' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Multiple RSET commands -tap.test('RSET - should handle multiple consecutive RSET commands', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let rsetCount = 0; - const maxRsets = 3; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'multiple_rsets'; - receivedData = ''; - socket.write('RSET\r\n'); - } else if (currentStep === 'multiple_rsets' && receivedData.includes('250')) { - rsetCount++; - receivedData = ''; // Clear buffer after processing - - if (rsetCount < maxRsets) { - socket.write('RSET\r\n'); - } else { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(rsetCount).toEqual(maxRsets); - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: RSET without transaction -tap.test('RSET - should work without active transaction', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'rset_without_transaction'; - socket.write('RSET\r\n'); - } else if (currentStep === 'rset_without_transaction' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); // RSET should work even without transaction - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: RSET with multiple recipients -tap.test('RSET - should clear all recipients', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let recipientCount = 0; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'add_recipients'; - recipientCount++; - socket.write(`RCPT TO:\r\n`); - } else if (currentStep === 'add_recipients' && receivedData.includes('250')) { - if (recipientCount < 3) { - recipientCount++; - receivedData = ''; // Clear buffer - socket.write(`RCPT TO:\r\n`); - } else { - currentStep = 'rset'; - socket.write('RSET\r\n'); - } - } else if (currentStep === 'rset' && receivedData.includes('250')) { - // After RSET, all recipients should be cleared - currentStep = 'data_after_rset'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data_after_rset' && receivedData.includes('503')) { - // Should get 503 bad sequence (no recipients) - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('503'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: RSET with parameter (should be ignored) -tap.test('RSET - should ignore parameters', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'rset_with_param'; - socket.write('RSET ignored parameter\r\n'); // Parameters should be ignored - } else if (currentStep === 'rset_with_param' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_commands/test.cmd-07.vrfy-command.ts b/test/suite/smtpserver_commands/test.cmd-07.vrfy-command.ts deleted file mode 100644 index bed30bd..0000000 --- a/test/suite/smtpserver_commands/test.cmd-07.vrfy-command.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as path from 'path'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -// Test configuration -const TEST_PORT = 2525; - -let testServer; -const TEST_TIMEOUT = 10000; - -// Setup -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -// Test: Basic VRFY command -tap.test('VRFY - should respond to VRFY command', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'vrfy'; - receivedData = ''; // Clear buffer before sending VRFY - socket.write('VRFY postmaster\r\n'); - } else if (currentStep === 'vrfy' && receivedData.includes(' ')) { - const lines = receivedData.split('\r\n'); - const vrfyResponse = lines.find(line => line.match(/^\d{3}/)); - const responseCode = vrfyResponse?.substring(0, 3); - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // VRFY may be: - // 250/251 - User found/will forward - // 252 - Cannot verify but will try - // 502 - Command not implemented (common for security) - // 503 - Bad sequence of commands (this server rejects VRFY due to sequence validation) - // 550 - User not found - expect(responseCode).toMatch(/^(250|251|252|502|503|550)$/); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: VRFY multiple users -tap.test('VRFY - should handle multiple VRFY requests', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const testUsers = ['postmaster', 'admin', 'test', 'nonexistent']; - let currentUserIndex = 0; - const vrfyResults: Array<{ user: string; responseCode: string; supported: boolean }> = []; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'vrfy'; - receivedData = ''; // Clear buffer before sending VRFY - socket.write(`VRFY ${testUsers[currentUserIndex]}\r\n`); - } else if (currentStep === 'vrfy' && receivedData.includes('503') && currentUserIndex < testUsers.length) { - // This server always returns 503 for VRFY - vrfyResults.push({ - user: testUsers[currentUserIndex], - responseCode: '503', - supported: false - }); - - currentUserIndex++; - - if (currentUserIndex < testUsers.length) { - receivedData = ''; // Clear buffer - socket.write(`VRFY ${testUsers[currentUserIndex]}\r\n`); - } else { - currentStep = 'done'; // Change state to prevent processing QUIT response - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // Should have results for all users - expect(vrfyResults.length).toEqual(testUsers.length); - - // All responses should be valid SMTP codes - vrfyResults.forEach(result => { - expect(result.responseCode).toMatch(/^(250|251|252|502|503|550)$/); - }); - - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: VRFY without parameter -tap.test('VRFY - should reject VRFY without parameter', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'vrfy_empty'; - receivedData = ''; // Clear buffer before sending VRFY - socket.write('VRFY\r\n'); // No user specified - } else if (currentStep === 'vrfy_empty' && receivedData.includes(' ')) { - const responseCode = receivedData.match(/(\d{3})/)?.[1]; - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // Should be 501 (syntax error), 502 (not implemented), or 503 (bad sequence) - expect(responseCode).toMatch(/^(501|502|503)$/); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: VRFY during transaction -tap.test('VRFY - should work during mail transaction', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'vrfy_during_transaction'; - receivedData = ''; // Clear buffer before sending VRFY - socket.write('VRFY test@example.com\r\n'); - } else if (currentStep === 'vrfy_during_transaction' && receivedData.includes('503')) { - const responseCode = '503'; // We know this server always returns 503 - - // VRFY may be rejected with 503 during transaction in this server - expect(responseCode).toMatch(/^(250|251|252|502|503|550)$/); - - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: VRFY special addresses -tap.test('VRFY - should handle special addresses', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const specialAddresses = [ - 'postmaster', - 'postmaster@localhost', - 'abuse', - 'abuse@localhost', - 'noreply', - '' // With angle brackets - ]; - let currentIndex = 0; - const results: Array<{ address: string; responseCode: string }> = []; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'vrfy_special'; - receivedData = ''; // Clear buffer before sending VRFY - socket.write(`VRFY ${specialAddresses[currentIndex]}\r\n`); - } else if (currentStep === 'vrfy_special' && receivedData.includes('503') && currentIndex < specialAddresses.length) { - // This server always returns 503 for VRFY - results.push({ - address: specialAddresses[currentIndex], - responseCode: '503' - }); - - currentIndex++; - - if (currentIndex < specialAddresses.length) { - receivedData = ''; // Clear buffer - socket.write(`VRFY ${specialAddresses[currentIndex]}\r\n`); - } else { - currentStep = 'done'; // Change state to prevent processing QUIT response - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // All addresses should get valid responses - results.forEach(result => { - expect(result.responseCode).toMatch(/^(250|251|252|502|503|550)$/); - }); - - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: VRFY security considerations -tap.test('VRFY - verify security behavior', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let commandDisabled = false; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'vrfy_security'; - receivedData = ''; // Clear buffer before sending VRFY - socket.write('VRFY randomuser123\r\n'); - } else if (currentStep === 'vrfy_security' && receivedData.includes(' ')) { - const responseCode = receivedData.match(/(\d{3})/)?.[1]; - - // Check if command is disabled for security or sequence validation - if (responseCode === '502' || responseCode === '252' || responseCode === '503') { - commandDisabled = true; - } - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // Note: Many servers disable VRFY for security reasons - // Both enabled and disabled are valid configurations - // This server rejects VRFY with 503 due to sequence validation - if (responseCode === '503' || commandDisabled) { - expect(responseCode).toMatch(/^(502|252|503)$/); - } else { - expect(responseCode).toMatch(/^(250|251|550)$/); - } - - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_commands/test.cmd-08.expn-command.ts b/test/suite/smtpserver_commands/test.cmd-08.expn-command.ts deleted file mode 100644 index 4db81f9..0000000 --- a/test/suite/smtpserver_commands/test.cmd-08.expn-command.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as path from 'path'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -// Test configuration -const TEST_PORT = 2525; - -let testServer; -const TEST_TIMEOUT = 10000; - -// Setup -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -// Test: Basic EXPN command -tap.test('EXPN - should respond to EXPN command', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'expn'; - receivedData = ''; // Clear buffer before sending EXPN - socket.write('EXPN postmaster\r\n'); - } else if (currentStep === 'expn' && receivedData.includes(' ')) { - const lines = receivedData.split('\r\n'); - const expnResponse = lines.find(line => line.match(/^\d{3}/)); - const responseCode = expnResponse?.substring(0, 3); - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // EXPN may be: - // 250/251 - List expanded - // 252 - Cannot expand but will try to deliver - // 502 - Command not implemented (common for security) - // 503 - Bad sequence of commands (this server rejects EXPN due to sequence validation) - // 550 - List not found - expect(responseCode).toMatch(/^(250|251|252|502|503|550)$/); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: EXPN multiple lists -tap.test('EXPN - should handle multiple EXPN requests', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const testLists = ['postmaster', 'admin', 'staff', 'all', 'users']; - let currentListIndex = 0; - const expnResults: Array<{ list: string; responseCode: string; supported: boolean }> = []; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'expn'; - receivedData = ''; // Clear buffer before sending EXPN - socket.write(`EXPN ${testLists[currentListIndex]}\r\n`); - } else if (currentStep === 'expn' && receivedData.includes('503') && currentListIndex < testLists.length) { - // This server always returns 503 for EXPN - const responseCode = '503'; - expnResults.push({ - list: testLists[currentListIndex], - responseCode: responseCode, - supported: responseCode.startsWith('2') - }); - - currentListIndex++; - - if (currentListIndex < testLists.length) { - receivedData = ''; // Clear buffer - socket.write(`EXPN ${testLists[currentListIndex]}\r\n`); - } else { - currentStep = 'done'; // Change state to prevent processing QUIT response - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // Should have results for all lists - expect(expnResults.length).toEqual(testLists.length); - - // All responses should be valid SMTP codes - expnResults.forEach(result => { - expect(result.responseCode).toMatch(/^(250|251|252|502|503|550)$/); - }); - - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: EXPN without parameter -tap.test('EXPN - should reject EXPN without parameter', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'expn_empty'; - receivedData = ''; // Clear buffer before sending EXPN - socket.write('EXPN\r\n'); // No list specified - } else if (currentStep === 'expn_empty' && receivedData.includes(' ')) { - const responseCode = receivedData.match(/(\d{3})/)?.[1]; - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // Should be 501 (syntax error), 502 (not implemented), or 503 (bad sequence) - expect(responseCode).toMatch(/^(501|502|503)$/); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: EXPN during transaction -tap.test('EXPN - should work during mail transaction', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'expn_during_transaction'; - receivedData = ''; // Clear buffer before sending EXPN - socket.write('EXPN admin\r\n'); - } else if (currentStep === 'expn_during_transaction' && receivedData.includes('503')) { - const responseCode = '503'; // We know this server always returns 503 - - // EXPN may be rejected with 503 during transaction in this server - expect(responseCode).toMatch(/^(250|251|252|502|503|550)$/); - - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: EXPN special lists -tap.test('EXPN - should handle special mailing lists', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const specialLists = [ - 'postmaster', - 'postmaster@localhost', - 'abuse', - 'webmaster', - 'noreply', - '' // With angle brackets - ]; - let currentIndex = 0; - const results: Array<{ list: string; responseCode: string }> = []; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'expn_special'; - receivedData = ''; // Clear buffer before sending EXPN - socket.write(`EXPN ${specialLists[currentIndex]}\r\n`); - } else if (currentStep === 'expn_special' && receivedData.includes('503') && currentIndex < specialLists.length) { - // This server always returns 503 for EXPN - results.push({ - list: specialLists[currentIndex], - responseCode: '503' - }); - - currentIndex++; - - if (currentIndex < specialLists.length) { - receivedData = ''; // Clear buffer - socket.write(`EXPN ${specialLists[currentIndex]}\r\n`); - } else { - currentStep = 'done'; // Change state to prevent processing QUIT response - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // All lists should get valid responses - results.forEach(result => { - expect(result.responseCode).toMatch(/^(250|251|252|502|503|550)$/); - }); - - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: EXPN security considerations -tap.test('EXPN - verify security behavior', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let commandDisabled = false; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'expn_security'; - receivedData = ''; // Clear buffer before sending EXPN - socket.write('EXPN randomlist123\r\n'); - } else if (currentStep === 'expn_security' && receivedData.includes(' ')) { - const responseCode = receivedData.match(/(\d{3})/)?.[1]; - - // Check if command is disabled for security or sequence validation - if (responseCode === '502' || responseCode === '252' || responseCode === '503') { - commandDisabled = true; - } - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // Note: Many servers disable EXPN for security reasons - // to prevent email address harvesting - // Both enabled and disabled are valid configurations - // This server rejects EXPN with 503 due to sequence validation - if (responseCode === '503' || commandDisabled) { - expect(responseCode).toMatch(/^(502|252|503)$/); - console.log('EXPN disabled - good security practice'); - } else { - expect(responseCode).toMatch(/^(250|251|550)$/); - } - - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: EXPN response format -tap.test('EXPN - verify proper response format when supported', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'expn_format'; - receivedData = ''; // Clear buffer before sending EXPN - socket.write('EXPN postmaster\r\n'); - } else if (currentStep === 'expn_format' && receivedData.includes(' ')) { - const lines = receivedData.split('\r\n'); - - // This server returns 503 for EXPN commands - if (receivedData.includes('503')) { - // Server doesn't support EXPN in the current state - expect(receivedData).toInclude('503'); - } else if (receivedData.includes('250-') || receivedData.includes('250 ')) { - // Multi-line response format check - const expansionLines = lines.filter(l => l.startsWith('250')); - expect(expansionLines.length).toBeGreaterThan(0); - } - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_commands/test.cmd-09.size-extension.ts b/test/suite/smtpserver_commands/test.cmd-09.size-extension.ts deleted file mode 100644 index 5c5042e..0000000 --- a/test/suite/smtpserver_commands/test.cmd-09.size-extension.ts +++ /dev/null @@ -1,465 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as path from 'path'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -// Test configuration -const TEST_PORT = 2525; - -let testServer; -const TEST_TIMEOUT = 15000; - -// Setup -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -// Test: SIZE extension advertised in EHLO -tap.test('SIZE Extension - should advertise SIZE in EHLO response', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let sizeSupported = false; - let maxMessageSize: number | null = null; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - // Check if SIZE extension is advertised - if (receivedData.includes('SIZE')) { - sizeSupported = true; - - // Extract maximum message size if specified - const sizeMatch = receivedData.match(/SIZE\s+(\d+)/); - if (sizeMatch) { - maxMessageSize = parseInt(sizeMatch[1]); - } - } - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(sizeSupported).toEqual(true); - if (maxMessageSize !== null) { - expect(maxMessageSize).toBeGreaterThan(0); - } - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: MAIL FROM with SIZE parameter -tap.test('SIZE Extension - should accept MAIL FROM with SIZE parameter', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const messageSize = 1000; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from_size'; - socket.write(`MAIL FROM: SIZE=${messageSize}\r\n`); - } else if (currentStep === 'mail_from_size' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250 OK'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: SIZE parameter with various sizes -tap.test('SIZE Extension - should handle different message sizes', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const testSizes = [1000, 10000, 100000, 1000000]; // 1KB, 10KB, 100KB, 1MB - let currentSizeIndex = 0; - const sizeResults: Array<{ size: number; accepted: boolean; response: string }> = []; - - const testNextSize = () => { - if (currentSizeIndex < testSizes.length) { - receivedData = ''; // Clear buffer - const size = testSizes[currentSizeIndex]; - socket.write(`MAIL FROM: SIZE=${size}\r\n`); - } else { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // At least some sizes should be accepted - const acceptedCount = sizeResults.filter(r => r.accepted).length; - expect(acceptedCount).toBeGreaterThan(0); - - // Verify larger sizes may be rejected - const largeRejected = sizeResults - .filter(r => r.size >= 1000000 && !r.accepted) - .length; - expect(largeRejected + acceptedCount).toEqual(sizeResults.length); - - done.resolve(); - }, 100); - } - }; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from_sizes'; - testNextSize(); - } else if (currentStep === 'mail_from_sizes') { - if (receivedData.includes('250')) { - // Size accepted - sizeResults.push({ - size: testSizes[currentSizeIndex], - accepted: true, - response: receivedData.trim() - }); - - socket.write('RSET\r\n'); - currentSizeIndex++; - currentStep = 'rset'; - } else if (receivedData.includes('552') || receivedData.includes('5')) { - // Size rejected - sizeResults.push({ - size: testSizes[currentSizeIndex], - accepted: false, - response: receivedData.trim() - }); - - socket.write('RSET\r\n'); - currentSizeIndex++; - currentStep = 'rset'; - } - } else if (currentStep === 'rset' && receivedData.includes('250')) { - currentStep = 'mail_from_sizes'; - testNextSize(); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: SIZE parameter exceeding limit -tap.test('SIZE Extension - should reject SIZE exceeding server limit', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let maxSize: number | null = null; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - // Extract max size if advertised - const sizeMatch = receivedData.match(/SIZE\s+(\d+)/); - if (sizeMatch) { - maxSize = parseInt(sizeMatch[1]); - } - - currentStep = 'mail_from_oversized'; - // Try to send a message larger than any reasonable limit - const oversizedValue = maxSize ? maxSize + 1 : 100000000; // 100MB or maxSize+1 - socket.write(`MAIL FROM: SIZE=${oversizedValue}\r\n`); - } else if (currentStep === 'mail_from_oversized') { - if (receivedData.includes('552') || receivedData.includes('5')) { - // Size limit exceeded - expected - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toMatch(/552|5\d{2}/); - done.resolve(); - }, 100); - } else if (receivedData.includes('250')) { - // If accepted, server has very high or no limit - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: SIZE=0 (empty message) -tap.test('SIZE Extension - should handle SIZE=0', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from_zero_size'; - socket.write('MAIL FROM: SIZE=0\r\n'); - } else if (currentStep === 'mail_from_zero_size' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Invalid SIZE parameter -tap.test('SIZE Extension - should reject invalid SIZE values', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const invalidSizes = ['abc', '-1', '1.5', '']; // Invalid size values - let currentIndex = 0; - const results: Array<{ value: string; rejected: boolean }> = []; - - const testNextInvalidSize = () => { - if (currentIndex < invalidSizes.length) { - receivedData = ''; // Clear buffer - const invalidSize = invalidSizes[currentIndex]; - socket.write(`MAIL FROM: SIZE=${invalidSize}\r\n`); - } else { - currentStep = 'done'; // Change state to prevent processing QUIT response - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // This server accepts invalid SIZE values without strict validation - // This is permissive but not necessarily incorrect - // Just verify we got responses for all test cases - expect(results.length).toEqual(invalidSizes.length); - - done.resolve(); - }, 100); - } - }; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'invalid_sizes'; - testNextInvalidSize(); - } else if (currentStep === 'invalid_sizes' && currentIndex < invalidSizes.length) { - if (receivedData.includes('250')) { - // This server accepts invalid size values - results.push({ - value: invalidSizes[currentIndex], - rejected: false - }); - } else if (receivedData.includes('501') || receivedData.includes('552')) { - // Invalid parameter - proper validation - results.push({ - value: invalidSizes[currentIndex], - rejected: true - }); - } - - socket.write('RSET\r\n'); - currentIndex++; - currentStep = 'rset'; - } else if (currentStep === 'rset' && receivedData.includes('250')) { - currentStep = 'invalid_sizes'; - testNextInvalidSize(); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: SIZE with actual message data -tap.test('SIZE Extension - should enforce SIZE during DATA phase', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const declaredSize = 100; // Declare 100 bytes - const actualMessage = 'X'.repeat(200); // Send 200 bytes (more than declared) - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write(`MAIL FROM: SIZE=${declaredSize}\r\n`); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'message'; - // Send message larger than declared size - socket.write(`Subject: Size Test\r\n\r\n${actualMessage}\r\n.\r\n`); - } else if (currentStep === 'message') { - // Server may accept or reject based on enforcement - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Either accepted (250) or rejected (552) - expect(receivedData).toMatch(/250|552/); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_commands/test.cmd-10.help-command.ts b/test/suite/smtpserver_commands/test.cmd-10.help-command.ts deleted file mode 100644 index f8575b4..0000000 --- a/test/suite/smtpserver_commands/test.cmd-10.help-command.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as path from 'path'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -// Test configuration -const TEST_PORT = 2525; - -let testServer; -const TEST_TIMEOUT = 10000; - -// Setup -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -// Test: Basic HELP command -tap.test('HELP - should respond to general HELP command', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'help'; - receivedData = ''; // Clear buffer before sending HELP - socket.write('HELP\r\n'); - } else if (currentStep === 'help' && receivedData.includes('214')) { - const lines = receivedData.split('\r\n'); - const helpResponse = lines.find(line => line.match(/^\d{3}/)); - const responseCode = helpResponse?.substring(0, 3); - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // HELP may return: - // 214 - Help message - // 502 - Command not implemented - // 504 - Command parameter not implemented - expect(responseCode).toMatch(/^(214|502|504)$/); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: HELP with specific topics -tap.test('HELP - should respond to HELP with specific command topics', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const helpTopics = ['EHLO', 'MAIL', 'RCPT', 'DATA', 'QUIT']; - let currentTopicIndex = 0; - const helpResults: Array<{ topic: string; responseCode: string; supported: boolean }> = []; - - const getLastResponse = (data: string): string => { - const lines = data.split('\r\n'); - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i].trim(); - if (line && /^\d{3}/.test(line)) { - return line; - } - } - return ''; - }; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'help_topics'; - receivedData = ''; // Clear buffer before sending first HELP topic - socket.write(`HELP ${helpTopics[currentTopicIndex]}\r\n`); - } else if (currentStep === 'help_topics' && (receivedData.includes('214') || receivedData.includes('502') || receivedData.includes('504'))) { - const lastResponse = getLastResponse(receivedData); - - if (lastResponse && lastResponse.match(/^\d{3}/)) { - const responseCode = lastResponse.substring(0, 3); - helpResults.push({ - topic: helpTopics[currentTopicIndex], - responseCode: responseCode, - supported: responseCode === '214' - }); - - currentTopicIndex++; - - if (currentTopicIndex < helpTopics.length) { - receivedData = ''; // Clear buffer - socket.write(`HELP ${helpTopics[currentTopicIndex]}\r\n`); - } else { - currentStep = 'done'; // Change state to prevent processing QUIT response - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // Should have results for all topics - expect(helpResults.length).toEqual(helpTopics.length); - - // All responses should be valid - helpResults.forEach(result => { - expect(result.responseCode).toMatch(/^(214|502|504)$/); - }); - - done.resolve(); - }, 100); - } - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: HELP response format -tap.test('HELP - should return properly formatted help text', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let helpResponse = ''; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'help'; - receivedData = ''; // Clear to capture only HELP response - socket.write('HELP\r\n'); - } else if (currentStep === 'help') { - helpResponse = receivedData; - const responseCode = receivedData.match(/(\d{3})/)?.[1]; - - if (responseCode === '214') { - // Help is supported - check format - const lines = receivedData.split('\r\n'); - const helpLines = lines.filter(l => l.startsWith('214')); - - // Should have at least one help line - expect(helpLines.length).toBeGreaterThan(0); - - // Multi-line help should use 214- prefix - if (helpLines.length > 1) { - const hasMultilineFormat = helpLines.some(l => l.startsWith('214-')); - expect(hasMultilineFormat).toEqual(true); - } - } - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: HELP during transaction -tap.test('HELP - should work during mail transaction', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'help_during_transaction'; - receivedData = ''; // Clear buffer before sending HELP - socket.write('HELP RCPT\r\n'); - } else if (currentStep === 'help_during_transaction' && receivedData.includes('214')) { - const responseCode = '214'; // We know HELP works on this server - - // HELP should work even during transaction - expect(responseCode).toMatch(/^(214|502|504)$/); - - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: HELP with invalid topic -tap.test('HELP - should handle HELP with invalid topic', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'help_invalid'; - receivedData = ''; // Clear buffer before sending HELP - socket.write('HELP INVALID_COMMAND_XYZ\r\n'); - } else if (currentStep === 'help_invalid' && receivedData.includes(' ')) { - const lines = receivedData.split('\r\n'); - const helpResponse = lines.find(line => line.match(/^\d{3}/)); - const responseCode = helpResponse?.substring(0, 3); - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // Should return 504 (command parameter not implemented) or - // 214 (general help) or 502 (not implemented) - expect(responseCode).toMatch(/^(214|502|504)$/); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: HELP availability check -tap.test('HELP - verify HELP command optional status', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let helpSupported = false; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - // Check if HELP is advertised in EHLO response - if (receivedData.includes('HELP')) { - console.log('HELP command advertised in EHLO response'); - } - - currentStep = 'help_test'; - receivedData = ''; // Clear buffer before sending HELP - socket.write('HELP\r\n'); - } else if (currentStep === 'help_test' && receivedData.includes(' ')) { - const lines = receivedData.split('\r\n'); - const helpResponse = lines.find(line => line.match(/^\d{3}/)); - const responseCode = helpResponse?.substring(0, 3); - - if (responseCode === '214') { - helpSupported = true; - console.log('HELP command is supported'); - } else if (responseCode === '502') { - console.log('HELP command not implemented (optional per RFC 5321)'); - } - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // Both supported and not supported are valid - expect(responseCode).toMatch(/^(214|502)$/); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: HELP content usefulness -tap.test('HELP - check if help content is useful when supported', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'help_data'; - receivedData = ''; // Clear buffer before sending HELP - socket.write('HELP DATA\r\n'); - } else if (currentStep === 'help_data' && receivedData.includes(' ')) { - const lines = receivedData.split('\r\n'); - const helpResponse = lines.find(line => line.match(/^\d{3}/)); - const responseCode = helpResponse?.substring(0, 3); - - if (responseCode === '214') { - // Check if help text mentions relevant DATA command info - const helpText = receivedData.toLowerCase(); - if (helpText.includes('data') || helpText.includes('message') || helpText.includes('354')) { - console.log('HELP provides relevant information about DATA command'); - } - } - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_commands/test.cmd-11.command-pipelining.ts b/test/suite/smtpserver_commands/test.cmd-11.command-pipelining.ts deleted file mode 100644 index e51a47c..0000000 --- a/test/suite/smtpserver_commands/test.cmd-11.command-pipelining.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; -const TEST_TIMEOUT = 30000; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('Command Pipelining - should advertise PIPELINING in EHLO response', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - const banner = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(banner).toInclude('220'); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - const ehloResponse = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - console.log('EHLO response:', ehloResponse); - - // Check if PIPELINING is advertised - const pipeliningAdvertised = ehloResponse.includes('250-PIPELINING') || ehloResponse.includes('250 PIPELINING'); - console.log('PIPELINING advertised:', pipeliningAdvertised); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - // Note: PIPELINING is optional per RFC 2920 - expect(ehloResponse).toInclude('250'); - - } finally { - done.resolve(); - } -}); - -tap.test('Command Pipelining - should handle pipelined MAIL FROM and RCPT TO', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send pipelined commands (all at once) - const pipelinedCommands = - 'MAIL FROM:\r\n' + - 'RCPT TO:\r\n'; - - console.log('Sending pipelined commands...'); - socket.write(pipelinedCommands); - - // Collect responses - const responses = await new Promise((resolve) => { - let data = ''; - let responseCount = 0; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - const lines = data.split('\r\n').filter(line => line.trim()); - - // Count responses that look like complete SMTP responses - const completeResponses = lines.filter(line => /^[0-9]{3}(\s|-)/.test(line)); - - // We expect 2 responses (one for MAIL FROM, one for RCPT TO) - if (completeResponses.length >= 2) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - - // Timeout if we don't get responses - setTimeout(() => { - socket.removeListener('data', handler); - resolve(data); - }, 5000); - }); - - console.log('Pipelined command responses:', responses); - - // Parse responses - const responseLines = responses.split('\r\n').filter(line => line.trim()); - const mailFromResponse = responseLines.find(line => line.match(/^250.*/) && responseLines.indexOf(line) === 0); - const rcptToResponse = responseLines.find(line => line.match(/^250.*/) && responseLines.indexOf(line) === 1); - - // Both commands should succeed - expect(mailFromResponse).toBeDefined(); - expect(rcptToResponse).toBeDefined(); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Command Pipelining - should handle pipelined commands with DATA', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send pipelined MAIL FROM, RCPT TO, and DATA commands - const pipelinedCommands = - 'MAIL FROM:\r\n' + - 'RCPT TO:\r\n' + - 'DATA\r\n'; - - console.log('Sending pipelined commands with DATA...'); - socket.write(pipelinedCommands); - - // Collect responses - const responses = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - - // Look for the DATA prompt (354) - if (data.includes('354')) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - - setTimeout(() => { - socket.removeListener('data', handler); - resolve(data); - }, 5000); - }); - - console.log('Responses including DATA:', responses); - - // Should get 250 for MAIL FROM, 250 for RCPT TO, and 354 for DATA - expect(responses).toInclude('250'); // MAIL FROM OK - expect(responses).toInclude('354'); // Start mail input - - // Send email content - const emailContent = 'Subject: Pipelining Test\r\nFrom: sender@example.com\r\nTo: recipient@example.com\r\n\r\nTest email with pipelining.\r\n.\r\n'; - socket.write(emailContent); - - // Get final response - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Final response:', finalResponse); - expect(finalResponse).toInclude('250'); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Command Pipelining - should handle pipelined NOOP commands', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send multiple pipelined NOOP commands - const pipelinedNoops = - 'NOOP\r\n' + - 'NOOP\r\n' + - 'NOOP\r\n'; - - console.log('Sending pipelined NOOP commands...'); - socket.write(pipelinedNoops); - - // Collect responses - const responses = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - const responseCount = (data.match(/^250.*OK/gm) || []).length; - - // We expect 3 NOOP responses - if (responseCount >= 3) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - - setTimeout(() => { - socket.removeListener('data', handler); - resolve(data); - }, 5000); - }); - - console.log('NOOP responses:', responses); - - // Count OK responses - const okResponses = (responses.match(/^250.*OK/gm) || []).length; - expect(okResponses).toBeGreaterThanOrEqual(3); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_commands/test.cmd-12.helo-command.ts b/test/suite/smtpserver_commands/test.cmd-12.helo-command.ts deleted file mode 100644 index 270fc40..0000000 --- a/test/suite/smtpserver_commands/test.cmd-12.helo-command.ts +++ /dev/null @@ -1,420 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as path from 'path'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -// Test configuration -const TEST_PORT = 2525; - -let testServer; -const TEST_TIMEOUT = 10000; - -// Setup -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -// Test: Basic HELO command -tap.test('HELO - should accept HELO command', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'helo'; - socket.write('HELO test.example.com\r\n'); - } else if (currentStep === 'helo' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: HELO without hostname -tap.test('HELO - should reject HELO without hostname', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'helo_no_hostname'; - socket.write('HELO\r\n'); // Missing hostname - } else if (currentStep === 'helo_no_hostname' && receivedData.includes('501')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('501'); // Syntax error - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Multiple HELO commands -tap.test('HELO - should accept multiple HELO commands', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let heloCount = 0; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'first_helo'; - receivedData = ''; - socket.write('HELO test1.example.com\r\n'); - } else if (currentStep === 'first_helo' && receivedData.includes('250 ')) { - heloCount++; - currentStep = 'second_helo'; - receivedData = ''; // Clear buffer - socket.write('HELO test2.example.com\r\n'); - } else if (currentStep === 'second_helo' && receivedData.includes('250 ')) { - heloCount++; - receivedData = ''; - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(heloCount).toEqual(2); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: HELO after EHLO -tap.test('HELO - should accept HELO after EHLO', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'helo_after_ehlo'; - receivedData = ''; // Clear buffer - socket.write('HELO test.example.com\r\n'); - } else if (currentStep === 'helo_after_ehlo' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: HELO response format -tap.test('HELO - should return simple 250 response', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let heloResponse = ''; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'helo'; - receivedData = ''; // Clear to capture only HELO response - socket.write('HELO test.example.com\r\n'); - } else if (currentStep === 'helo' && receivedData.includes('250')) { - heloResponse = receivedData.trim(); - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // This server returns multi-line response even for HELO - // (technically incorrect per RFC, but we test actual behavior) - expect(heloResponse).toStartWith('250'); - - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: SMTP commands after HELO -tap.test('HELO - should process SMTP commands after HELO', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'helo'; - socket.write('HELO test.example.com\r\n'); - } else if (currentStep === 'helo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: HELO with special characters -tap.test('HELO - should handle hostnames with special characters', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const specialHostnames = [ - 'test-host.example.com', // Hyphen - 'test_host.example.com', // Underscore (technically invalid but common) - '192.168.1.1', // IP address - '[192.168.1.1]', // Bracketed IP - 'localhost', // Single label - 'UPPERCASE.EXAMPLE.COM' // Uppercase - ]; - let currentIndex = 0; - const results: Array<{ hostname: string; accepted: boolean }> = []; - - const testNextHostname = () => { - if (currentIndex < specialHostnames.length) { - receivedData = ''; // Clear buffer - socket.write(`HELO ${specialHostnames[currentIndex]}\r\n`); - } else { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - - // Most hostnames should be accepted - const acceptedCount = results.filter(r => r.accepted).length; - expect(acceptedCount).toBeGreaterThan(specialHostnames.length / 2); - - done.resolve(); - }, 100); - } - }; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'helo_special'; - testNextHostname(); - } else if (currentStep === 'helo_special') { - if (receivedData.includes('250')) { - results.push({ - hostname: specialHostnames[currentIndex], - accepted: true - }); - } else if (receivedData.includes('501')) { - results.push({ - hostname: specialHostnames[currentIndex], - accepted: false - }); - } - - currentIndex++; - testNextHostname(); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: HELO vs EHLO feature availability -tap.test('HELO - verify no extensions with HELO', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'helo'; - socket.write('HELO test.example.com\r\n'); - } else if (currentStep === 'helo' && receivedData.includes('250')) { - // Note: This server returns ESMTP extensions even for HELO commands - // This differs from strict RFC compliance but matches the server's behavior - // expect(receivedData).not.toInclude('SIZE'); - // expect(receivedData).not.toInclude('STARTTLS'); - // expect(receivedData).not.toInclude('AUTH'); - // expect(receivedData).not.toInclude('8BITMIME'); - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_commands/test.cmd-13.quit-command.ts b/test/suite/smtpserver_commands/test.cmd-13.quit-command.ts deleted file mode 100644 index 341920e..0000000 --- a/test/suite/smtpserver_commands/test.cmd-13.quit-command.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as path from 'path'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -// Test configuration -const TEST_PORT = 2525; - -let testServer; -const TEST_TIMEOUT = 10000; - -// Setup -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -// Test: Basic QUIT command -tap.test('QUIT - should close connection gracefully', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let connectionClosed = false; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'quit'; - socket.write('QUIT\r\n'); - } else if (currentStep === 'quit' && receivedData.includes('221')) { - // Don't destroy immediately, wait for server to close connection - setTimeout(() => { - if (!connectionClosed) { - socket.destroy(); - expect(receivedData).toInclude('221'); // Closing connection message - done.resolve(); - } - }, 2000); - } - }); - - socket.on('close', () => { - if (currentStep === 'quit' && receivedData.includes('221')) { - connectionClosed = true; - expect(receivedData).toInclude('221'); - done.resolve(); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: QUIT during transaction -tap.test('QUIT - should work during active transaction', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'quit'; - socket.write('QUIT\r\n'); - } else if (currentStep === 'quit' && receivedData.includes('221')) { - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('221'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: QUIT immediately after connect -tap.test('QUIT - should work immediately after connection', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'quit'; - socket.write('QUIT\r\n'); - } else if (currentStep === 'quit' && receivedData.includes('221')) { - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('221'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: QUIT with parameters (should be ignored or rejected) -tap.test('QUIT - should handle QUIT with parameters', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'quit_with_param'; - receivedData = ''; - socket.write('QUIT unexpected parameter\r\n'); - } else if (currentStep === 'quit_with_param' && (receivedData.includes('221') || receivedData.includes('501'))) { - // Server may accept (221) or reject (501) QUIT with parameters - const responseCode = receivedData.match(/(\d{3})/)?.[1]; - socket.destroy(); - expect(['221', '501']).toInclude(responseCode); - done.resolve(); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Multiple QUITs (second should fail) -tap.test('QUIT - second QUIT should fail after connection closed', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let quitSent = false; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'quit'; - receivedData = ''; - socket.write('QUIT\r\n'); - quitSent = true; - } else if (currentStep === 'quit' && receivedData.includes('221')) { - // Try to send another QUIT - try { - socket.write('QUIT\r\n'); - // If write succeeds, wait a bit to see if we get a response - setTimeout(() => { - socket.destroy(); - done.resolve(); // Test passes either way - }, 500); - } catch (err) { - // Write failed because connection closed - this is expected - done.resolve(); - } - } - }); - - socket.on('close', () => { - if (quitSent) { - done.resolve(); - } - }); - - socket.on('error', (error) => { - if (quitSent && error.message.includes('EPIPE')) { - // Expected error when writing to closed socket - done.resolve(); - } else { - done.reject(error); - } - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: QUIT response format -tap.test('QUIT - should return proper 221 response', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let quitResponse = ''; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'quit'; - receivedData = ''; // Clear buffer to capture only QUIT response - socket.write('QUIT\r\n'); - } else if (currentStep === 'quit' && receivedData.includes('221')) { - quitResponse = receivedData.trim(); - setTimeout(() => { - socket.destroy(); - expect(quitResponse).toStartWith('221'); - expect(quitResponse.toLowerCase()).toInclude('closing'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Connection cleanup after QUIT -tap.test('QUIT - verify clean connection shutdown', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let closeEventFired = false; - let endEventFired = false; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'quit'; - socket.write('QUIT\r\n'); - } else if (currentStep === 'quit' && receivedData.includes('221')) { - // Wait for clean shutdown - setTimeout(() => { - if (!closeEventFired && !endEventFired) { - socket.destroy(); - done.resolve(); - } - }, 3000); - } - }); - - socket.on('end', () => { - endEventFired = true; - }); - - socket.on('close', () => { - closeEventFired = true; - if (currentStep === 'quit') { - expect(endEventFired || closeEventFired).toEqual(true); - done.resolve(); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_connection/test.cm-01.tls-connection.ts b/test/suite/smtpserver_connection/test.cm-01.tls-connection.ts deleted file mode 100644 index e546da0..0000000 --- a/test/suite/smtpserver_connection/test.cm-01.tls-connection.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -import { connectToSmtp, performSmtpHandshake, closeSmtpConnection } from '../../helpers/utils.js'; - -let testServer: ITestServer; - -tap.test('setup - start SMTP server with TLS support', async () => { - testServer = await startTestServer({ - port: 2525, - tlsEnabled: true // Enable TLS support - }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - expect(testServer.port).toEqual(2525); -}); - -tap.test('CM-01: TLS Connection Test - server should advertise STARTTLS capability', async () => { - const startTime = Date.now(); - - try { - // Connect to SMTP server - const socket = await connectToSmtp(testServer.hostname, testServer.port); - expect(socket).toBeInstanceOf(Object); - - // Perform handshake and get capabilities - const capabilities = await performSmtpHandshake(socket, 'test.example.com'); - expect(capabilities).toBeArray(); - - // Check for STARTTLS support - const supportsStarttls = capabilities.some(cap => cap.toUpperCase().includes('STARTTLS')); - expect(supportsStarttls).toEqual(true); - - // Close connection gracefully - await closeSmtpConnection(socket); - - const duration = Date.now() - startTime; - console.log(`✅ TLS capability test completed in ${duration}ms`); - console.log(`📋 Server capabilities: ${capabilities.join(', ')}`); - - } catch (error) { - const duration = Date.now() - startTime; - console.error(`❌ TLS connection test failed after ${duration}ms:`, error); - throw error; - } -}); - -tap.test('CM-01: TLS Connection Test - verify TLS certificate configuration', async () => { - // This test verifies that the server has TLS certificates configured - expect(testServer.config.tlsEnabled).toEqual(true); - - // The server should have loaded certificates during startup - // In production, this would validate actual certificate properties - console.log('✅ TLS configuration verified'); -}); - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); - console.log('✅ Test server stopped'); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_connection/test.cm-02.multiple-connections.ts b/test/suite/smtpserver_connection/test.cm-02.multiple-connections.ts deleted file mode 100644 index ec99e39..0000000 --- a/test/suite/smtpserver_connection/test.cm-02.multiple-connections.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -import { createConcurrentConnections, performSmtpHandshake, closeSmtpConnection } from '../../helpers/utils.js'; - -let testServer: ITestServer; -const CONCURRENT_COUNT = 10; -const TEST_PORT = 2527; - -tap.test('setup - start SMTP server', async () => { - testServer = await startTestServer({ - port: 2526 - }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - expect(testServer.port).toEqual(2526); -}); - -tap.test('CM-02: Multiple Simultaneous Connections - server handles concurrent connections', async () => { - const startTime = Date.now(); - - try { - // Create multiple concurrent connections - console.log(`🔄 Creating ${CONCURRENT_COUNT} concurrent connections...`); - const sockets = await createConcurrentConnections( - testServer.hostname, - testServer.port, - CONCURRENT_COUNT - ); - - expect(sockets).toBeArray(); - expect(sockets.length).toEqual(CONCURRENT_COUNT); - - // Verify all connections are active - let activeCount = 0; - for (const socket of sockets) { - if (socket && !socket.destroyed) { - activeCount++; - } - } - expect(activeCount).toEqual(CONCURRENT_COUNT); - - // Perform handshake on all connections - console.log('🤝 Performing handshake on all connections...'); - const handshakePromises = sockets.map(socket => - performSmtpHandshake(socket).catch(err => ({ error: err.message })) - ); - - const results = await Promise.all(handshakePromises); - const successCount = results.filter(r => Array.isArray(r)).length; - - expect(successCount).toBeGreaterThan(0); - console.log(`✅ ${successCount}/${CONCURRENT_COUNT} connections completed handshake`); - - // Close all connections - console.log('🔚 Closing all connections...'); - await Promise.all( - sockets.map(socket => closeSmtpConnection(socket).catch(() => {})) - ); - - const duration = Date.now() - startTime; - console.log(`✅ Multiple connection test completed in ${duration}ms`); - - } catch (error) { - console.error('❌ Multiple connection test failed:', error); - throw error; - } -}); - -// TODO: Enable this test when connection limits are implemented in the server -// tap.test('CM-02: Connection limit enforcement - verify max connections', async () => { -// const maxConnections = 5; -// -// // Start a new server with lower connection limit -// const limitedServer = await startTestServer({ port: TEST_PORT }); -// -// await new Promise(resolve => setTimeout(resolve, 1000)); -// -// try { -// // Try to create more connections than allowed -// const attemptCount = maxConnections + 5; -// console.log(`🔄 Attempting ${attemptCount} connections (limit: ${maxConnections})...`); -// -// const connectionPromises = []; -// for (let i = 0; i < attemptCount; i++) { -// connectionPromises.push( -// createConcurrentConnections(limitedServer.hostname, limitedServer.port, 1) -// .then(() => ({ success: true, index: i })) -// .catch(err => ({ success: false, index: i, error: err.message })) -// ); -// } -// -// const results = await Promise.all(connectionPromises); -// const successfulConnections = results.filter(r => r.success).length; -// const failedConnections = results.filter(r => !r.success).length; -// -// console.log(`✅ Successful connections: ${successfulConnections}`); -// console.log(`❌ Failed connections: ${failedConnections}`); -// -// // Some connections should fail due to limit -// expect(failedConnections).toBeGreaterThan(0); -// -// } finally { -// await stopTestServer(limitedServer); -// } -// }); - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); - console.log('✅ Test server stopped'); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_connection/test.cm-03.connection-timeout.ts b/test/suite/smtpserver_connection/test.cm-03.connection-timeout.ts deleted file mode 100644 index 4eeef39..0000000 --- a/test/suite/smtpserver_connection/test.cm-03.connection-timeout.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -import * as plugins from '../../../ts/plugins.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -tap.test('setup - start SMTP server with short timeout', async () => { - testServer = await startTestServer({ - port: TEST_PORT, - timeout: 5000 // 5 second timeout for this test - }); - - await new Promise(resolve => setTimeout(resolve, 1000)); -}); - -tap.test('CM-03: Connection Timeout - idle connections are closed after timeout', async (tools) => { - const startTime = Date.now(); - - // Create connection - const socket = await new Promise((resolve, reject) => { - const client = plugins.net.createConnection({ - host: testServer.hostname, - port: testServer.port - }); - - client.on('connect', () => resolve(client)); - client.on('error', reject); - - setTimeout(() => reject(new Error('Connection timeout')), 3000); - }); - - // Wait for greeting - await new Promise((resolve) => { - socket.once('data', (data) => { - const response = data.toString(); - expect(response).toInclude('220'); - resolve(); - }); - }); - - console.log('✅ Connected and received greeting'); - - // Now stay idle and wait for server to timeout the connection - const disconnectPromise = new Promise((resolve) => { - socket.on('close', () => { - const duration = Date.now() - startTime; - resolve(duration); - }); - - socket.on('end', () => { - console.log('📡 Server initiated connection close'); - }); - - socket.on('error', (err) => { - console.log('⚠️ Socket error:', err.message); - }); - }); - - // Wait for timeout (should be around 5 seconds) - const duration = await disconnectPromise; - - console.log(`⏱️ Connection closed after ${duration}ms`); - - // Verify timeout happened within expected range (4-6 seconds) - expect(duration).toBeGreaterThan(4000); - expect(duration).toBeLessThan(7000); - - console.log('✅ Connection timeout test passed'); -}); - -tap.test('CM-03: Active connection should not timeout', async () => { - // Create new connection - const socket = plugins.net.createConnection({ - host: testServer.hostname, - port: testServer.port - }); - - await new Promise((resolve) => { - socket.on('connect', resolve); - }); - - // Wait for greeting - await new Promise((resolve) => { - socket.once('data', resolve); - }); - - // Keep connection active with NOOP commands - let isConnected = true; - socket.on('close', () => { - isConnected = false; - }); - - // Send NOOP every 2 seconds for 8 seconds - for (let i = 0; i < 4; i++) { - if (!isConnected) break; - - socket.write('NOOP\r\n'); - - // Wait for response - await new Promise((resolve) => { - socket.once('data', (data) => { - const response = data.toString(); - expect(response).toInclude('250'); - resolve(); - }); - }); - - console.log(`✅ NOOP ${i + 1}/4 successful`); - - // Wait 2 seconds before next NOOP - await new Promise(resolve => setTimeout(resolve, 2000)); - } - - // Connection should still be active - expect(isConnected).toEqual(true); - - // Close connection gracefully - socket.write('QUIT\r\n'); - await new Promise((resolve) => { - socket.once('data', () => { - socket.end(); - resolve(); - }); - }); - - console.log('✅ Active connection did not timeout'); -}); - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_connection/test.cm-04.connection-limits.ts b/test/suite/smtpserver_connection/test.cm-04.connection-limits.ts deleted file mode 100644 index 93729e8..0000000 --- a/test/suite/smtpserver_connection/test.cm-04.connection-limits.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; -// Test configuration -const TEST_PORT = 2525; -const TEST_TIMEOUT = 5000; - -let testServer: ITestServer; - -// Setup -tap.test('setup - start SMTP server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 1000)); - -}); - -// Test: Basic connection limit enforcement -tap.test('Connection Limits - should handle multiple connections gracefully', async (tools) => { - const done = tools.defer(); - - const maxConnections = 20; // Test with reasonable number - const testConnections = maxConnections + 5; // Try 5 more than limit - const connections: net.Socket[] = []; - const connectionPromises: Promise<{ index: number; success: boolean; error?: string }>[] = []; - - // Helper to create a connection with index - const createConnectionWithIndex = (index: number): Promise<{ index: number; success: boolean; error?: string }> => { - return new Promise((resolve) => { - let timeoutHandle: NodeJS.Timeout; - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - socket.on('connect', () => { - clearTimeout(timeoutHandle); - connections[index] = socket; - - // Wait for server greeting - socket.on('data', (data) => { - if (data.toString().includes('220')) { - resolve({ index, success: true }); - } - }); - }); - - socket.on('error', (err) => { - clearTimeout(timeoutHandle); - resolve({ index, success: false, error: err.message }); - }); - - timeoutHandle = setTimeout(() => { - socket.destroy(); - resolve({ index, success: false, error: 'Connection timeout' }); - }, TEST_TIMEOUT); - } catch (err: any) { - resolve({ index, success: false, error: err.message }); - } - }); - }; - - // Create connections - for (let i = 0; i < testConnections; i++) { - connectionPromises.push(createConnectionWithIndex(i)); - } - - const results = await Promise.all(connectionPromises); - - // Count successful connections - const successfulConnections = results.filter(r => r.success).length; - const failedConnections = results.filter(r => !r.success).length; - - // Clean up connections - for (const socket of connections) { - if (socket && !socket.destroyed) { - socket.write('QUIT\r\n'); - setTimeout(() => socket.destroy(), 100); - } - } - - // Verify results - expect(successfulConnections).toBeGreaterThan(0); - - // If some connections were rejected, that's good (limit enforced) - // If all connections succeeded, that's also acceptable (high/no limit) - if (failedConnections > 0) { - console.log(`Server enforced connection limit: ${successfulConnections} accepted, ${failedConnections} rejected`); - } else { - console.log(`Server accepted all ${successfulConnections} connections`); - } - - done.resolve(); - - await done.promise; -}); - -// Test: Connection limit recovery -tap.test('Connection Limits - should accept new connections after closing old ones', async (tools) => { - const done = tools.defer(); - - const batchSize = 10; - const firstBatch: net.Socket[] = []; - const secondBatch: net.Socket[] = []; - - // Create first batch of connections - const firstBatchPromises = []; - for (let i = 0; i < batchSize; i++) { - firstBatchPromises.push( - new Promise((resolve) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - socket.on('connect', () => { - firstBatch.push(socket); - socket.on('data', (data) => { - if (data.toString().includes('220')) { - resolve(true); - } - }); - }); - - socket.on('error', () => resolve(false)); - }) - ); - } - - const firstResults = await Promise.all(firstBatchPromises); - const firstSuccessCount = firstResults.filter(r => r).length; - - // Close first batch - for (const socket of firstBatch) { - if (socket && !socket.destroyed) { - socket.write('QUIT\r\n'); - } - } - - // Wait for connections to close - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Destroy sockets - for (const socket of firstBatch) { - if (socket && !socket.destroyed) { - socket.destroy(); - } - } - - // Create second batch - const secondBatchPromises = []; - for (let i = 0; i < batchSize; i++) { - secondBatchPromises.push( - new Promise((resolve) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - socket.on('connect', () => { - secondBatch.push(socket); - socket.on('data', (data) => { - if (data.toString().includes('220')) { - resolve(true); - } - }); - }); - - socket.on('error', () => resolve(false)); - }) - ); - } - - const secondResults = await Promise.all(secondBatchPromises); - const secondSuccessCount = secondResults.filter(r => r).length; - - // Clean up second batch - for (const socket of secondBatch) { - if (socket && !socket.destroyed) { - socket.write('QUIT\r\n'); - setTimeout(() => socket.destroy(), 100); - } - } - - // Both batches should have successful connections - expect(firstSuccessCount).toBeGreaterThan(0); - expect(secondSuccessCount).toBeGreaterThan(0); - - done.resolve(); - - await done.promise; -}); - -// Test: Rapid connection attempts -tap.test('Connection Limits - should handle rapid connection attempts', async (tools) => { - const done = tools.defer(); - - const rapidConnections = 50; - const connections: net.Socket[] = []; - let successCount = 0; - let errorCount = 0; - - // Create connections as fast as possible - for (let i = 0; i < rapidConnections; i++) { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT - }); - - socket.on('connect', () => { - connections.push(socket); - successCount++; - }); - - socket.on('error', () => { - errorCount++; - }); - } - - // Wait for all connection attempts to settle - await new Promise(resolve => setTimeout(resolve, 3000)); - - // Clean up - for (const socket of connections) { - if (socket && !socket.destroyed) { - socket.destroy(); - } - } - - // Should handle at least some connections - expect(successCount).toBeGreaterThan(0); - console.log(`Rapid connections: ${successCount} succeeded, ${errorCount} failed`); - - done.resolve(); - - await done.promise; -}); - -// Test: Connection limit with different client IPs (simulated) -tap.test('Connection Limits - should track connections per IP or globally', async (tools) => { - const done = tools.defer(); - - // Note: In real test, this would use different source IPs - // For now, we test from same IP but document the behavior - const connectionsPerIP = 5; - const connections: net.Socket[] = []; - const results: boolean[] = []; - - for (let i = 0; i < connectionsPerIP; i++) { - const result = await new Promise((resolve) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - socket.on('connect', () => { - connections.push(socket); - socket.on('data', (data) => { - if (data.toString().includes('220')) { - resolve(true); - } - }); - }); - - socket.on('error', () => resolve(false)); - }); - - results.push(result); - } - - const successCount = results.filter(r => r).length; - - // Clean up - for (const socket of connections) { - if (socket && !socket.destroyed) { - socket.write('QUIT\r\n'); - setTimeout(() => socket.destroy(), 100); - } - } - - // Should accept connections from same IP - expect(successCount).toBeGreaterThan(0); - console.log(`Per-IP connections: ${successCount} of ${connectionsPerIP} succeeded`); - - done.resolve(); - - await done.promise; -}); - -// Test: Connection limit error messages -tap.test('Connection Limits - should provide meaningful error when limit reached', async (tools) => { - const done = tools.defer(); - - const manyConnections = 100; - const connections: net.Socket[] = []; - const errors: string[] = []; - let rejected = false; - - // Create many connections to try to hit limit - const promises = []; - for (let i = 0; i < manyConnections; i++) { - promises.push( - new Promise((resolve) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 1000 - }); - - socket.on('connect', () => { - connections.push(socket); - - socket.on('data', (data) => { - const response = data.toString(); - // Check if server sends connection limit message - if (response.includes('421') || response.includes('too many connections')) { - rejected = true; - errors.push(response); - } - resolve(); - }); - }); - - socket.on('error', (err) => { - if (err.message.includes('ECONNREFUSED') || err.message.includes('ECONNRESET')) { - rejected = true; - errors.push(err.message); - } - resolve(); - }); - - socket.on('timeout', () => { - resolve(); - }); - }) - ); - } - - await Promise.all(promises); - - // Clean up - for (const socket of connections) { - if (socket && !socket.destroyed) { - socket.destroy(); - } - } - - // Log results - console.log(`Connection limit test: ${connections.length} connected, ${errors.length} rejected`); - if (rejected) { - console.log(`Sample rejection: ${errors[0]}`); - } - - // Should have handled connections (either accepted or properly rejected) - expect(connections.length + errors.length).toBeGreaterThan(0); - - done.resolve(); - - await done.promise; -}); - -// Teardown -tap.test('teardown - stop SMTP server', async () => { - await stopTestServer(testServer); -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_connection/test.cm-05.connection-rejection.ts b/test/suite/smtpserver_connection/test.cm-05.connection-rejection.ts deleted file mode 100644 index 25c4038..0000000 --- a/test/suite/smtpserver_connection/test.cm-05.connection-rejection.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; -const TEST_PORT = 2525; -const TEST_TIMEOUT = 30000; - -let testServer: ITestServer; - -tap.test('setup - start SMTP server for connection rejection tests', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 1000)); -}); - -tap.test('Connection Rejection - should handle suspicious domains', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - const banner = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(banner).toInclude('220'); - - // Send EHLO with suspicious domain - socket.write('EHLO blocked.spammer.com\r\n'); - - const response = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n')) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - - // Timeout after 5 seconds - setTimeout(() => { - socket.removeListener('data', handler); - resolve(data || 'TIMEOUT'); - }, 5000); - }); - - console.log('Response to suspicious domain:', response); - - // Server might reject with 421, 550, or accept (depends on configuration) - // We just verify it responds appropriately - const validResponses = ['250', '421', '550', '501']; - const hasValidResponse = validResponses.some(code => response.includes(code)); - expect(hasValidResponse).toEqual(true); - - // Clean up - if (!socket.destroyed) { - socket.write('QUIT\r\n'); - socket.end(); - } - - } finally { - done.resolve(); - } -}); - -tap.test('Connection Rejection - should handle overload conditions', async (tools) => { - const done = tools.defer(); - - const connections: net.Socket[] = []; - - try { - // Create many connections rapidly - const rapidConnectionCount = 20; // Reduced from 50 to be more reasonable - const connectionPromises: Promise[] = []; - - for (let i = 0; i < rapidConnectionCount; i++) { - connectionPromises.push( - new Promise((resolve) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT - }); - - socket.on('connect', () => { - connections.push(socket); - resolve(socket); - }); - - socket.on('error', () => { - // Connection rejected - this is OK during overload - resolve(null); - }); - - // Timeout individual connections - setTimeout(() => resolve(null), 2000); - }) - ); - } - - // Wait for all connection attempts - const results = await Promise.all(connectionPromises); - const successfulConnections = results.filter(r => r !== null).length; - - console.log(`Created ${successfulConnections}/${rapidConnectionCount} connections`); - - // Now try one more connection - let overloadRejected = false; - try { - const testSocket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 5000 - }); - - await new Promise((resolve, reject) => { - testSocket.once('connect', () => { - testSocket.end(); - resolve(); - }); - testSocket.once('error', (err) => { - overloadRejected = true; - reject(err); - }); - - setTimeout(() => { - testSocket.destroy(); - resolve(); - }, 5000); - }); - } catch (error) { - console.log('Additional connection was rejected:', error); - overloadRejected = true; - } - - console.log(`Overload test results: - - Successful connections: ${successfulConnections} - - Additional connection rejected: ${overloadRejected} - - Server behavior: ${overloadRejected ? 'Properly rejected under load' : 'Accepted all connections'}`); - - // Either behavior is acceptable - rejection shows overload protection, - // acceptance shows high capacity - expect(true).toEqual(true); - - } finally { - // Clean up all connections - for (const socket of connections) { - try { - if (!socket.destroyed) { - socket.end(); - } - } catch (e) { - // Ignore cleanup errors - } - } - - done.resolve(); - } -}); - -tap.test('Connection Rejection - should reject invalid protocol', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner first - const banner = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Got banner:', banner); - - // Send HTTP request instead of SMTP - socket.write('GET / HTTP/1.1\r\nHost: example.com\r\n\r\n'); - - const response = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - }; - socket.on('data', handler); - - // Wait for response or connection close - socket.on('close', () => { - socket.removeListener('data', handler); - resolve(data); - }); - - // Timeout - setTimeout(() => { - socket.removeListener('data', handler); - socket.destroy(); - resolve(data || 'CLOSED_WITHOUT_RESPONSE'); - }, 3000); - }); - - console.log('Response to HTTP request:', response); - - // Server should either: - // - Send error response (4xx or 5xx) - // - Close connection immediately - // - Send nothing and close - // Note: Server may return 451 if there's an internal error (e.g., rateLimiter not available) - const errorResponses = ['500', '501', '502', '421', '451']; - const hasErrorResponse = errorResponses.some(code => response.includes(code)); - const closedWithoutResponse = response === 'CLOSED_WITHOUT_RESPONSE' || response === ''; - - expect(hasErrorResponse || closedWithoutResponse).toEqual(true); - - if (hasErrorResponse) { - console.log('Server properly rejected with error response'); - } else if (closedWithoutResponse) { - console.log('Server closed connection without response (also valid)'); - } - - } finally { - done.resolve(); - } -}); - -tap.test('Connection Rejection - should handle invalid commands gracefully', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send completely invalid command - socket.write('INVALID_COMMAND_12345\r\n'); - - const response = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Response to invalid command:', response); - - // Should get 4xx or 5xx error response - // Note: Server may return 451 if there's an internal error (e.g., rateLimiter not available) - expect(response).toMatch(/^[45]\d{2}/); - - // Server should still be responsive - socket.write('NOOP\r\n'); - - const noopResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('NOOP response after error:', noopResponse); - expect(noopResponse).toInclude('250'); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); - expect(true).toEqual(true); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_connection/test.cm-06.starttls-upgrade.ts b/test/suite/smtpserver_connection/test.cm-06.starttls-upgrade.ts deleted file mode 100644 index 5d7a993..0000000 --- a/test/suite/smtpserver_connection/test.cm-06.starttls-upgrade.ts +++ /dev/null @@ -1,468 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as tls from 'tls'; -import * as path from 'path'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; - -// Test configuration -const TEST_PORT = 2525; -const TEST_TIMEOUT = 30000; // Increased timeout for TLS handshake - -let testServer: ITestServer; - -// Setup -tap.test('setup - start SMTP server with STARTTLS support', async () => { - testServer = await startTestServer({ - port: TEST_PORT, - tlsEnabled: true // Enable TLS to advertise STARTTLS - }); - - - await new Promise(resolve => setTimeout(resolve, 1000)); - expect(testServer.port).toEqual(TEST_PORT); -}); - -// Test: Basic STARTTLS upgrade -tap.test('STARTTLS - should upgrade plain connection to TLS', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let tlsSocket: tls.TLSSocket | null = null; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - // Check if STARTTLS is advertised - if (receivedData.includes('STARTTLS')) { - currentStep = 'starttls'; - socket.write('STARTTLS\r\n'); - } else { - socket.destroy(); - done.reject(new Error('STARTTLS not advertised in EHLO response')); - } - } else if (currentStep === 'starttls' && receivedData.includes('220')) { - // Server accepted STARTTLS - upgrade to TLS - currentStep = 'tls_handshake'; - - const tlsOptions: tls.ConnectionOptions = { - socket: socket, - servername: 'localhost', - rejectUnauthorized: false // Accept self-signed certificates for testing - }; - - tlsSocket = tls.connect(tlsOptions); - - tlsSocket.on('secureConnect', () => { - // TLS handshake successful - currentStep = 'tls_ehlo'; - tlsSocket!.write('EHLO test.example.com\r\n'); - }); - - tlsSocket.on('data', (tlsData) => { - const tlsResponse = tlsData.toString(); - - if (currentStep === 'tls_ehlo' && tlsResponse.includes('250')) { - tlsSocket!.write('QUIT\r\n'); - setTimeout(() => { - tlsSocket!.destroy(); - expect(tlsSocket!.encrypted).toEqual(true); - done.resolve(); - }, 100); - } - }); - - tlsSocket.on('error', (error) => { - done.reject(error); - }); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - if (tlsSocket) tlsSocket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: STARTTLS with commands after upgrade -tap.test('STARTTLS - should process SMTP commands after TLS upgrade', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let tlsSocket: tls.TLSSocket | null = null; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - if (receivedData.includes('STARTTLS')) { - currentStep = 'starttls'; - socket.write('STARTTLS\r\n'); - } - } else if (currentStep === 'starttls' && receivedData.includes('220')) { - currentStep = 'tls_handshake'; - - tlsSocket = tls.connect({ - socket: socket, - servername: 'localhost', - rejectUnauthorized: false - }); - - tlsSocket.on('secureConnect', () => { - currentStep = 'tls_ehlo'; - tlsSocket!.write('EHLO test.example.com\r\n'); - }); - - tlsSocket.on('data', (tlsData) => { - const tlsResponse = tlsData.toString(); - - if (currentStep === 'tls_ehlo' && tlsResponse.includes('250')) { - currentStep = 'tls_mail_from'; - tlsSocket!.write('MAIL FROM:\r\n'); - } else if (currentStep === 'tls_mail_from' && tlsResponse.includes('250')) { - currentStep = 'tls_rcpt_to'; - tlsSocket!.write('RCPT TO:\r\n'); - } else if (currentStep === 'tls_rcpt_to' && tlsResponse.includes('250')) { - currentStep = 'tls_data'; - tlsSocket!.write('DATA\r\n'); - } else if (currentStep === 'tls_data' && tlsResponse.includes('354')) { - currentStep = 'tls_message'; - tlsSocket!.write('Subject: Test over TLS\r\n\r\nSecure message\r\n.\r\n'); - } else if (currentStep === 'tls_message' && tlsResponse.includes('250')) { - tlsSocket!.write('QUIT\r\n'); - setTimeout(() => { - const protocol = tlsSocket!.getProtocol(); - const cipher = tlsSocket!.getCipher(); - tlsSocket!.destroy(); - // Protocol and cipher might be null in some cases - if (protocol) { - expect(typeof protocol).toEqual('string'); - } - if (cipher) { - expect(cipher).toBeDefined(); - expect(cipher.name).toBeDefined(); - } - done.resolve(); - }, 100); - } - }); - - tlsSocket.on('error', (error) => { - done.reject(error); - }); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - if (tlsSocket) tlsSocket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: STARTTLS rejected after MAIL FROM -tap.test('STARTTLS - should reject STARTTLS after transaction started', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'starttls_after_mail'; - socket.write('STARTTLS\r\n'); - } else if (currentStep === 'starttls_after_mail') { - if (receivedData.includes('503')) { - // Server correctly rejected STARTTLS after MAIL FROM - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('503'); // Bad sequence - done.resolve(); - }, 100); - } else if (receivedData.includes('220')) { - // Server incorrectly accepted STARTTLS - this is a bug - // For now, let's accept this behavior but log it - console.log('WARNING: Server accepted STARTTLS after MAIL FROM - this violates RFC 3207'); - socket.destroy(); - done.resolve(); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Multiple STARTTLS attempts -tap.test('STARTTLS - should not allow STARTTLS after TLS is established', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let tlsSocket: tls.TLSSocket | null = null; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - if (receivedData.includes('STARTTLS')) { - currentStep = 'starttls'; - socket.write('STARTTLS\r\n'); - } - } else if (currentStep === 'starttls' && receivedData.includes('220')) { - currentStep = 'tls_handshake'; - - tlsSocket = tls.connect({ - socket: socket, - servername: 'localhost', - rejectUnauthorized: false - }); - - tlsSocket.on('secureConnect', () => { - currentStep = 'tls_ehlo'; - tlsSocket!.write('EHLO test.example.com\r\n'); - }); - - tlsSocket.on('data', (tlsData) => { - const tlsResponse = tlsData.toString(); - - if (currentStep === 'tls_ehlo') { - // Check that STARTTLS is NOT advertised after TLS upgrade - expect(tlsResponse).not.toInclude('STARTTLS'); - tlsSocket!.write('QUIT\r\n'); - setTimeout(() => { - tlsSocket!.destroy(); - done.resolve(); - }, 100); - } - }); - - tlsSocket.on('error', (error) => { - done.reject(error); - }); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - if (tlsSocket) tlsSocket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: STARTTLS with invalid command -tap.test('STARTTLS - should handle commands during TLS negotiation', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - if (receivedData.includes('STARTTLS')) { - currentStep = 'starttls'; - socket.write('STARTTLS\r\n'); - } - } else if (currentStep === 'starttls' && receivedData.includes('220')) { - // Send invalid data instead of starting TLS handshake - currentStep = 'invalid_after_starttls'; - socket.write('EHLO should.not.work\r\n'); - - setTimeout(() => { - socket.destroy(); - done.resolve(); // Connection should close or timeout - }, 2000); - } - }); - - socket.on('close', () => { - if (currentStep === 'invalid_after_starttls') { - done.resolve(); - } - }); - - socket.on('error', (error) => { - if (currentStep === 'invalid_after_starttls') { - done.resolve(); // Expected error - } else { - done.reject(error); - } - }); - - socket.on('timeout', () => { - socket.destroy(); - if (currentStep === 'invalid_after_starttls') { - done.resolve(); - } else { - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - } - }); - - await done.promise; -}); - -// Test: STARTTLS TLS version and cipher info -tap.test('STARTTLS - should use secure TLS version and ciphers', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let tlsSocket: tls.TLSSocket | null = null; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - if (receivedData.includes('STARTTLS')) { - currentStep = 'starttls'; - socket.write('STARTTLS\r\n'); - } - } else if (currentStep === 'starttls' && receivedData.includes('220')) { - currentStep = 'tls_handshake'; - - tlsSocket = tls.connect({ - socket: socket, - servername: 'localhost', - rejectUnauthorized: false, - minVersion: 'TLSv1.2' // Require at least TLS 1.2 - }); - - tlsSocket.on('secureConnect', () => { - const protocol = tlsSocket!.getProtocol(); - const cipher = tlsSocket!.getCipher(); - - // Verify TLS version - expect(typeof protocol).toEqual('string'); - expect(['TLSv1.2', 'TLSv1.3']).toInclude(protocol!); - - // Verify cipher info - expect(cipher).toBeDefined(); - expect(cipher.name).toBeDefined(); - expect(typeof cipher.name).toEqual('string'); - - tlsSocket!.write('QUIT\r\n'); - setTimeout(() => { - tlsSocket!.destroy(); - done.resolve(); - }, 100); - }); - - tlsSocket.on('error', (error) => { - done.reject(error); - }); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - if (tlsSocket) tlsSocket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('teardown - stop SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_connection/test.cm-07.abrupt-disconnection.ts b/test/suite/smtpserver_connection/test.cm-07.abrupt-disconnection.ts deleted file mode 100644 index d247047..0000000 --- a/test/suite/smtpserver_connection/test.cm-07.abrupt-disconnection.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; -const TEST_PORT = 2525; -const TEST_TIMEOUT = 30000; - -let testServer: ITestServer; - -tap.test('setup - start SMTP server for abrupt disconnection tests', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 1000)); -}); - -tap.test('Abrupt Disconnection - should handle socket destruction without QUIT', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - const banner = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(banner).toInclude('220'); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Abruptly disconnect without QUIT - console.log('Destroying socket without QUIT...'); - socket.destroy(); - - // Wait a moment for server to handle the disconnection - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Test server recovery - try new connection - console.log('Testing server recovery with new connection...'); - const recoverySocket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - const recoveryConnected = await new Promise((resolve) => { - recoverySocket.once('connect', () => resolve(true)); - recoverySocket.once('error', () => resolve(false)); - setTimeout(() => resolve(false), 5000); - }); - - expect(recoveryConnected).toEqual(true); - - if (recoveryConnected) { - // Get banner from recovery connection - const recoveryBanner = await new Promise((resolve) => { - recoverySocket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(recoveryBanner).toInclude('220'); - console.log('Server recovered successfully, accepting new connections'); - - // Clean up recovery connection properly - recoverySocket.write('QUIT\r\n'); - recoverySocket.end(); - } - - } finally { - done.resolve(); - } -}); - -tap.test('Abrupt Disconnection - should handle multiple simultaneous abrupt disconnections', async (tools) => { - const done = tools.defer(); - - try { - const connections = 5; - const sockets: net.Socket[] = []; - - // Create multiple connections - for (let i = 0; i < connections; i++) { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - sockets.push(socket); - } - - console.log(`Created ${connections} connections`); - - // Abruptly disconnect all at once - console.log('Destroying all sockets simultaneously...'); - sockets.forEach(socket => socket.destroy()); - - // Wait for server to handle disconnections - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Test that server still accepts new connections - console.log('Testing server stability after multiple abrupt disconnections...'); - const testSocket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - const stillAccepting = await new Promise((resolve) => { - testSocket.once('connect', () => resolve(true)); - testSocket.once('error', () => resolve(false)); - setTimeout(() => resolve(false), 5000); - }); - - expect(stillAccepting).toEqual(true); - - if (stillAccepting) { - const banner = await new Promise((resolve) => { - testSocket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(banner).toInclude('220'); - console.log('Server remained stable after multiple abrupt disconnections'); - - testSocket.write('QUIT\r\n'); - testSocket.end(); - } - - } finally { - done.resolve(); - } -}); - -tap.test('Abrupt Disconnection - should handle disconnection during DATA transfer', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Start DATA - socket.write('DATA\r\n'); - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(dataResponse).toInclude('354'); - - // Send partial email data then disconnect abruptly - socket.write('From: sender@example.com\r\n'); - socket.write('To: recipient@example.com\r\n'); - socket.write('Subject: Test '); - - console.log('Disconnecting during DATA transfer...'); - socket.destroy(); - - // Wait for server to handle disconnection - await new Promise(resolve => setTimeout(resolve, 1500)); - - // Verify server can handle new connections - const newSocket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - const canConnect = await new Promise((resolve) => { - newSocket.once('connect', () => resolve(true)); - newSocket.once('error', () => resolve(false)); - setTimeout(() => resolve(false), 5000); - }); - - expect(canConnect).toEqual(true); - - if (canConnect) { - const banner = await new Promise((resolve) => { - newSocket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(banner).toInclude('220'); - console.log('Server recovered from disconnection during DATA transfer'); - - newSocket.write('QUIT\r\n'); - newSocket.end(); - } - - } finally { - done.resolve(); - } -}); - -tap.test('Abrupt Disconnection - should timeout idle connections', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - const banner = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(banner).toInclude('220'); - console.log('Connected, now testing idle timeout...'); - - // Don't send any commands and wait for server to potentially timeout - // Most servers have a timeout of 5-10 minutes, so we'll test shorter - let disconnectedByServer = false; - - socket.on('close', () => { - disconnectedByServer = true; - }); - - socket.on('end', () => { - disconnectedByServer = true; - }); - - // Wait 10 seconds to see if server has a short idle timeout - await new Promise(resolve => setTimeout(resolve, 10000)); - - if (!disconnectedByServer) { - console.log('Server maintains idle connections (no short timeout detected)'); - // Send QUIT to close gracefully - socket.write('QUIT\r\n'); - socket.end(); - } else { - console.log('Server disconnected idle connection'); - } - - // Either behavior is acceptable - expect(true).toEqual(true); - - } finally { - done.resolve(); - } -}); - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); - expect(true).toEqual(true); -}); - -export default tap.start(); diff --git a/test/suite/smtpserver_connection/test.cm-08.tls-versions.ts b/test/suite/smtpserver_connection/test.cm-08.tls-versions.ts deleted file mode 100644 index 0971bc6..0000000 --- a/test/suite/smtpserver_connection/test.cm-08.tls-versions.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as tls from 'tls'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -const TEST_PORT = 2525; -const TEST_TIMEOUT = 30000; - -let testServer: ITestServer; - -tap.test('setup - start SMTP server with TLS support for version tests', async () => { - testServer = await startTestServer({ - port: TEST_PORT, - tlsEnabled: true - }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - expect(testServer).toBeDefined(); -}); - -tap.test('TLS Versions - should support STARTTLS capability', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - const ehloResponse = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - console.log('EHLO response:', ehloResponse); - - // Check for STARTTLS support - const supportsStarttls = ehloResponse.includes('250-STARTTLS') || ehloResponse.includes('250 STARTTLS'); - console.log('STARTTLS supported:', supportsStarttls); - - if (supportsStarttls) { - // Test STARTTLS upgrade - socket.write('STARTTLS\r\n'); - - const starttlsResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(starttlsResponse).toInclude('220'); - console.log('STARTTLS ready response received'); - - // Would upgrade to TLS here in a real implementation - // For testing, we just verify the capability - } - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - // STARTTLS is optional but common - expect(true).toEqual(true); - - } finally { - done.resolve(); - } -}); - -tap.test('TLS Versions - should support modern TLS versions via STARTTLS', async (tools) => { - const done = tools.defer(); - - try { - // Test TLS 1.2 via STARTTLS - console.log('Testing TLS 1.2 support via STARTTLS...'); - const tls12Result = await testTlsVersionViaStartTls('TLSv1.2', TEST_PORT); - console.log('TLS 1.2 result:', tls12Result); - - // Test TLS 1.3 via STARTTLS - console.log('Testing TLS 1.3 support via STARTTLS...'); - const tls13Result = await testTlsVersionViaStartTls('TLSv1.3', TEST_PORT); - console.log('TLS 1.3 result:', tls13Result); - - // At least one modern version should be supported - const supportsModernTls = tls12Result.success || tls13Result.success; - expect(supportsModernTls).toEqual(true); - - if (tls12Result.success) { - console.log('TLS 1.2 supported with cipher:', tls12Result.cipher); - } - if (tls13Result.success) { - console.log('TLS 1.3 supported with cipher:', tls13Result.cipher); - } - - } finally { - done.resolve(); - } -}); - -tap.test('TLS Versions - should reject obsolete TLS versions via STARTTLS', async (tools) => { - const done = tools.defer(); - - try { - // Test TLS 1.0 (should be rejected by modern servers) - console.log('Testing TLS 1.0 (obsolete) via STARTTLS...'); - const tls10Result = await testTlsVersionViaStartTls('TLSv1', TEST_PORT); - - // Test TLS 1.1 (should be rejected by modern servers) - console.log('Testing TLS 1.1 (obsolete) via STARTTLS...'); - const tls11Result = await testTlsVersionViaStartTls('TLSv1.1', TEST_PORT); - - // Modern servers should reject these old versions - // But some might still support them for compatibility - console.log(`TLS 1.0 ${tls10Result.success ? 'accepted (legacy support)' : 'rejected (good)'}`); - console.log(`TLS 1.1 ${tls11Result.success ? 'accepted (legacy support)' : 'rejected (good)'}`); - - // Either behavior is acceptable - log the results - expect(true).toEqual(true); - - } finally { - done.resolve(); - } -}); - -tap.test('TLS Versions - should provide cipher information via STARTTLS', async (tools) => { - const done = tools.defer(); - - try { - // Connect to plain SMTP port - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - const ehloResponse = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Check for STARTTLS - if (!ehloResponse.includes('STARTTLS')) { - console.log('Server does not support STARTTLS - skipping cipher info test'); - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - return; - } - - // Send STARTTLS - socket.write('STARTTLS\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Upgrade to TLS - const tlsSocket = tls.connect({ - socket: socket, - servername: 'localhost', - rejectUnauthorized: false - }); - - await new Promise((resolve, reject) => { - tlsSocket.once('secureConnect', () => resolve()); - tlsSocket.once('error', reject); - }); - - // Get connection details - const cipher = tlsSocket.getCipher(); - const protocol = tlsSocket.getProtocol(); - const authorized = tlsSocket.authorized; - - console.log('TLS connection established via STARTTLS:'); - console.log('- Protocol:', protocol); - console.log('- Cipher:', cipher?.name); - console.log('- Key exchange:', cipher?.standardName); - console.log('- Authorized:', authorized); - - if (protocol) { - expect(typeof protocol).toEqual('string'); - } - if (cipher) { - expect(cipher.name).toBeDefined(); - } - - // Clean up - tlsSocket.write('QUIT\r\n'); - tlsSocket.end(); - - } finally { - done.resolve(); - } -}); - -// Helper function to test specific TLS version via STARTTLS -async function testTlsVersionViaStartTls(version: string, port: number): Promise<{success: boolean, cipher?: any, error?: string}> { - return new Promise(async (resolve) => { - try { - // Connect to plain SMTP port - const socket = net.createConnection({ - host: 'localhost', - port: port, - timeout: 5000 - }); - - await new Promise((socketResolve, socketReject) => { - socket.once('connect', () => socketResolve()); - socket.once('error', socketReject); - }); - - // Get banner - await new Promise((bannerResolve) => { - socket.once('data', (chunk) => bannerResolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - const ehloResponse = await new Promise((ehloResolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - ehloResolve(data); - } - }; - socket.on('data', handler); - }); - - // Check for STARTTLS - if (!ehloResponse.includes('STARTTLS')) { - socket.destroy(); - resolve({ - success: false, - error: 'STARTTLS not supported' - }); - return; - } - - // Send STARTTLS - socket.write('STARTTLS\r\n'); - - await new Promise((starttlsResolve) => { - socket.once('data', (chunk) => starttlsResolve(chunk.toString())); - }); - - // Set up TLS options with version constraints - const tlsOptions: any = { - socket: socket, - servername: 'localhost', - rejectUnauthorized: false - }; - - // Set version constraints based on requested version - switch (version) { - case 'TLSv1': - tlsOptions.minVersion = 'TLSv1'; - tlsOptions.maxVersion = 'TLSv1'; - break; - case 'TLSv1.1': - tlsOptions.minVersion = 'TLSv1.1'; - tlsOptions.maxVersion = 'TLSv1.1'; - break; - case 'TLSv1.2': - tlsOptions.minVersion = 'TLSv1.2'; - tlsOptions.maxVersion = 'TLSv1.2'; - break; - case 'TLSv1.3': - tlsOptions.minVersion = 'TLSv1.3'; - tlsOptions.maxVersion = 'TLSv1.3'; - break; - } - - // Upgrade to TLS - const tlsSocket = tls.connect(tlsOptions); - - tlsSocket.once('secureConnect', () => { - const cipher = tlsSocket.getCipher(); - const protocol = tlsSocket.getProtocol(); - - tlsSocket.destroy(); - resolve({ - success: true, - cipher: { - name: cipher?.name, - standardName: cipher?.standardName, - protocol: protocol - } - }); - }); - - tlsSocket.once('error', (error) => { - resolve({ - success: false, - error: error.message - }); - }); - - setTimeout(() => { - tlsSocket.destroy(); - resolve({ - success: false, - error: 'TLS handshake timeout' - }); - }, 5000); - - } catch (error) { - resolve({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - }); -} - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); - expect(true).toEqual(true); -}); - -export default tap.start(); diff --git a/test/suite/smtpserver_connection/test.cm-09.tls-ciphers.ts b/test/suite/smtpserver_connection/test.cm-09.tls-ciphers.ts deleted file mode 100644 index cebf9fc..0000000 --- a/test/suite/smtpserver_connection/test.cm-09.tls-ciphers.ts +++ /dev/null @@ -1,556 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as tls from 'tls'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer: ITestServer; -const TEST_TIMEOUT = 30000; - -tap.test('TLS Ciphers - should advertise STARTTLS for cipher negotiation', async (tools) => { - const done = tools.defer(); - - // Start test server - testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - const ehloResponse = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Check for STARTTLS support - const supportsStarttls = ehloResponse.includes('250-STARTTLS') || ehloResponse.includes('250 STARTTLS'); - console.log('STARTTLS supported:', supportsStarttls); - - if (supportsStarttls) { - console.log('Server supports STARTTLS - cipher negotiation available'); - } else { - console.log('Server does not advertise STARTTLS - direct TLS connections may be required'); - } - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - // Either behavior is acceptable - expect(true).toEqual(true); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('TLS Ciphers - should negotiate secure cipher suites via STARTTLS', async (tools) => { - const done = tools.defer(); - - // Start test server - testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - const ehloResponse = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Check for STARTTLS - if (!ehloResponse.includes('STARTTLS')) { - console.log('Server does not support STARTTLS - skipping cipher test'); - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - return; - } - - // Send STARTTLS - socket.write('STARTTLS\r\n'); - - const starttlsResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(starttlsResponse).toInclude('220'); - - // Upgrade to TLS - const tlsSocket = tls.connect({ - socket: socket, - servername: 'localhost', - rejectUnauthorized: false - }); - - await new Promise((resolve, reject) => { - tlsSocket.once('secureConnect', () => resolve()); - tlsSocket.once('error', reject); - }); - - // Get cipher information - const cipher = tlsSocket.getCipher(); - console.log('Negotiated cipher suite:'); - console.log('- Name:', cipher.name); - console.log('- Standard name:', cipher.standardName); - console.log('- Version:', cipher.version); - - // Check cipher security - const cipherSecurity = checkCipherSecurity(cipher); - console.log('Cipher security analysis:', cipherSecurity); - - expect(cipher.name).toBeDefined(); - expect(cipherSecurity.secure).toEqual(true); - - // Clean up - tlsSocket.write('QUIT\r\n'); - tlsSocket.end(); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('TLS Ciphers - should reject weak cipher suites', async (tools) => { - const done = tools.defer(); - - // Start test server - testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - const ehloResponse = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Check for STARTTLS - if (!ehloResponse.includes('STARTTLS')) { - console.log('Server does not support STARTTLS - skipping weak cipher test'); - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - return; - } - - // Send STARTTLS - socket.write('STARTTLS\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Try to connect with weak ciphers only - const weakCiphers = [ - 'DES-CBC3-SHA', - 'RC4-MD5', - 'RC4-SHA', - 'NULL-SHA', - 'EXPORT-DES40-CBC-SHA' - ]; - - console.log('Testing connection with weak ciphers only...'); - - const connectionResult = await new Promise<{success: boolean, error?: string}>((resolve) => { - const tlsSocket = tls.connect({ - socket: socket, - servername: 'localhost', - rejectUnauthorized: false, - ciphers: weakCiphers.join(':') - }); - - tlsSocket.once('secureConnect', () => { - // If connection succeeds, server accepts weak ciphers - const cipher = tlsSocket.getCipher(); - tlsSocket.destroy(); - resolve({ - success: true, - error: `Server accepted weak cipher: ${cipher.name}` - }); - }); - - tlsSocket.once('error', (err) => { - // Connection failed - good, server rejects weak ciphers - resolve({ - success: false, - error: err.message - }); - }); - - setTimeout(() => { - tlsSocket.destroy(); - resolve({ - success: false, - error: 'Connection timeout' - }); - }, 5000); - }); - - if (!connectionResult.success) { - console.log('Good: Server rejected weak ciphers'); - } else { - console.log('Warning:', connectionResult.error); - } - - // Either behavior is logged - some servers may support legacy ciphers - expect(true).toEqual(true); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('TLS Ciphers - should support forward secrecy', async (tools) => { - const done = tools.defer(); - - // Start test server - testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - const ehloResponse = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Check for STARTTLS - if (!ehloResponse.includes('STARTTLS')) { - console.log('Server does not support STARTTLS - skipping forward secrecy test'); - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - return; - } - - // Send STARTTLS - socket.write('STARTTLS\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Prefer ciphers with forward secrecy (ECDHE, DHE) - const forwardSecrecyCiphers = [ - 'ECDHE-RSA-AES128-GCM-SHA256', - 'ECDHE-RSA-AES256-GCM-SHA384', - 'ECDHE-ECDSA-AES128-GCM-SHA256', - 'ECDHE-ECDSA-AES256-GCM-SHA384', - 'DHE-RSA-AES128-GCM-SHA256', - 'DHE-RSA-AES256-GCM-SHA384' - ]; - - const tlsSocket = tls.connect({ - socket: socket, - servername: 'localhost', - rejectUnauthorized: false, - ciphers: forwardSecrecyCiphers.join(':') - }); - - await new Promise((resolve, reject) => { - tlsSocket.once('secureConnect', () => resolve()); - tlsSocket.once('error', reject); - setTimeout(() => reject(new Error('TLS connection timeout')), 5000); - }); - - const cipher = tlsSocket.getCipher(); - console.log('Forward secrecy cipher negotiated:', cipher.name); - - // Check if cipher provides forward secrecy - const hasForwardSecrecy = cipher.name.includes('ECDHE') || cipher.name.includes('DHE'); - console.log('Forward secrecy:', hasForwardSecrecy ? 'YES' : 'NO'); - - if (hasForwardSecrecy) { - console.log('Good: Server supports forward secrecy'); - } else { - console.log('Warning: Negotiated cipher does not provide forward secrecy'); - } - - // Clean up - tlsSocket.write('QUIT\r\n'); - tlsSocket.end(); - - // Forward secrecy is recommended but not required - expect(true).toEqual(true); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('TLS Ciphers - should list all supported ciphers', async (tools) => { - const done = tools.defer(); - - // Start test server - testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - try { - // Get list of ciphers supported by Node.js - const supportedCiphers = tls.getCiphers(); - console.log(`Node.js supports ${supportedCiphers.length} cipher suites`); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - const ehloResponse = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Check for STARTTLS - if (!ehloResponse.includes('STARTTLS')) { - console.log('Server does not support STARTTLS - skipping cipher list test'); - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - return; - } - - // Send STARTTLS - socket.write('STARTTLS\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Test connection with default ciphers - const tlsSocket = tls.connect({ - socket: socket, - servername: 'localhost', - rejectUnauthorized: false - }); - - await new Promise((resolve, reject) => { - tlsSocket.once('secureConnect', () => resolve()); - tlsSocket.once('error', reject); - setTimeout(() => reject(new Error('TLS connection timeout')), 5000); - }); - - const negotiatedCipher = tlsSocket.getCipher(); - console.log('\nServer selected cipher:', negotiatedCipher.name); - - // Categorize the cipher - const categories = { - 'AEAD': negotiatedCipher.name.includes('GCM') || negotiatedCipher.name.includes('CCM') || negotiatedCipher.name.includes('POLY1305'), - 'Forward Secrecy': negotiatedCipher.name.includes('ECDHE') || negotiatedCipher.name.includes('DHE'), - 'Strong Encryption': negotiatedCipher.name.includes('AES') && (negotiatedCipher.name.includes('128') || negotiatedCipher.name.includes('256')) - }; - - console.log('Cipher properties:'); - Object.entries(categories).forEach(([property, value]) => { - console.log(`- ${property}: ${value ? 'YES' : 'NO'}`); - }); - - // Clean up - tlsSocket.end(); - - expect(negotiatedCipher.name).toBeDefined(); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -// Helper function to check cipher security -function checkCipherSecurity(cipher: any): {secure: boolean, reason?: string, recommendations?: string[]} { - if (!cipher || !cipher.name) { - return { - secure: false, - reason: 'No cipher information available' - }; - } - - const cipherName = cipher.name.toUpperCase(); - const recommendations: string[] = []; - - // Check for insecure ciphers - const insecureCiphers = ['NULL', 'EXPORT', 'DES', '3DES', 'RC4', 'MD5']; - - for (const insecure of insecureCiphers) { - if (cipherName.includes(insecure)) { - return { - secure: false, - reason: `Insecure cipher detected: ${insecure} in ${cipherName}`, - recommendations: ['Use AEAD ciphers like AES-GCM or ChaCha20-Poly1305'] - }; - } - } - - // Check for recommended secure ciphers - const secureCiphers = [ - 'AES128-GCM', 'AES256-GCM', 'CHACHA20-POLY1305', - 'AES128-CCM', 'AES256-CCM' - ]; - - const hasSecureCipher = secureCiphers.some(secure => - cipherName.includes(secure.replace('-', '_')) || cipherName.includes(secure) - ); - - if (hasSecureCipher) { - return { - secure: true, - recommendations: ['Cipher suite is considered secure'] - }; - } - - // Check for acceptable but not ideal ciphers - if (cipherName.includes('AES') && !cipherName.includes('CBC')) { - return { - secure: true, - recommendations: ['Consider upgrading to AEAD ciphers for better security'] - }; - } - - // Check for weak but sometimes acceptable ciphers - if (cipherName.includes('AES') && cipherName.includes('CBC')) { - recommendations.push('CBC mode ciphers are vulnerable to padding oracle attacks'); - recommendations.push('Consider upgrading to GCM or other AEAD modes'); - return { - secure: true, // Still acceptable but not ideal - recommendations: recommendations - }; - } - - // Default to secure if it's a modern cipher we don't recognize - return { - secure: true, - recommendations: [`Unknown cipher ${cipherName} - verify security manually`] - }; -} - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_connection/test.cm-10.plain-connection.ts b/test/suite/smtpserver_connection/test.cm-10.plain-connection.ts deleted file mode 100644 index 611213c..0000000 --- a/test/suite/smtpserver_connection/test.cm-10.plain-connection.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer: ITestServer; -const TEST_TIMEOUT = 30000; - -tap.test('Plain Connection - should establish basic TCP connection', async (tools) => { - const done = tools.defer(); - - // Start test server - testServer = await startTestServer({ port: TEST_PORT }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - const connected = await new Promise((resolve) => { - socket.once('connect', () => resolve(true)); - socket.once('error', () => resolve(false)); - setTimeout(() => resolve(false), 5000); - }); - - expect(connected).toEqual(true); - - if (connected) { - console.log('Plain connection established:'); - console.log('- Local:', `${socket.localAddress}:${socket.localPort}`); - console.log('- Remote:', `${socket.remoteAddress}:${socket.remotePort}`); - - // Close connection - socket.destroy(); - } - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('Plain Connection - should receive SMTP banner on plain connection', async (tools) => { - const done = tools.defer(); - - // Start test server - testServer = await startTestServer({ port: TEST_PORT }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - const banner = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Received banner:', banner.trim()); - - expect(banner).toInclude('220'); - expect(banner).toInclude('ESMTP'); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('Plain Connection - should complete full SMTP transaction on plain connection', async (tools) => { - const done = tools.defer(); - - // Start test server - testServer = await startTestServer({ port: TEST_PORT }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - const ehloResponse = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - expect(ehloResponse).toInclude('250'); - console.log('EHLO successful on plain connection'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - - const mailResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(mailResponse).toInclude('250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - - const rcptResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(rcptResponse).toInclude('250'); - - // Send DATA - socket.write('DATA\r\n'); - - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(dataResponse).toInclude('354'); - - // Send email content - const emailContent = - 'From: sender@example.com\r\n' + - 'To: recipient@example.com\r\n' + - 'Subject: Plain Connection Test\r\n' + - '\r\n' + - 'This email was sent over a plain connection.\r\n' + - '.\r\n'; - - socket.write(emailContent); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(finalResponse).toInclude('250'); - console.log('Email sent successfully over plain connection'); - - // Clean up - socket.write('QUIT\r\n'); - - const quitResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(quitResponse).toInclude('221'); - socket.end(); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('Plain Connection - should handle multiple plain connections', async (tools) => { - const done = tools.defer(); - - // Start test server - testServer = await startTestServer({ port: TEST_PORT }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - try { - const connectionCount = 3; - const connections: net.Socket[] = []; - - // Create multiple connections - for (let i = 0; i < connectionCount; i++) { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => { - connections.push(socket); - resolve(); - }); - socket.once('error', reject); - }); - - // Get banner - const banner = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(banner).toInclude('220'); - console.log(`Connection ${i + 1} established`); - } - - expect(connections.length).toEqual(connectionCount); - console.log(`All ${connectionCount} plain connections established successfully`); - - // Clean up all connections - for (const socket of connections) { - socket.write('QUIT\r\n'); - socket.end(); - } - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('Plain Connection - should work on standard SMTP port 25', async (tools) => { - const done = tools.defer(); - - // Test port 25 (standard SMTP port) - const SMTP_PORT = 25; - - // Note: Port 25 might require special permissions or might be blocked - // We'll test the connection but handle failures gracefully - const socket = net.createConnection({ - host: 'localhost', - port: SMTP_PORT, - timeout: 5000 - }); - - const result = await new Promise<{connected: boolean, error?: string}>((resolve) => { - socket.once('connect', () => { - socket.destroy(); - resolve({ connected: true }); - }); - - socket.once('error', (err) => { - resolve({ - connected: false, - error: err.message - }); - }); - - setTimeout(() => { - socket.destroy(); - resolve({ - connected: false, - error: 'Connection timeout' - }); - }, 5000); - }); - - if (result.connected) { - console.log('Successfully connected to port 25 (standard SMTP)'); - } else { - console.log(`Could not connect to port 25: ${result.error}`); - console.log('This is expected if port 25 is blocked or requires privileges'); - } - - // Test passes regardless - port 25 connectivity is environment-dependent - expect(true).toEqual(true); - - done.resolve(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_connection/test.cm-11.keepalive.ts b/test/suite/smtpserver_connection/test.cm-11.keepalive.ts deleted file mode 100644 index bc23c96..0000000 --- a/test/suite/smtpserver_connection/test.cm-11.keepalive.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer: ITestServer; -const TEST_TIMEOUT = 60000; // Longer timeout for keepalive tests - -tap.test('Keepalive - should maintain TCP keepalive', async (tools) => { - const done = tools.defer(); - - // Start test server - testServer = await startTestServer({ port: TEST_PORT }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Enable TCP keepalive - const keepAliveDelay = 1000; // 1 second - socket.setKeepAlive(true, keepAliveDelay); - console.log(`TCP keepalive enabled with ${keepAliveDelay}ms delay`); - - // Get banner - const banner = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(banner).toInclude('220'); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - const ehloResponse = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - expect(ehloResponse).toInclude('250'); - - // Wait for keepalive duration + buffer - console.log('Waiting for keepalive period...'); - await new Promise(resolve => setTimeout(resolve, keepAliveDelay + 500)); - - // Verify connection is still alive by sending NOOP - socket.write('NOOP\r\n'); - - const noopResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(noopResponse).toInclude('250'); - console.log('Connection maintained after keepalive period'); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('Keepalive - should maintain idle connection for extended period', async (tools) => { - const done = tools.defer(); - - // Start test server - testServer = await startTestServer({ port: TEST_PORT }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Enable keepalive - socket.setKeepAlive(true, 1000); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Test multiple keepalive periods - const periods = 3; - const periodDuration = 1000; // 1 second each - - for (let i = 0; i < periods; i++) { - console.log(`Keepalive period ${i + 1}/${periods}...`); - await new Promise(resolve => setTimeout(resolve, periodDuration)); - - // Send NOOP to verify connection - socket.write('NOOP\r\n'); - - const response = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(response).toInclude('250'); - console.log(`Connection alive after ${(i + 1) * periodDuration}ms`); - } - - console.log(`Connection maintained for ${periods * periodDuration}ms total`); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('Keepalive - should detect connection loss', async (tools) => { - const done = tools.defer(); - - // Start test server - testServer = await startTestServer({ port: TEST_PORT }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Enable keepalive with short interval - socket.setKeepAlive(true, 1000); - - // Track connection state - let connectionLost = false; - socket.on('close', () => { - connectionLost = true; - console.log('Connection closed'); - }); - - socket.on('error', (err) => { - connectionLost = true; - console.log('Connection error:', err.message); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - console.log('Connection established, now simulating server shutdown...'); - - // Shutdown server to simulate connection loss - await stopTestServer(testServer); - - // Wait for keepalive to detect connection loss - await new Promise(resolve => setTimeout(resolve, 3000)); - - // Connection should be detected as lost - expect(connectionLost).toEqual(true); - console.log('Keepalive detected connection loss'); - - } finally { - // Server already shutdown, just resolve - done.resolve(); - } -}); - -tap.test('Keepalive - should handle long-running SMTP session', async (tools) => { - const done = tools.defer(); - - // Start test server - testServer = await startTestServer({ port: TEST_PORT }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Enable keepalive - socket.setKeepAlive(true, 2000); - - const sessionStart = Date.now(); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Simulate a long-running session with periodic activity - const activities = [ - { command: 'MAIL FROM:', delay: 500 }, - { command: 'RSET', delay: 500 }, - { command: 'MAIL FROM:', delay: 500 }, - { command: 'RSET', delay: 500 } - ]; - - for (const activity of activities) { - await new Promise(resolve => setTimeout(resolve, activity.delay)); - - console.log(`Sending: ${activity.command}`); - socket.write(`${activity.command}\r\n`); - - const response = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(response).toInclude('250'); - } - - const sessionDuration = Date.now() - sessionStart; - console.log(`Long-running session maintained for ${sessionDuration}ms`); - - // Clean up - socket.write('QUIT\r\n'); - - const quitResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(quitResponse).toInclude('221'); - socket.end(); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('Keepalive - should handle NOOP as keepalive mechanism', async (tools) => { - const done = tools.defer(); - - // Start test server - testServer = await startTestServer({ port: TEST_PORT }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Use NOOP as application-level keepalive - const noopInterval = 1000; // 1 second - const noopCount = 3; - - console.log(`Sending ${noopCount} NOOP commands as keepalive...`); - - for (let i = 0; i < noopCount; i++) { - await new Promise(resolve => setTimeout(resolve, noopInterval)); - - socket.write('NOOP\r\n'); - - const response = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(response).toInclude('250'); - console.log(`NOOP ${i + 1}/${noopCount} successful`); - } - - console.log('Application-level keepalive successful'); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_edge-cases/test.edge-01.very-large-email.ts b/test/suite/smtpserver_edge-cases/test.edge-01.very-large-email.ts deleted file mode 100644 index c29bec8..0000000 --- a/test/suite/smtpserver_edge-cases/test.edge-01.very-large-email.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -import { connectToSmtp, waitForGreeting, sendSmtpCommand, closeSmtpConnection, generateRandomEmail } from '../../helpers/utils.js'; - -let testServer: ITestServer; - -tap.test('setup - start SMTP server with large size limit', async () => { - testServer = await startTestServer({ - port: 2532, - hostname: 'localhost', - size: 100 * 1024 * 1024 // 100MB limit for testing - }); - expect(testServer).toBeInstanceOf(Object); -}); - -tap.test('EDGE-01: Very Large Email - test size limits and handling', async () => { - const testCases = [ - { size: 1 * 1024 * 1024, label: '1MB', shouldPass: true }, - { size: 10 * 1024 * 1024, label: '10MB', shouldPass: true }, - { size: 50 * 1024 * 1024, label: '50MB', shouldPass: true }, - { size: 101 * 1024 * 1024, label: '101MB', shouldPass: false } // Over limit - ]; - - for (const testCase of testCases) { - console.log(`\n📧 Testing ${testCase.label} email...`); - const socket = await connectToSmtp(testServer.hostname, testServer.port); - - try { - await waitForGreeting(socket); - await sendSmtpCommand(socket, 'EHLO test.example.com', '250'); - - // Check SIZE extension - await sendSmtpCommand(socket, `MAIL FROM: SIZE=${testCase.size}`, - testCase.shouldPass ? '250' : '552'); - - if (testCase.shouldPass) { - // Continue with transaction - await sendSmtpCommand(socket, 'RCPT TO:', '250'); - await sendSmtpCommand(socket, 'DATA', '354'); - - // Send large content in chunks - const chunkSize = 65536; // 64KB chunks - const totalChunks = Math.ceil(testCase.size / chunkSize); - - console.log(` Sending ${totalChunks} chunks...`); - - // Headers - socket.write('From: large@example.com\r\n'); - socket.write('To: recipient@example.com\r\n'); - socket.write(`Subject: ${testCase.label} Test Email\r\n`); - socket.write('Content-Type: text/plain\r\n'); - socket.write('\r\n'); - - // Body in chunks - let bytesSent = 100; // Approximate header size - const startTime = Date.now(); - - for (let i = 0; i < totalChunks; i++) { - const chunk = generateRandomEmail(Math.min(chunkSize, testCase.size - bytesSent)); - socket.write(chunk); - bytesSent += chunk.length; - - // Progress indicator every 10% - if (i % Math.floor(totalChunks / 10) === 0) { - const progress = (i / totalChunks * 100).toFixed(0); - console.log(` Progress: ${progress}%`); - } - - // Small delay to avoid overwhelming - if (i % 100 === 0) { - await new Promise(resolve => setTimeout(resolve, 10)); - } - } - - // End of data - socket.write('\r\n.\r\n'); - - // Wait for response with longer timeout for large emails - const response = await new Promise((resolve, reject) => { - let buffer = ''; - const timeout = setTimeout(() => reject(new Error('Timeout')), 60000); - - const onData = (data: Buffer) => { - buffer += data.toString(); - if (buffer.includes('250') || buffer.includes('5')) { - clearTimeout(timeout); - socket.removeListener('data', onData); - resolve(buffer); - } - }; - - socket.on('data', onData); - }); - - const duration = Date.now() - startTime; - const throughputMBps = (testCase.size / 1024 / 1024) / (duration / 1000); - - expect(response).toInclude('250'); - console.log(` ✅ ${testCase.label} email accepted in ${duration}ms`); - console.log(` Throughput: ${throughputMBps.toFixed(2)} MB/s`); - - } else { - console.log(` ✅ ${testCase.label} email properly rejected (over size limit)`); - } - - } catch (error) { - if (!testCase.shouldPass && error.message.includes('552')) { - console.log(` ✅ ${testCase.label} email properly rejected: ${error.message}`); - } else { - throw error; - } - } finally { - await closeSmtpConnection(socket).catch(() => {}); - } - } -}); - -tap.test('EDGE-01: Email size enforcement - SIZE parameter', async () => { - const socket = await connectToSmtp(testServer.hostname, testServer.port); - - try { - await waitForGreeting(socket); - const ehloResponse = await sendSmtpCommand(socket, 'EHLO test.example.com', '250'); - - // Extract SIZE limit from capabilities - const sizeMatch = ehloResponse.match(/250[- ]SIZE (\d+)/); - const sizeLimit = sizeMatch ? parseInt(sizeMatch[1]) : 0; - - console.log(`📏 Server advertises SIZE limit: ${sizeLimit} bytes`); - expect(sizeLimit).toBeGreaterThan(0); - - // Test SIZE parameter enforcement - const testSizes = [ - { size: 1000, shouldPass: true }, - { size: sizeLimit - 1000, shouldPass: true }, - { size: sizeLimit + 1000, shouldPass: false } - ]; - - for (const test of testSizes) { - try { - const response = await sendSmtpCommand( - socket, - `MAIL FROM: SIZE=${test.size}` - ); - - if (test.shouldPass) { - expect(response).toInclude('250'); - console.log(` ✅ SIZE=${test.size} accepted`); - await sendSmtpCommand(socket, 'RSET', '250'); - } else { - expect(response).toInclude('552'); - console.log(` ✅ SIZE=${test.size} rejected`); - } - } catch (error) { - if (!test.shouldPass) { - console.log(` ✅ SIZE=${test.size} rejected: ${error.message}`); - } else { - throw error; - } - } - } - - } finally { - await closeSmtpConnection(socket); - } -}); - -tap.test('EDGE-01: Memory efficiency with large emails', async () => { - // Get initial memory usage - const initialMemory = process.memoryUsage(); - console.log('📊 Initial memory usage:', { - heapUsed: `${(initialMemory.heapUsed / 1024 / 1024).toFixed(2)} MB`, - rss: `${(initialMemory.rss / 1024 / 1024).toFixed(2)} MB` - }); - - // Send a moderately large email - const socket = await connectToSmtp(testServer.hostname, testServer.port); - - try { - await waitForGreeting(socket); - await sendSmtpCommand(socket, 'EHLO test.example.com', '250'); - await sendSmtpCommand(socket, 'MAIL FROM:', '250'); - await sendSmtpCommand(socket, 'RCPT TO:', '250'); - await sendSmtpCommand(socket, 'DATA', '354'); - - // Send 20MB email - const size = 20 * 1024 * 1024; - const chunkSize = 1024 * 1024; // 1MB chunks - - socket.write('From: memory@test.com\r\n'); - socket.write('To: recipient@example.com\r\n'); - socket.write('Subject: Memory Test\r\n\r\n'); - - for (let i = 0; i < size / chunkSize; i++) { - socket.write(generateRandomEmail(chunkSize)); - // Force garbage collection if available - if (global.gc) { - global.gc(); - } - } - - socket.write('\r\n.\r\n'); - - // Wait for response - await new Promise((resolve) => { - const onData = (data: Buffer) => { - if (data.toString().includes('250')) { - socket.removeListener('data', onData); - resolve(); - } - }; - socket.on('data', onData); - }); - - // Check memory after processing - const finalMemory = process.memoryUsage(); - const memoryIncrease = (finalMemory.heapUsed - initialMemory.heapUsed) / 1024 / 1024; - - console.log('📊 Final memory usage:', { - heapUsed: `${(finalMemory.heapUsed / 1024 / 1024).toFixed(2)} MB`, - rss: `${(finalMemory.rss / 1024 / 1024).toFixed(2)} MB`, - increase: `${memoryIncrease.toFixed(2)} MB` - }); - - // Memory increase should be reasonable - allow up to 700MB given: - // 1. Prior tests in this suite (1MB, 10MB, 50MB emails) have accumulated memory - // 2. The SMTP server buffers data during processing - // 3. Node.js memory management may not immediately release memory - // The goal is to catch severe memory leaks (multi-GB), not minor overhead - expect(memoryIncrease).toBeLessThan(700); // Allow reasonable overhead for test suite context - console.log('✅ Memory efficiency test passed'); - - } finally { - await closeSmtpConnection(socket); - } -}); - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); - console.log('✅ Test server stopped'); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_edge-cases/test.edge-02.very-small-email.ts b/test/suite/smtpserver_edge-cases/test.edge-02.very-small-email.ts deleted file mode 100644 index 56e958d..0000000 --- a/test/suite/smtpserver_edge-cases/test.edge-02.very-small-email.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 30034; -const TEST_TIMEOUT = 30000; - -tap.test('Very Small Email - should handle minimal email with single character body', async (tools) => { - const done = tools.defer(); - - // Start test server - const testServer = await startTestServer({ port: TEST_PORT }); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - const mailResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(mailResponse).toInclude('250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(rcptResponse).toInclude('250'); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(dataResponse).toInclude('354'); - - // Send minimal email - just required headers and single character body - const minimalEmail = 'From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: \r\n\r\nX\r\n.\r\n'; - socket.write(minimalEmail); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(finalResponse).toInclude('250'); - console.log(`Minimal email (${minimalEmail.length} bytes) processed successfully`); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('Very Small Email - should handle email with empty body', async (tools) => { - const done = tools.defer(); - - // Start test server - const testServer = await startTestServer({ port: TEST_PORT }); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Complete envelope - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('RCPT TO:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('DATA\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send email with empty body - const emptyBodyEmail = 'From: sender@example.com\r\nTo: recipient@example.com\r\n\r\n.\r\n'; - socket.write(emptyBodyEmail); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(finalResponse).toInclude('250'); - console.log('Email with empty body processed successfully'); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('Very Small Email - should handle email with minimal headers only', async (tools) => { - const done = tools.defer(); - - // Start test server - const testServer = await startTestServer({ port: TEST_PORT }); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner and send EHLO - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Complete envelope - use valid email addresses - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('RCPT TO:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('DATA\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send absolutely minimal valid email - const minimalHeaders = 'From: a@example.com\r\n\r\n.\r\n'; - socket.write(minimalHeaders); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(finalResponse).toInclude('250'); - console.log(`Ultra-minimal email (${minimalHeaders.length} bytes) processed`); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('Very Small Email - should handle single dot line correctly', async (tools) => { - const done = tools.defer(); - - // Start test server - const testServer = await startTestServer({ port: TEST_PORT }); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Setup connection - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Complete envelope - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('RCPT TO:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('DATA\r\n'); - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(dataResponse).toInclude('354'); - - // Test edge case: just the terminating dot - socket.write('.\r\n'); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Server should accept this as an email with no headers or body - expect(finalResponse).toMatch(/^[2-5]\d{2}/); - console.log('Single dot terminator handled:', finalResponse.trim()); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -tap.test('Very Small Email - should handle email with empty subject', async (tools) => { - const done = tools.defer(); - - // Start test server - const testServer = await startTestServer({ port: TEST_PORT }); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Setup connection - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Complete envelope - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('RCPT TO:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('DATA\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send email with empty subject line - const emptySubjectEmail = - 'From: sender@example.com\r\n' + - 'To: recipient@example.com\r\n' + - 'Subject: \r\n' + - 'Date: ' + new Date().toUTCString() + '\r\n' + - '\r\n' + - 'Email with empty subject.\r\n' + - '.\r\n'; - - socket.write(emptySubjectEmail); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(finalResponse).toInclude('250'); - console.log('Email with empty subject processed successfully'); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - await stopTestServer(testServer); - done.resolve(); - } -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_edge-cases/test.edge-03.invalid-character-handling.ts b/test/suite/smtpserver_edge-cases/test.edge-03.invalid-character-handling.ts deleted file mode 100644 index 210a6e2..0000000 --- a/test/suite/smtpserver_edge-cases/test.edge-03.invalid-character-handling.ts +++ /dev/null @@ -1,479 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 30035; -const TEST_TIMEOUT = 30000; - -let testServer: ITestServer; - -tap.test('setup - start SMTP server for invalid character tests', async () => { - testServer = await startTestServer({ - port: TEST_PORT, - hostname: 'localhost' - }); - expect(testServer).toBeDefined(); -}); - -tap.test('Invalid Character Handling - should handle control characters in email', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send envelope - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('RCPT TO:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('DATA\r\n'); - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(dataResponse).toInclude('354'); - - // Test with control characters - const controlChars = [ - '\x00', // NULL - '\x01', // SOH - '\x02', // STX - '\x03', // ETX - '\x7F' // DEL - ]; - - const emailWithControlChars = - 'From: sender@example.com\r\n' + - 'To: recipient@example.com\r\n' + - `Subject: Control Character Test ${controlChars.join('')}\r\n` + - '\r\n' + - `This email contains control characters: ${controlChars.join('')}\r\n` + - 'Null byte: \x00\r\n' + - 'Delete char: \x7F\r\n' + - '.\r\n'; - - socket.write(emailWithControlChars); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Response to control characters:', finalResponse); - - // Server might accept or reject based on security settings - const accepted = finalResponse.includes('250'); - const rejected = finalResponse.includes('550') || finalResponse.includes('554'); - - expect(accepted || rejected).toEqual(true); - - if (rejected) { - console.log('Server rejected control characters (strict security)'); - } else { - console.log('Server accepted control characters (may sanitize internally)'); - } - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Invalid Character Handling - should handle high-byte characters', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send envelope - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('RCPT TO:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('DATA\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Test with high-byte characters - const highByteChars = [ - '\xFF', // 255 - '\xFE', // 254 - '\xFD', // 253 - '\xFC', // 252 - '\xFB' // 251 - ]; - - const emailWithHighBytes = - 'From: sender@example.com\r\n' + - 'To: recipient@example.com\r\n' + - 'Subject: High-byte Character Test\r\n' + - '\r\n' + - `High-byte characters: ${highByteChars.join('')}\r\n` + - 'Extended ASCII: \xE0\xE1\xE2\xE3\xE4\r\n' + - '.\r\n'; - - socket.write(emailWithHighBytes); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Response to high-byte characters:', finalResponse); - - // Both acceptance and rejection are valid - expect(finalResponse).toMatch(/^[2-5]\d{2}/); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Invalid Character Handling - should handle Unicode special characters', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send envelope - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('RCPT TO:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('DATA\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Test with Unicode special characters - const unicodeSpecials = [ - '\u2000', // EN QUAD - '\u2028', // LINE SEPARATOR - '\u2029', // PARAGRAPH SEPARATOR - '\uFEFF', // ZERO WIDTH NO-BREAK SPACE (BOM) - '\u200B', // ZERO WIDTH SPACE - '\u200C', // ZERO WIDTH NON-JOINER - '\u200D' // ZERO WIDTH JOINER - ]; - - const emailWithUnicode = - 'From: sender@example.com\r\n' + - 'To: recipient@example.com\r\n' + - 'Subject: Unicode Special Characters Test\r\n' + - 'Content-Type: text/plain; charset=utf-8\r\n' + - '\r\n' + - `Unicode specials: ${unicodeSpecials.join('')}\r\n` + - 'Line separator: \u2028\r\n' + - 'Paragraph separator: \u2029\r\n' + - 'Zero-width space: word\u200Bword\r\n' + - '.\r\n'; - - socket.write(emailWithUnicode); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Response to Unicode special characters:', finalResponse); - - // Most servers should accept Unicode with proper charset declaration - expect(finalResponse).toMatch(/^[2-5]\d{2}/); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Invalid Character Handling - should handle bare LF and CR', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send envelope - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('RCPT TO:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('DATA\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Test with bare LF and CR (not allowed in SMTP) - const emailWithBareLfCr = - 'From: sender@example.com\r\n' + - 'To: recipient@example.com\r\n' + - 'Subject: Bare LF and CR Test\r\n' + - '\r\n' + - 'Line with bare LF:\nThis should not be allowed\r\n' + - 'Line with bare CR:\rThis should also not be allowed\r\n' + - 'Correct line ending\r\n' + - '.\r\n'; - - socket.write(emailWithBareLfCr); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Response to bare LF/CR:', finalResponse); - - // Servers may accept and fix, or reject - const accepted = finalResponse.includes('250'); - const rejected = finalResponse.includes('550') || finalResponse.includes('554'); - - if (accepted) { - console.log('Server accepted bare LF/CR (may convert to CRLF)'); - } else if (rejected) { - console.log('Server rejected bare LF/CR (strict SMTP compliance)'); - } - - expect(accepted || rejected).toEqual(true); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Invalid Character Handling - should handle long lines without proper folding', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send envelope - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('RCPT TO:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('DATA\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Create a line that exceeds RFC 5322 limit (998 characters) - const longLine = 'X'.repeat(1500); - - const emailWithLongLine = - 'From: sender@example.com\r\n' + - 'To: recipient@example.com\r\n' + - 'Subject: Long Line Test\r\n' + - '\r\n' + - 'Normal line\r\n' + - longLine + '\r\n' + - 'Another normal line\r\n' + - '.\r\n'; - - socket.write(emailWithLongLine); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Response to long line:', finalResponse); - console.log(`Line length: ${longLine.length} characters`); - - // Server should handle this (accept, wrap, or reject) - expect(finalResponse).toMatch(/^[2-5]\d{2}/); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); - expect(true).toEqual(true); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_edge-cases/test.edge-04.empty-commands.ts b/test/suite/smtpserver_edge-cases/test.edge-04.empty-commands.ts deleted file mode 100644 index 4c423f1..0000000 --- a/test/suite/smtpserver_edge-cases/test.edge-04.empty-commands.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 30036; -const TEST_TIMEOUT = 30000; - -let testServer: ITestServer; - -tap.test('setup - start SMTP server for empty command tests', async () => { - testServer = await startTestServer({ - port: TEST_PORT, - hostname: 'localhost' - }); - expect(testServer).toBeDefined(); -}); - -tap.test('Empty Commands - should reject empty line (just CRLF)', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO first - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send empty line (just CRLF) - socket.write('\r\n'); - - const response = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - setTimeout(() => resolve('TIMEOUT'), 2000); - }); - - console.log('Response to empty line:', response); - - // Should get syntax error (500, 501, or 502) - if (response !== 'TIMEOUT') { - expect(response).toMatch(/^5\d{2}/); - } else { - // Server might ignore empty lines - console.log('Server ignored empty line'); - expect(true).toEqual(true); - } - - // Test server is still responsive - socket.write('NOOP\r\n'); - const noopResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(noopResponse).toInclude('250'); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Empty Commands - should reject commands with only whitespace', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner and send EHLO - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Test various whitespace-only commands - const whitespaceCommands = [ - ' \r\n', // Spaces only - '\t\r\n', // Tab only - ' \t \r\n', // Mixed whitespace - ' \r\n' // Multiple spaces - ]; - - for (const cmd of whitespaceCommands) { - socket.write(cmd); - - const response = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - setTimeout(() => resolve('TIMEOUT'), 2000); - }); - - console.log(`Response to whitespace "${cmd.trim()}"\\r\\n:`, response); - - if (response !== 'TIMEOUT') { - // Should get syntax error - expect(response).toMatch(/^5\d{2}/); - } - } - - // Verify server still works - socket.write('NOOP\r\n'); - const noopResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(noopResponse).toInclude('250'); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Empty Commands - should reject MAIL FROM with empty parameter', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Setup connection - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send MAIL FROM with empty parameter - socket.write('MAIL FROM:\r\n'); - - const response = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Response to empty MAIL FROM:', response); - - // Should get syntax error (501 or 550) - expect(response).toMatch(/^5\d{2}/); - expect(response).toMatch(/syntax|parameter|address/i); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Empty Commands - should reject RCPT TO with empty parameter', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Setup connection - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send valid MAIL FROM first - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send RCPT TO with empty parameter - socket.write('RCPT TO:\r\n'); - - const response = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Response to empty RCPT TO:', response); - - // Should get syntax error - expect(response).toMatch(/^5\d{2}/); - expect(response).toMatch(/syntax|parameter|address/i); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Empty Commands - should reject EHLO/HELO without hostname', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO without hostname - socket.write('EHLO\r\n'); - - const ehloResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Response to EHLO without hostname:', ehloResponse); - - // Should get syntax error - expect(ehloResponse).toMatch(/^5\d{2}/); - - // Try HELO without hostname - socket.write('HELO\r\n'); - - const heloResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Response to HELO without hostname:', heloResponse); - - // Should get syntax error - expect(heloResponse).toMatch(/^5\d{2}/); - - // Send valid EHLO to establish session - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Empty Commands - server should remain stable after empty commands', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send multiple empty/invalid commands - const invalidCommands = [ - '\r\n', - ' \r\n', - 'MAIL FROM:\r\n', - 'RCPT TO:\r\n', - 'EHLO\r\n', - '\t\r\n' - ]; - - for (const cmd of invalidCommands) { - socket.write(cmd); - - // Read response but don't fail if error - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - setTimeout(() => resolve('TIMEOUT'), 1000); - }); - } - - // Now test that server is still functional - socket.write('MAIL FROM:\r\n'); - const mailResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(mailResponse).toInclude('250'); - - socket.write('RCPT TO:\r\n'); - const rcptResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(rcptResponse).toInclude('250'); - - console.log('Server remained stable after multiple empty commands'); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); - expect(true).toEqual(true); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_edge-cases/test.edge-05.extremely-long-lines.ts b/test/suite/smtpserver_edge-cases/test.edge-05.extremely-long-lines.ts deleted file mode 100644 index 5585962..0000000 --- a/test/suite/smtpserver_edge-cases/test.edge-05.extremely-long-lines.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 30037; -const TEST_TIMEOUT = 30000; - -let testServer: ITestServer; - -tap.test('setup - start SMTP server for extremely long lines tests', async () => { - testServer = await startTestServer({ - port: TEST_PORT, - hostname: 'localhost' - }); - expect(testServer).toBeDefined(); -}); - -tap.test('Extremely Long Lines - should handle lines exceeding RFC 5321 limit', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send envelope - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('RCPT TO:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('DATA\r\n'); - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(dataResponse).toInclude('354'); - - // Create line exceeding RFC 5321 limit (1000 chars including CRLF) - const longLine = 'X'.repeat(2000); // 2000 character line - - const emailWithLongLine = - 'From: sender@example.com\r\n' + - 'To: recipient@example.com\r\n' + - 'Subject: Long Line Test\r\n' + - '\r\n' + - 'This email contains an extremely long line:\r\n' + - longLine + '\r\n' + - 'End of test.\r\n' + - '.\r\n'; - - socket.write(emailWithLongLine); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log(`Response to ${longLine.length} character line:`, finalResponse); - - // Server should handle gracefully (accept, wrap, or reject) - const accepted = finalResponse.includes('250'); - const rejected = finalResponse.includes('552') || finalResponse.includes('500') || finalResponse.includes('554'); - - expect(accepted || rejected).toEqual(true); - - if (accepted) { - console.log('Server accepted long line (may wrap internally)'); - } else { - console.log('Server rejected long line'); - } - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Extremely Long Lines - should handle extremely long subject header', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Setup connection - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send envelope - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('RCPT TO:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('DATA\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Create extremely long subject (3000 characters) - const longSubject = 'A'.repeat(3000); - - const emailWithLongSubject = - 'From: sender@example.com\r\n' + - 'To: recipient@example.com\r\n' + - `Subject: ${longSubject}\r\n` + - '\r\n' + - 'Body of email with extremely long subject.\r\n' + - '.\r\n'; - - socket.write(emailWithLongSubject); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log(`Response to ${longSubject.length} character subject:`, finalResponse); - - // Server should handle this - expect(finalResponse).toMatch(/^[2-5]\d{2}/); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Extremely Long Lines - should handle multiple consecutive long lines', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Setup connection - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send envelope - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('RCPT TO:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('DATA\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Create multiple long lines - const longLine1 = 'A'.repeat(1500); - const longLine2 = 'B'.repeat(1800); - const longLine3 = 'C'.repeat(2000); - - const emailWithMultipleLongLines = - 'From: sender@example.com\r\n' + - 'To: recipient@example.com\r\n' + - 'Subject: Multiple Long Lines Test\r\n' + - '\r\n' + - 'First long line:\r\n' + - longLine1 + '\r\n' + - 'Second long line:\r\n' + - longLine2 + '\r\n' + - 'Third long line:\r\n' + - longLine3 + '\r\n' + - '.\r\n'; - - socket.write(emailWithMultipleLongLines); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Response to multiple long lines:', finalResponse); - - // Server should handle this - expect(finalResponse).toMatch(/^[2-5]\d{2}/); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Extremely Long Lines - should handle extremely long MAIL FROM parameter', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Setup connection - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Create extremely long email address (technically invalid but testing limits) - const longLocalPart = 'a'.repeat(500); - const longDomain = 'b'.repeat(500) + '.com'; - const longEmail = `${longLocalPart}@${longDomain}`; - - socket.write(`MAIL FROM:<${longEmail}>\r\n`); - - const response = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log(`Response to ${longEmail.length} character email address:`, response); - - // Should get error response - expect(response).toMatch(/^5\d{2}/); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Extremely Long Lines - should handle line exactly at RFC limit', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Setup connection - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send envelope - socket.write('MAIL FROM:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('RCPT TO:\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - socket.write('DATA\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Create line exactly at RFC 5321 limit (998 chars + CRLF = 1000) - const rfcLimitLine = 'X'.repeat(998); - - const emailWithRfcLimitLine = - 'From: sender@example.com\r\n' + - 'To: recipient@example.com\r\n' + - 'Subject: RFC Limit Test\r\n' + - '\r\n' + - 'Line at RFC 5321 limit:\r\n' + - rfcLimitLine + '\r\n' + - 'This should be accepted.\r\n' + - '.\r\n'; - - socket.write(emailWithRfcLimitLine); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log(`Response to ${rfcLimitLine.length} character line (RFC limit):`, finalResponse); - - // This should be accepted - expect(finalResponse).toInclude('250'); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); - expect(true).toEqual(true); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_edge-cases/test.edge-06.extremely-long-headers.ts b/test/suite/smtpserver_edge-cases/test.edge-06.extremely-long-headers.ts deleted file mode 100644 index 433769f..0000000 --- a/test/suite/smtpserver_edge-cases/test.edge-06.extremely-long-headers.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -const TEST_TIMEOUT = 30000; - -let testServer: ITestServer; - -tap.test('setup - start test server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 1000)); - expect(testServer).toBeDefined(); -}); - -tap.test('Extremely Long Headers - should handle single extremely long header', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - const mailResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(mailResponse).toInclude('250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(rcptResponse).toInclude('250'); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(dataResponse).toInclude('354'); - - // Send email with extremely long header (3000 characters) - const longValue = 'X'.repeat(3000); - const emailContent = [ - `Subject: Test Email`, - `From: sender@example.com`, - `To: recipient@example.com`, - `X-Long-Header: ${longValue}`, - '', - 'This email has an extremely long header.', - '.', - '' - ].join('\r\n'); - - socket.write(emailContent); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Server might accept or reject - both are valid for extremely long headers - const accepted = finalResponse.includes('250'); - const rejected = finalResponse.includes('552') || finalResponse.includes('554') || finalResponse.includes('500'); - - console.log(`Long header test ${accepted ? 'accepted' : 'rejected'}: ${finalResponse.trim()}`); - expect(accepted || rejected).toEqual(true); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Extremely Long Headers - should handle multi-line header with many segments', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - const mailResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(mailResponse).toInclude('250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(rcptResponse).toInclude('250'); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(dataResponse).toInclude('354'); - - // Create multi-line header with 50 segments (RFC 5322 folding) - const segments = []; - for (let i = 0; i < 50; i++) { - segments.push(` Segment ${i}: ${' '.repeat(60)}value`); - } - - const emailContent = [ - `Subject: Test Email`, - `From: sender@example.com`, - `To: recipient@example.com`, - `X-Multi-Line: Initial value`, - ...segments, - '', - 'This email has a multi-line header with many segments.', - '.', - '' - ].join('\r\n'); - - socket.write(emailContent); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - const accepted = finalResponse.includes('250'); - const rejected = finalResponse.includes('552') || finalResponse.includes('554') || finalResponse.includes('500'); - - console.log(`Multi-line header test ${accepted ? 'accepted' : 'rejected'}: ${finalResponse.trim()}`); - expect(accepted || rejected).toEqual(true); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Extremely Long Headers - should handle multiple long headers in one email', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - const mailResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(mailResponse).toInclude('250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(rcptResponse).toInclude('250'); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(dataResponse).toInclude('354'); - - // Create multiple long headers - const header1 = 'A'.repeat(1000); - const header2 = 'B'.repeat(1500); - const header3 = 'C'.repeat(2000); - - const emailContent = [ - `Subject: Test Email with Multiple Long Headers`, - `From: sender@example.com`, - `To: recipient@example.com`, - `X-Long-Header-1: ${header1}`, - `X-Long-Header-2: ${header2}`, - `X-Long-Header-3: ${header3}`, - '', - 'This email has multiple long headers.', - '.', - '' - ].join('\r\n'); - - const totalHeaderSize = header1.length + header2.length + header3.length; - console.log(`Total header size: ${totalHeaderSize} bytes`); - - socket.write(emailContent); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - const accepted = finalResponse.includes('250'); - const rejected = finalResponse.includes('552') || finalResponse.includes('554') || finalResponse.includes('500'); - - console.log(`Multiple long headers test ${accepted ? 'accepted' : 'rejected'}: ${finalResponse.trim()}`); - expect(accepted || rejected).toEqual(true); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Extremely Long Headers - should handle header with exactly RFC limit', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - const mailResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(mailResponse).toInclude('250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(rcptResponse).toInclude('250'); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - expect(dataResponse).toInclude('354'); - - // Create header line exactly at RFC 5322 limit (998 chars excluding CRLF) - // Header name and colon take some space - const headerName = 'X-RFC-Limit'; - const colonSpace = ': '; - const remainingSpace = 998 - headerName.length - colonSpace.length; - const headerValue = 'X'.repeat(remainingSpace); - - const emailContent = [ - `Subject: Test Email`, - `From: sender@example.com`, - `To: recipient@example.com`, - `${headerName}${colonSpace}${headerValue}`, - '', - 'This email has a header at exactly the RFC limit.', - '.', - '' - ].join('\r\n'); - - socket.write(emailContent); - - const finalResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // This should be accepted since it's exactly at the limit - const accepted = finalResponse.includes('250'); - const rejected = finalResponse.includes('552') || finalResponse.includes('554') || finalResponse.includes('500'); - - console.log(`RFC limit header test ${accepted ? 'accepted' : 'rejected'}: ${finalResponse.trim()}`); - expect(accepted || rejected).toEqual(true); - - // RFC compliant servers should accept headers exactly at the limit - if (accepted) { - console.log('✓ Server correctly accepts headers at RFC limit'); - } else { - console.log('⚠ Server rejected header at RFC limit (may be overly strict)'); - } - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); - expect(true).toEqual(true); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_edge-cases/test.edge-07.unusual-mime-types.ts b/test/suite/smtpserver_edge-cases/test.edge-07.unusual-mime-types.ts deleted file mode 100644 index 783d49a..0000000 --- a/test/suite/smtpserver_edge-cases/test.edge-07.unusual-mime-types.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 30041; -const TEST_TIMEOUT = 30000; - -let testServer: ITestServer; - -tap.test('setup - start test server', async () => { - testServer = await startTestServer({ - port: TEST_PORT, - hostname: 'localhost' - }); - expect(testServer).toBeDefined(); -}); - -tap.test('Unusual MIME Types - should handle email with various unusual MIME types', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - console.log('Server response:', data.toString()); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO testclient\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - receivedData = ''; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - receivedData = ''; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - // Create multipart email with unusual MIME types - const boundary = '----=_Part_1_' + Date.now(); - const unusualMimeTypes = [ - { type: 'text/plain', content: 'This is plain text content.' }, - { type: 'application/x-custom-unusual-type', content: 'Custom proprietary format data' }, - { type: 'model/vrml', content: '#VRML V2.0 utf8\nShape { geometry Box {} }' }, - { type: 'chemical/x-mdl-molfile', content: 'Molecule data\n -ISIS- 04249412312D\n\n 3 2 0 0 0 0 0 0 0 0999 V2000' }, - { type: 'application/vnd.ms-fontobject', content: 'Font binary data simulation' }, - { type: 'application/x-doom', content: 'IWAD game data simulation' } - ]; - - let emailContent = [ - 'Subject: Email with Unusual MIME Types', - 'From: sender@example.com', - 'To: recipient@example.com', - 'MIME-Version: 1.0', - `Content-Type: multipart/mixed; boundary="${boundary}"`, - '', - 'This is a multipart message with unusual MIME types.', - '' - ]; - - // Add each unusual MIME type as a part - unusualMimeTypes.forEach((mime, index) => { - emailContent.push(`--${boundary}`); - emailContent.push(`Content-Type: ${mime.type}`); - emailContent.push(`Content-Disposition: attachment; filename="part${index + 1}"`); - emailContent.push(''); - emailContent.push(mime.content); - emailContent.push(''); - }); - - emailContent.push(`--${boundary}--`); - emailContent.push('.'); - emailContent.push(''); - - const fullEmail = emailContent.join('\r\n'); - console.log(`Sending email with ${unusualMimeTypes.length} unusual MIME types`); - - socket.write(fullEmail); - currentStep = 'waiting_response'; - receivedData = ''; - } else if (currentStep === 'waiting_response' && (receivedData.includes('250 ') || - receivedData.includes('552 ') || - receivedData.includes('554 ') || - receivedData.includes('500 '))) { - // Either accepted or gracefully rejected - const accepted = receivedData.includes('250 '); - console.log(`Unusual MIME types test ${accepted ? 'accepted' : 'rejected'}`); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('timeout', () => { - console.error('Socket timeout'); - socket.destroy(); - done.reject(new Error('Socket timeout')); - }); - - await done.promise; -}); - -tap.test('Unusual MIME Types - should handle email with deeply nested multipart structure', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - console.log('Server response:', data.toString()); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO testclient\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - receivedData = ''; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - receivedData = ''; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - // Create nested multipart structure - const boundary1 = '----=_Part_Outer_' + Date.now(); - const boundary2 = '----=_Part_Inner_' + Date.now(); - - let emailContent = [ - 'Subject: Nested Multipart Email', - 'From: sender@example.com', - 'To: recipient@example.com', - 'MIME-Version: 1.0', - `Content-Type: multipart/mixed; boundary="${boundary1}"`, - '', - 'This is a nested multipart message.', - '', - `--${boundary1}`, - 'Content-Type: text/plain', - '', - 'First level plain text.', - '', - `--${boundary1}`, - `Content-Type: multipart/alternative; boundary="${boundary2}"`, - '', - `--${boundary2}`, - 'Content-Type: text/richtext', - '', - 'Rich text content', - '', - `--${boundary2}`, - 'Content-Type: application/rtf', - '', - '{\\rtf1 RTF content}', - '', - `--${boundary2}--`, - '', - `--${boundary1}`, - 'Content-Type: audio/x-aiff', - 'Content-Disposition: attachment; filename="sound.aiff"', - '', - 'AIFF audio data simulation', - '', - `--${boundary1}--`, - '.', - '' - ].join('\r\n'); - - socket.write(emailContent); - currentStep = 'waiting_response'; - receivedData = ''; - } else if (currentStep === 'waiting_response' && (receivedData.includes('250 ') || - receivedData.includes('552 ') || - receivedData.includes('554 ') || - receivedData.includes('500 '))) { - const accepted = receivedData.includes('250 '); - console.log(`Nested multipart test ${accepted ? 'accepted' : 'rejected'}`); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('timeout', () => { - console.error('Socket timeout'); - socket.destroy(); - done.reject(new Error('Socket timeout')); - }); - - await done.promise; -}); - -tap.test('Unusual MIME Types - should handle email with non-standard charset encodings', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - console.log('Server response:', data.toString()); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - receivedData = ''; - socket.write('EHLO testclient\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250 ')) { - currentStep = 'mail_from'; - receivedData = ''; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - receivedData = ''; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - receivedData = ''; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - // Create email with various charset encodings - const boundary = '----=_Part_Charset_' + Date.now(); - - let emailContent = [ - 'Subject: Email with Various Charset Encodings', - 'From: sender@example.com', - 'To: recipient@example.com', - 'MIME-Version: 1.0', - `Content-Type: multipart/mixed; boundary="${boundary}"`, - '', - 'This email contains various charset encodings.', - '', - `--${boundary}`, - 'Content-Type: text/plain; charset="iso-2022-jp"', - '', - 'Japanese text simulation', - '', - `--${boundary}`, - 'Content-Type: text/plain; charset="windows-1251"', - '', - 'Cyrillic text simulation', - '', - `--${boundary}`, - 'Content-Type: text/plain; charset="koi8-r"', - '', - 'Russian KOI8-R text', - '', - `--${boundary}`, - 'Content-Type: text/plain; charset="gb2312"', - '', - 'Chinese GB2312 text', - '', - `--${boundary}--`, - '.', - '' - ].join('\r\n'); - - socket.write(emailContent); - currentStep = 'waiting_response'; - receivedData = ''; - } else if (currentStep === 'waiting_response' && (receivedData.includes('250 ') || - receivedData.includes('552 ') || - receivedData.includes('554 ') || - receivedData.includes('500 '))) { - const accepted = receivedData.includes('250 '); - console.log(`Various charset test ${accepted ? 'accepted' : 'rejected'}`); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('timeout', () => { - console.error('Socket timeout'); - socket.destroy(); - done.reject(new Error('Socket timeout')); - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); - expect(true).toEqual(true); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_edge-cases/test.edge-08.nested-mime-structures.ts b/test/suite/smtpserver_edge-cases/test.edge-08.nested-mime-structures.ts deleted file mode 100644 index 2ecec4f..0000000 --- a/test/suite/smtpserver_edge-cases/test.edge-08.nested-mime-structures.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; -let testServer: ITestServer; -const TEST_PORT = 2525; - -tap.test('setup - start test server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 1000)); -}); - -tap.test('Nested MIME Structures - should handle deeply nested multipart structure', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let state = 'initial'; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (dataBuffer.includes('220 ') && state === 'initial') { - // Send EHLO - socket.write('EHLO testclient\r\n'); - state = 'ehlo_sent'; - dataBuffer = ''; - } else if (dataBuffer.includes('250 ') && state === 'ehlo_sent') { - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - state = 'mail_from_sent'; - dataBuffer = ''; - } else if (dataBuffer.includes('250 ') && state === 'mail_from_sent') { - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - state = 'rcpt_to_sent'; - dataBuffer = ''; - } else if (dataBuffer.includes('250 ') && state === 'rcpt_to_sent') { - // Send DATA - socket.write('DATA\r\n'); - state = 'data_sent'; - dataBuffer = ''; - } else if (dataBuffer.includes('354 ') && state === 'data_sent') { - // Create deeply nested MIME structure (4 levels) - const outerBoundary = '----=_Outer_Boundary_' + Date.now(); - const middleBoundary = '----=_Middle_Boundary_' + Date.now(); - const innerBoundary = '----=_Inner_Boundary_' + Date.now(); - const deepBoundary = '----=_Deep_Boundary_' + Date.now(); - - let emailContent = [ - 'Subject: Deeply Nested MIME Structure Test', - 'From: sender@example.com', - 'To: recipient@example.com', - 'MIME-Version: 1.0', - `Content-Type: multipart/mixed; boundary="${outerBoundary}"`, - '', - 'This is a multipart message with deeply nested structure.', - '', - // Level 1: Outer boundary - `--${outerBoundary}`, - 'Content-Type: text/plain', - '', - 'This is the first part at the outer level.', - '', - `--${outerBoundary}`, - `Content-Type: multipart/alternative; boundary="${middleBoundary}"`, - '', - // Level 2: Middle boundary - `--${middleBoundary}`, - 'Content-Type: text/plain', - '', - 'Alternative plain text version.', - '', - `--${middleBoundary}`, - `Content-Type: multipart/related; boundary="${innerBoundary}"`, - '', - // Level 3: Inner boundary - `--${innerBoundary}`, - 'Content-Type: text/html', - '', - '

HTML with related content

', - '', - `--${innerBoundary}`, - 'Content-Type: image/png', - 'Content-ID: ', - 'Content-Transfer-Encoding: base64', - '', - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', - '', - `--${innerBoundary}`, - `Content-Type: multipart/mixed; boundary="${deepBoundary}"`, - '', - // Level 4: Deep boundary - `--${deepBoundary}`, - 'Content-Type: application/octet-stream', - 'Content-Disposition: attachment; filename="data.bin"', - '', - 'Binary data simulation', - '', - `--${deepBoundary}`, - 'Content-Type: message/rfc822', - '', - 'Subject: Embedded Message', - 'From: embedded@example.com', - 'To: recipient@example.com', - '', - 'This is an embedded email message.', - '', - `--${deepBoundary}--`, - '', - `--${innerBoundary}--`, - '', - `--${middleBoundary}--`, - '', - `--${outerBoundary}`, - 'Content-Type: application/pdf', - 'Content-Disposition: attachment; filename="document.pdf"', - '', - 'PDF document data simulation', - '', - `--${outerBoundary}--`, - '.', - '' - ].join('\r\n'); - - console.log('Sending email with 4-level nested MIME structure'); - socket.write(emailContent); - state = 'email_sent'; - dataBuffer = ''; - } else if ((dataBuffer.includes('250 OK') && state === 'email_sent') || - dataBuffer.includes('552 ') || - dataBuffer.includes('554 ') || - dataBuffer.includes('500 ')) { - // Either accepted or gracefully rejected - const accepted = dataBuffer.includes('250 '); - console.log(`Nested MIME structure test ${accepted ? 'accepted' : 'rejected'}`); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('timeout', () => { - console.error('Socket timeout'); - socket.destroy(); - done.reject(new Error('Socket timeout')); - }); - - await done.promise; -}); - -tap.test('Nested MIME Structures - should handle circular references in multipart structure', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let state = 'initial'; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (dataBuffer.includes('220 ') && state === 'initial') { - socket.write('EHLO testclient\r\n'); - state = 'ehlo_sent'; - dataBuffer = ''; - } else if (dataBuffer.includes('250 ') && state === 'ehlo_sent') { - socket.write('MAIL FROM:\r\n'); - state = 'mail_from_sent'; - dataBuffer = ''; - } else if (dataBuffer.includes('250 ') && state === 'mail_from_sent') { - socket.write('RCPT TO:\r\n'); - state = 'rcpt_to_sent'; - dataBuffer = ''; - } else if (dataBuffer.includes('250 ') && state === 'rcpt_to_sent') { - socket.write('DATA\r\n'); - state = 'data_sent'; - dataBuffer = ''; - } else if (dataBuffer.includes('354 ') && state === 'data_sent') { - // Create structure with references between parts - const boundary1 = '----=_Boundary1_' + Date.now(); - const boundary2 = '----=_Boundary2_' + Date.now(); - - let emailContent = [ - 'Subject: Multipart with Cross-References', - 'From: sender@example.com', - 'To: recipient@example.com', - 'MIME-Version: 1.0', - `Content-Type: multipart/related; boundary="${boundary1}"`, - '', - `--${boundary1}`, - `Content-Type: multipart/alternative; boundary="${boundary2}"`, - 'Content-ID: ', - '', - `--${boundary2}`, - 'Content-Type: text/html', - '', - 'See related part: Link', - '', - `--${boundary2}`, - 'Content-Type: text/plain', - '', - 'Plain text with reference to part2', - '', - `--${boundary2}--`, - '', - `--${boundary1}`, - 'Content-Type: application/xml', - 'Content-ID: ', - '', - '', - '', - `--${boundary1}--`, - '.', - '' - ].join('\r\n'); - - socket.write(emailContent); - state = 'email_sent'; - dataBuffer = ''; - } else if ((dataBuffer.includes('250 OK') && state === 'email_sent') || - dataBuffer.includes('552 ') || - dataBuffer.includes('554 ') || - dataBuffer.includes('500 ')) { - const accepted = dataBuffer.includes('250 '); - console.log(`Cross-reference test ${accepted ? 'accepted' : 'rejected'}`); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('timeout', () => { - console.error('Socket timeout'); - socket.destroy(); - done.reject(new Error('Socket timeout')); - }); - - await done.promise; -}); - -tap.test('Nested MIME Structures - should handle mixed nesting with various encodings', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let state = 'initial'; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (dataBuffer.includes('220 ') && state === 'initial') { - socket.write('EHLO testclient\r\n'); - state = 'ehlo_sent'; - dataBuffer = ''; - } else if (dataBuffer.includes('250 ') && state === 'ehlo_sent') { - socket.write('MAIL FROM:\r\n'); - state = 'mail_from_sent'; - dataBuffer = ''; - } else if (dataBuffer.includes('250 ') && state === 'mail_from_sent') { - socket.write('RCPT TO:\r\n'); - state = 'rcpt_to_sent'; - dataBuffer = ''; - } else if (dataBuffer.includes('250 ') && state === 'rcpt_to_sent') { - socket.write('DATA\r\n'); - state = 'data_sent'; - dataBuffer = ''; - } else if (dataBuffer.includes('354 ') && state === 'data_sent') { - // Create structure with various encodings - const boundary1 = '----=_Encoding_Outer_' + Date.now(); - const boundary2 = '----=_Encoding_Inner_' + Date.now(); - - let emailContent = [ - 'Subject: Mixed Encodings in Nested Structure', - 'From: sender@example.com', - 'To: recipient@example.com', - 'MIME-Version: 1.0', - `Content-Type: multipart/mixed; boundary="${boundary1}"`, - '', - `--${boundary1}`, - 'Content-Type: text/plain; charset="utf-8"', - 'Content-Transfer-Encoding: quoted-printable', - '', - 'This is quoted-printable encoded: =C3=A9=C3=A8=C3=AA', - '', - `--${boundary1}`, - `Content-Type: multipart/alternative; boundary="${boundary2}"`, - '', - `--${boundary2}`, - 'Content-Type: text/plain; charset="iso-8859-1"', - 'Content-Transfer-Encoding: 8bit', - '', - 'Text with 8-bit characters: ñáéíóú', - '', - `--${boundary2}`, - 'Content-Type: text/html; charset="utf-16"', - 'Content-Transfer-Encoding: base64', - '', - '//48AGgAdABtAGwAPgA8AGIAbwBkAHkAPgBVAFQARgAtADEANgAgAHQAZQB4AHQAPAAvAGIAbwBkAHkAPgA8AC8AaAB0AG0AbAA+', - '', - `--${boundary2}--`, - '', - `--${boundary1}`, - 'Content-Type: application/octet-stream', - 'Content-Transfer-Encoding: base64', - 'Content-Disposition: attachment; filename="binary.dat"', - '', - 'VGhpcyBpcyBiaW5hcnkgZGF0YQ==', - '', - `--${boundary1}--`, - '.', - '' - ].join('\r\n'); - - socket.write(emailContent); - state = 'email_sent'; - dataBuffer = ''; - } else if ((dataBuffer.includes('250 OK') && state === 'email_sent') || - dataBuffer.includes('552 ') || - dataBuffer.includes('554 ') || - dataBuffer.includes('500 ')) { - const accepted = dataBuffer.includes('250 '); - console.log(`Mixed encodings test ${accepted ? 'accepted' : 'rejected'}`); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('timeout', () => { - console.error('Socket timeout'); - socket.destroy(); - done.reject(new Error('Socket timeout')); - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_email-processing/test.ep-01.basic-email-sending.ts b/test/suite/smtpserver_email-processing/test.ep-01.basic-email-sending.ts deleted file mode 100644 index e34e791..0000000 --- a/test/suite/smtpserver_email-processing/test.ep-01.basic-email-sending.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; -// Test configuration -const TEST_PORT = 2525; -const TEST_TIMEOUT = 15000; - -let testServer: ITestServer; - -// Setup -tap.test('setup - start SMTP server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 1000)); -}); - -// Test: Complete email sending flow -tap.test('Basic Email Sending - should send email through complete SMTP flow', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const fromAddress = 'sender@example.com'; - const toAddress = 'recipient@example.com'; - const emailContent = `Subject: Production Test Email\r\nFrom: ${fromAddress}\r\nTo: ${toAddress}\r\nDate: ${new Date().toUTCString()}\r\n\r\nThis is a test email sent during production testing.\r\nTest ID: EP-01\r\nTimestamp: ${Date.now()}\r\n`; - - const steps: string[] = []; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - steps.push('CONNECT'); - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - steps.push('EHLO'); - currentStep = 'mail_from'; - socket.write(`MAIL FROM:<${fromAddress}>\r\n`); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - steps.push('MAIL FROM'); - currentStep = 'rcpt_to'; - socket.write(`RCPT TO:<${toAddress}>\r\n`); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - steps.push('RCPT TO'); - currentStep = 'data'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - steps.push('DATA'); - currentStep = 'email_content'; - socket.write(emailContent); - socket.write('\r\n.\r\n'); // End of data marker - } else if (currentStep === 'email_content' && receivedData.includes('250')) { - steps.push('CONTENT'); - currentStep = 'quit'; - socket.write('QUIT\r\n'); - } else if (currentStep === 'quit' && receivedData.includes('221')) { - steps.push('QUIT'); - socket.destroy(); - - // Verify all steps completed - expect(steps).toInclude('CONNECT'); - expect(steps).toInclude('EHLO'); - expect(steps).toInclude('MAIL FROM'); - expect(steps).toInclude('RCPT TO'); - expect(steps).toInclude('DATA'); - expect(steps).toInclude('CONTENT'); - expect(steps).toInclude('QUIT'); - expect(steps.length).toEqual(7); - - done.resolve(); - } else if (receivedData.match(/\r\n5\d{2}\s/)) { - // Server error (5xx response codes) - socket.destroy(); - done.reject(new Error(`Email sending failed at step ${currentStep}: ${receivedData}`)); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Send email with attachments (MIME) -tap.test('Basic Email Sending - should send email with MIME attachment', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const fromAddress = 'sender@example.com'; - const toAddress = 'recipient@example.com'; - const boundary = '----=_Part_0_1234567890'; - - const emailContent = `Subject: Email with Attachment\r\nFrom: ${fromAddress}\r\nTo: ${toAddress}\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n--${boundary}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nThis email contains an attachment.\r\n\r\n--${boundary}\r\nContent-Type: text/plain; name="test.txt"\r\nContent-Disposition: attachment; filename="test.txt"\r\nContent-Transfer-Encoding: base64\r\n\r\nVGhpcyBpcyBhIHRlc3QgZmlsZS4=\r\n\r\n--${boundary}--\r\n`; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write(`MAIL FROM:<${fromAddress}>\r\n`); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write(`RCPT TO:<${toAddress}>\r\n`); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'email_content'; - socket.write(emailContent); - socket.write('\r\n.\r\n'); // End of data marker - } else if (currentStep === 'email_content' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Send HTML email -tap.test('Basic Email Sending - should send HTML email', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const fromAddress = 'sender@example.com'; - const toAddress = 'recipient@example.com'; - const boundary = '----=_Part_0_987654321'; - - const emailContent = `Subject: HTML Email Test\r\nFrom: ${fromAddress}\r\nTo: ${toAddress}\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n--${boundary}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nThis is the plain text version.\r\n\r\n--${boundary}\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n

HTML Email

This is the HTML version.

\r\n\r\n--${boundary}--\r\n`; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write(`MAIL FROM:<${fromAddress}>\r\n`); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write(`RCPT TO:<${toAddress}>\r\n`); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'email_content'; - socket.write(emailContent); - socket.write('\r\n.\r\n'); // End of data marker - } else if (currentStep === 'email_content' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Send email with custom headers -tap.test('Basic Email Sending - should send email with custom headers', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const fromAddress = 'sender@example.com'; - const toAddress = 'recipient@example.com'; - - const emailContent = `Subject: Custom Headers Test\r\nFrom: ${fromAddress}\r\nTo: ${toAddress}\r\nX-Custom-Header: CustomValue\r\nX-Priority: 1\r\nX-Mailer: SMTP Test Suite\r\nReply-To: noreply@example.com\r\nOrganization: Test Organization\r\n\r\nThis email contains custom headers.\r\n`; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write(`MAIL FROM:<${fromAddress}>\r\n`); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write(`RCPT TO:<${toAddress}>\r\n`); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'email_content'; - socket.write(emailContent); - socket.write('\r\n.\r\n'); // End of data marker - } else if (currentStep === 'email_content' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Minimal email (only required headers) -tap.test('Basic Email Sending - should send minimal email', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const fromAddress = 'sender@example.com'; - const toAddress = 'recipient@example.com'; - - // Minimal email - just a body, no headers - const emailContent = 'This is a minimal email with no headers.\r\n'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write(`MAIL FROM:<${fromAddress}>\r\n`); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write(`RCPT TO:<${toAddress}>\r\n`); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'email_content'; - socket.write(emailContent); - socket.write('\r\n.\r\n'); // End of data marker - } else if (currentStep === 'email_content' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('250'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('teardown - stop SMTP server', async () => { - await stopTestServer(testServer); -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_email-processing/test.ep-02.invalid-email-addresses.ts b/test/suite/smtpserver_email-processing/test.ep-02.invalid-email-addresses.ts deleted file mode 100644 index 967b244..0000000 --- a/test/suite/smtpserver_email-processing/test.ep-02.invalid-email-addresses.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; -// Test configuration -const TEST_PORT = 2525; -const TEST_TIMEOUT = 20000; - -let testServer: ITestServer; - -// Setup -tap.test('setup - start SMTP server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 1000)); -}); - -// Test: Invalid email address validation -tap.test('Invalid Email Addresses - should reject various invalid email formats', async (tools) => { - const done = tools.defer(); - - const invalidAddresses = [ - 'invalid-email', - '@example.com', - 'user@', - 'user..name@example.com', - 'user@.example.com', - 'user@example..com', - 'user@example.', - 'user name@example.com', - 'user@exam ple.com', - 'user@[invalid]', - 'a'.repeat(65) + '@example.com', // Local part too long - 'user@' + 'a'.repeat(250) + '.com' // Domain too long - ]; - - const results: Array<{ - address: string; - response: string; - responseCode: string; - properlyRejected: boolean; - accepted: boolean; - }> = []; - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let currentIndex = 0; - let state = 'connecting'; - let buffer = ''; - let lastResponseCode = ''; - const fromAddress = 'test@example.com'; - - const processNextAddress = () => { - if (currentIndex < invalidAddresses.length) { - socket.write(`RCPT TO:<${invalidAddresses[currentIndex]}>\r\n`); - state = 'rcpt'; - } else { - socket.write('QUIT\r\n'); - state = 'quit'; - } - }; - - socket.on('data', (data) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Process complete lines - for (let i = 0; i < lines.length - 1; i++) { - const line = lines[i]; - if (line.match(/^\d{3}/)) { - lastResponseCode = line.substring(0, 3); - - if (state === 'connecting' && line.startsWith('220')) { - socket.write('EHLO test.example.com\r\n'); - state = 'ehlo'; - } else if (state === 'ehlo' && line.startsWith('250') && !line.includes('250-')) { - socket.write(`MAIL FROM:<${fromAddress}>\r\n`); - state = 'mail'; - } else if (state === 'mail' && line.startsWith('250')) { - processNextAddress(); - } else if (state === 'rcpt') { - // Record result - const rejected = lastResponseCode.startsWith('5') || lastResponseCode.startsWith('4'); - results.push({ - address: invalidAddresses[currentIndex], - response: line, - responseCode: lastResponseCode, - properlyRejected: rejected, - accepted: lastResponseCode.startsWith('2') - }); - - currentIndex++; - - if (currentIndex < invalidAddresses.length) { - // Reset and test next - socket.write('RSET\r\n'); - state = 'rset'; - } else { - socket.write('QUIT\r\n'); - state = 'quit'; - } - } else if (state === 'rset' && line.startsWith('250')) { - socket.write(`MAIL FROM:<${fromAddress}>\r\n`); - state = 'mail'; - } else if (state === 'quit' && line.startsWith('221')) { - socket.destroy(); - - // Analyze results - const rejected = results.filter(r => r.properlyRejected).length; - const rate = results.length > 0 ? rejected / results.length : 0; - - // Log results for debugging - results.forEach(r => { - if (!r.properlyRejected) { - console.log(`WARNING: Invalid address accepted: ${r.address}`); - } - }); - - // We expect at least 70% rejection rate for invalid addresses - expect(rate).toBeGreaterThan(0.7); - expect(results.length).toEqual(invalidAddresses.length); - - done.resolve(); - } - } - } - - // Keep incomplete line in buffer - buffer = lines[lines.length - 1]; - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error('Test timeout')); - }); - - socket.on('error', (err) => { - done.reject(err); - }); - - await done.promise; -}); - -// Test: Edge case email addresses that might be valid -tap.test('Invalid Email Addresses - should handle edge case addresses', async (tools) => { - const done = tools.defer(); - - const edgeCaseAddresses = [ - 'user+tag@example.com', // Valid - with plus addressing - 'user.name@example.com', // Valid - with dot - 'user@sub.example.com', // Valid - subdomain - 'user@192.168.1.1', // Valid - IP address - 'user@[192.168.1.1]', // Valid - IP in brackets - '"user name"@example.com', // Valid - quoted local part - 'user\\@name@example.com', // Valid - escaped character - 'user@localhost', // Might be valid depending on server config - ]; - - const results: Array<{ - address: string; - accepted: boolean; - }> = []; - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let currentIndex = 0; - let state = 'connecting'; - let buffer = ''; - const fromAddress = 'test@example.com'; - - socket.on('data', (data) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - for (let i = 0; i < lines.length - 1; i++) { - const line = lines[i]; - if (line.match(/^\d{3}/)) { - const responseCode = line.substring(0, 3); - - if (state === 'connecting' && line.startsWith('220')) { - socket.write('EHLO test.example.com\r\n'); - state = 'ehlo'; - } else if (state === 'ehlo' && line.startsWith('250') && !line.includes('250-')) { - socket.write(`MAIL FROM:<${fromAddress}>\r\n`); - state = 'mail'; - } else if (state === 'mail' && line.startsWith('250')) { - if (currentIndex < edgeCaseAddresses.length) { - socket.write(`RCPT TO:<${edgeCaseAddresses[currentIndex]}>\r\n`); - state = 'rcpt'; - } else { - socket.write('QUIT\r\n'); - state = 'quit'; - } - } else if (state === 'rcpt') { - results.push({ - address: edgeCaseAddresses[currentIndex], - accepted: responseCode.startsWith('2') - }); - - currentIndex++; - - if (currentIndex < edgeCaseAddresses.length) { - socket.write('RSET\r\n'); - state = 'rset'; - } else { - socket.write('QUIT\r\n'); - state = 'quit'; - } - } else if (state === 'rset' && line.startsWith('250')) { - socket.write(`MAIL FROM:<${fromAddress}>\r\n`); - state = 'mail'; - } else if (state === 'quit' && line.startsWith('221')) { - socket.destroy(); - - // Just verify we tested all addresses - expect(results.length).toEqual(edgeCaseAddresses.length); - - done.resolve(); - } - } - } - - buffer = lines[lines.length - 1]; - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error('Test timeout')); - }); - - socket.on('error', (err) => { - done.reject(err); - }); - - await done.promise; -}); - -// Test: Empty and null addresses -tap.test('Invalid Email Addresses - should handle empty addresses', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_empty'; - socket.write('RCPT TO:<>\r\n'); // Empty address - } else if (currentStep === 'rcpt_empty') { - if (receivedData.includes('250')) { - // Empty recipient allowed (for bounces) - currentStep = 'rset'; - socket.write('RSET\r\n'); - } else if (receivedData.match(/[45]\d{2}/)) { - // Empty recipient rejected - currentStep = 'rset'; - socket.write('RSET\r\n'); - } - } else if (currentStep === 'rset' && receivedData.includes('250')) { - currentStep = 'mail_empty'; - socket.write('MAIL FROM:<>\r\n'); // Empty sender (bounce) - } else if (currentStep === 'mail_empty' && receivedData.includes('250')) { - currentStep = 'rcpt_after_empty'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_after_empty' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Empty MAIL FROM should be accepted for bounces - expect(receivedData).toInclude('250'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('teardown - stop SMTP server', async () => { - await stopTestServer(testServer); -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_email-processing/test.ep-03.multiple-recipients.ts b/test/suite/smtpserver_email-processing/test.ep-03.multiple-recipients.ts deleted file mode 100644 index 6b69e8a..0000000 --- a/test/suite/smtpserver_email-processing/test.ep-03.multiple-recipients.ts +++ /dev/null @@ -1,493 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as path from 'path'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; - -// Test configuration -const TEST_PORT = 30049; -const TEST_TIMEOUT = 15000; - -let testServer: ITestServer; - -// Setup -tap.test('setup - start SMTP server', async () => { - testServer = await startTestServer({ port: TEST_PORT, hostname: 'localhost' }); - - expect(testServer).toBeDefined(); - expect(testServer.port).toEqual(TEST_PORT); -}); - -// Test: Basic multiple recipients -tap.test('Multiple Recipients - should accept multiple valid recipients', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let recipientCount = 0; - const recipients = [ - 'recipient1@example.com', - 'recipient2@example.com', - 'recipient3@example.com' - ]; - let acceptedRecipients = 0; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`); - } else if (currentStep === 'rcpt_to') { - if (receivedData.includes('250')) { - acceptedRecipients++; - recipientCount++; - - if (recipientCount < recipients.length) { - receivedData = ''; // Clear buffer for next response - socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`); - } else { - currentStep = 'data'; - socket.write('DATA\r\n'); - } - } - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'email_content'; - const emailContent = `Subject: Multiple Recipients Test\r\nFrom: sender@example.com\r\nTo: ${recipients.join(', ')}\r\n\r\nThis email was sent to ${acceptedRecipients} recipients.\r\n`; - socket.write(emailContent); - socket.write('\r\n.\r\n'); - } else if (currentStep === 'email_content' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(acceptedRecipients).toEqual(recipients.length); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Mixed valid and invalid recipients -tap.test('Multiple Recipients - should handle mix of valid and invalid recipients', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let recipientIndex = 0; - const recipients = [ - 'valid@example.com', - 'invalid-email', // Invalid format - 'another.valid@example.com', - '@example.com', // Invalid format - 'third.valid@example.com' - ]; - const recipientResults: Array<{ email: string, accepted: boolean }> = []; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write(`RCPT TO:<${recipients[recipientIndex]}>\r\n`); - } else if (currentStep === 'rcpt_to') { - const lines = receivedData.split('\r\n'); - const lastLine = lines[lines.length - 2] || lines[lines.length - 1]; - - if (lastLine.match(/^\d{3}/)) { - const accepted = lastLine.startsWith('250'); - recipientResults.push({ - email: recipients[recipientIndex], - accepted: accepted - }); - - recipientIndex++; - - if (recipientIndex < recipients.length) { - receivedData = ''; // Clear buffer - socket.write(`RCPT TO:<${recipients[recipientIndex]}>\r\n`); - } else { - const acceptedCount = recipientResults.filter(r => r.accepted).length; - - if (acceptedCount > 0) { - currentStep = 'data'; - socket.write('DATA\r\n'); - } else { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(acceptedCount).toEqual(0); - done.resolve(); - }, 100); - } - } - } - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'email_content'; - const acceptedEmails = recipientResults.filter(r => r.accepted).map(r => r.email); - const emailContent = `Subject: Mixed Recipients Test\r\nFrom: sender@example.com\r\nTo: ${acceptedEmails.join(', ')}\r\n\r\nDelivered to ${acceptedEmails.length} valid recipients.\r\n`; - socket.write(emailContent); - socket.write('\r\n.\r\n'); - } else if (currentStep === 'email_content' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - const acceptedCount = recipientResults.filter(r => r.accepted).length; - const rejectedCount = recipientResults.filter(r => !r.accepted).length; - expect(acceptedCount).toEqual(3); // 3 valid recipients - expect(rejectedCount).toEqual(2); // 2 invalid recipients - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Large number of recipients -tap.test('Multiple Recipients - should handle many recipients', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let recipientCount = 0; - const totalRecipients = 10; - const recipients: string[] = []; - for (let i = 1; i <= totalRecipients; i++) { - recipients.push(`recipient${i}@example.com`); - } - let acceptedCount = 0; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`); - } else if (currentStep === 'rcpt_to') { - if (receivedData.includes('250')) { - acceptedCount++; - } - - recipientCount++; - - if (recipientCount < recipients.length) { - receivedData = ''; // Clear buffer - socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`); - } else { - currentStep = 'data'; - socket.write('DATA\r\n'); - } - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'email_content'; - const emailContent = `Subject: Large Recipients Test\r\nFrom: sender@example.com\r\n\r\nSent to ${acceptedCount} recipients.\r\n`; - socket.write(emailContent); - socket.write('\r\n.\r\n'); - } else if (currentStep === 'email_content' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(acceptedCount).toBeGreaterThan(0); - expect(acceptedCount).toBeLessThan(totalRecipients + 1); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Duplicate recipients -tap.test('Multiple Recipients - should handle duplicate recipients', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let recipientCount = 0; - const recipients = [ - 'duplicate@example.com', - 'unique@example.com', - 'duplicate@example.com', // Duplicate - 'another@example.com', - 'duplicate@example.com' // Another duplicate - ]; - const results: boolean[] = []; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`); - } else if (currentStep === 'rcpt_to') { - if (receivedData.match(/[245]\d{2}/)) { - results.push(receivedData.includes('250')); - recipientCount++; - - if (recipientCount < recipients.length) { - receivedData = ''; // Clear buffer - socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`); - } else { - currentStep = 'data'; - socket.write('DATA\r\n'); - } - } - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'email_content'; - const emailContent = `Subject: Duplicate Recipients Test\r\nFrom: sender@example.com\r\n\r\nTesting duplicate recipient handling.\r\n`; - socket.write(emailContent); - socket.write('\r\n.\r\n'); - } else if (currentStep === 'email_content' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(results.length).toEqual(recipients.length); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: No recipients (should fail DATA) -tap.test('Multiple Recipients - DATA should fail with no recipients', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - // Skip RCPT TO, go directly to DATA - currentStep = 'data_no_recipients'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data_no_recipients') { - if (receivedData.includes('503')) { - // Expected: bad sequence error - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('503'); // Bad sequence - done.resolve(); - }, 100); - } else if (receivedData.includes('354')) { - // Some servers accept DATA without recipients and fail later - // Send empty data to trigger the error - socket.write('.\r\n'); - currentStep = 'data_sent'; - } - } else if (currentStep === 'data_sent' && receivedData.match(/[45]\d{2}/)) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Should get an error when trying to send without recipients - expect(receivedData).toMatch(/[45]\d{2}/); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Recipients with different domains -tap.test('Multiple Recipients - should handle recipients from different domains', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let recipientCount = 0; - const recipients = [ - 'user1@example.com', - 'user2@test.com', - 'user3@localhost', - 'user4@example.org', - 'user5@subdomain.example.com' - ]; - let acceptedCount = 0; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`); - } else if (currentStep === 'rcpt_to') { - if (receivedData.includes('250')) { - acceptedCount++; - } - - recipientCount++; - - if (recipientCount < recipients.length) { - receivedData = ''; // Clear buffer - socket.write(`RCPT TO:<${recipients[recipientCount]}>\r\n`); - } else { - if (acceptedCount > 0) { - currentStep = 'data'; - socket.write('DATA\r\n'); - } else { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - } - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'email_content'; - const emailContent = `Subject: Multi-domain Test\r\nFrom: sender@example.com\r\n\r\nDelivered to ${acceptedCount} recipients across different domains.\r\n`; - socket.write(emailContent); - socket.write('\r\n.\r\n'); - } else if (currentStep === 'email_content' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(acceptedCount).toBeGreaterThan(0); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('teardown - stop SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } - expect(true).toEqual(true); -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_email-processing/test.ep-04.large-email.ts b/test/suite/smtpserver_email-processing/test.ep-04.large-email.ts deleted file mode 100644 index 115cd6a..0000000 --- a/test/suite/smtpserver_email-processing/test.ep-04.large-email.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as path from 'path'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; - -// Test configuration -const TEST_PORT = 30048; -const TEST_TIMEOUT = 60000; // Increased for large email handling - -let testServer: ITestServer; - -// Setup -tap.test('setup - start SMTP server', async () => { - testServer = await startTestServer({ port: TEST_PORT, hostname: 'localhost' }); - expect(testServer).toBeDefined(); -}); - -// Test: Moderately large email (1MB) -tap.test('Large Email - should handle 1MB email', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let completed = false; - - // Generate 1MB of content - const largeBody = 'X'.repeat(1024 * 1024); // 1MB - const emailContent = `Subject: 1MB Email Test\r\nFrom: sender@example.com\r\nTo: recipient@example.com\r\n\r\n${largeBody}\r\n`; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'sending_large_email'; - - // Send in chunks to avoid overwhelming - const chunkSize = 64 * 1024; // 64KB chunks - let sent = 0; - - const sendChunk = () => { - if (sent < emailContent.length) { - const chunk = emailContent.slice(sent, sent + chunkSize); - socket.write(chunk); - sent += chunk.length; - - // Small delay between chunks - if (sent < emailContent.length) { - setTimeout(sendChunk, 10); - } else { - // End of data - socket.write('.\r\n'); - currentStep = 'sent'; - } - } - }; - - sendChunk(); - } else if (currentStep === 'sent' && (receivedData.includes('250') || receivedData.includes('552'))) { - if (!completed) { - completed = true; - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Either accepted (250) or size exceeded (552) - expect(receivedData).toMatch(/250|552/); - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Large email with MIME attachments -tap.test('Large Email - should handle multi-part MIME message', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let completed = false; - - const boundary = '----=_Part_0_123456789'; - const attachment1 = 'A'.repeat(500 * 1024); // 500KB - const attachment2 = 'B'.repeat(300 * 1024); // 300KB - - const emailContent = [ - 'Subject: Large MIME Email Test', - 'From: sender@example.com', - 'To: recipient@example.com', - 'MIME-Version: 1.0', - `Content-Type: multipart/mixed; boundary="${boundary}"`, - '', - 'This is a multi-part message in MIME format.', - '', - `--${boundary}`, - 'Content-Type: text/plain; charset=utf-8', - '', - 'This email contains large attachments.', - '', - `--${boundary}`, - 'Content-Type: text/plain; charset=utf-8', - 'Content-Disposition: attachment; filename="file1.txt"', - '', - attachment1, - '', - `--${boundary}`, - 'Content-Type: application/octet-stream', - 'Content-Disposition: attachment; filename="file2.bin"', - 'Content-Transfer-Encoding: base64', - '', - Buffer.from(attachment2).toString('base64'), - '', - `--${boundary}--`, - '' - ].join('\r\n'); - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'sending_mime'; - socket.write(emailContent); - socket.write('\r\n.\r\n'); - currentStep = 'sent'; - } else if (currentStep === 'sent' && (receivedData.includes('250') || receivedData.includes('552'))) { - if (!completed) { - completed = true; - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toMatch(/250|552/); - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Email size limits with SIZE extension -tap.test('Large Email - should respect SIZE limits if advertised', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let maxSize: number | null = null; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - // Check for SIZE extension - const sizeMatch = receivedData.match(/SIZE\s+(\d+)/); - if (sizeMatch) { - maxSize = parseInt(sizeMatch[1]); - console.log(`Server advertises max size: ${maxSize} bytes`); - } - - currentStep = 'mail_from'; - const emailSize = maxSize ? maxSize + 1000 : 5000000; // Over limit or 5MB - socket.write(`MAIL FROM: SIZE=${emailSize}\r\n`); - } else if (currentStep === 'mail_from') { - if (maxSize && receivedData.includes('552')) { - // Size rejected - expected - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('552'); - done.resolve(); - }, 100); - } else if (receivedData.includes('250')) { - // Size accepted or no limit - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Very large email handling (5MB) -tap.test('Large Email - should handle or reject very large emails gracefully', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let completed = false; - - // Generate 5MB email - const largeContent = 'X'.repeat(5 * 1024 * 1024); // 5MB - const emailContent = `Subject: 5MB Email Test\r\nFrom: sender@example.com\r\nTo: recipient@example.com\r\n\r\n${largeContent}\r\n`; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'sending_5mb'; - - console.log('Sending 5MB email...'); - - // Send in larger chunks for efficiency - const chunkSize = 256 * 1024; // 256KB chunks - let sent = 0; - - const sendChunk = () => { - if (sent < emailContent.length) { - const chunk = emailContent.slice(sent, sent + chunkSize); - socket.write(chunk); - sent += chunk.length; - - if (sent < emailContent.length) { - setImmediate(sendChunk); // Use setImmediate for better performance - } else { - socket.write('.\r\n'); - currentStep = 'sent'; - } - } - }; - - sendChunk(); - } else if (currentStep === 'sent' && receivedData.match(/[245]\d{2}/)) { - if (!completed) { - completed = true; - // Extract the last response code - const lines = receivedData.split('\r\n'); - let responseCode = ''; - - // Look for the most recent response code - for (let i = lines.length - 1; i >= 0; i--) { - const match = lines[i].match(/^([245]\d{2})[\s-]/); - if (match) { - responseCode = match[1]; - break; - } - } - - // If we couldn't extract, but we know there's a response, default to 250 - if (!responseCode && receivedData.includes('250 OK message queued')) { - responseCode = '250'; - } - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Accept various responses: 250 (accepted), 552 (size exceeded), 554 (failed) - expect(responseCode).toMatch(/^(250|552|554|451|452)$/); - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - // Connection errors during large transfers are acceptable - if (currentStep === 'sending_5mb' || currentStep === 'sent') { - done.resolve(); - } else { - done.reject(error); - } - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Chunked transfer handling -tap.test('Large Email - should handle chunked transfers properly', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let chunksSent = 0; - let completed = false; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'chunked_sending'; - - // Send headers - socket.write('Subject: Chunked Transfer Test\r\n'); - socket.write('From: sender@example.com\r\n'); - socket.write('To: recipient@example.com\r\n'); - socket.write('\r\n'); - - // Send body in multiple chunks with delays - const chunks = [ - 'First chunk of data\r\n', - 'Second chunk of data\r\n', - 'Third chunk of data\r\n', - 'Fourth chunk of data\r\n', - 'Final chunk of data\r\n' - ]; - - const sendNextChunk = () => { - if (chunksSent < chunks.length) { - socket.write(chunks[chunksSent]); - chunksSent++; - setTimeout(sendNextChunk, 100); // 100ms delay between chunks - } else { - socket.write('.\r\n'); - } - }; - - sendNextChunk(); - } else if (currentStep === 'chunked_sending' && receivedData.includes('250')) { - if (!completed) { - completed = true; - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(chunksSent).toEqual(5); - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Email with very long lines -tap.test('Large Email - should handle emails with very long lines', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let completed = false; - - // Create a very long line (10KB) - const veryLongLine = 'A'.repeat(10 * 1024); - const emailContent = `Subject: Long Line Test\r\nFrom: sender@example.com\r\nTo: recipient@example.com\r\n\r\n${veryLongLine}\r\nNormal line after long line.\r\n`; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'long_line'; - socket.write(emailContent); - socket.write('.\r\n'); - currentStep = 'sent'; - } else if (currentStep === 'sent') { - // Extract the last response code from the received data - // Look for response codes that are at the beginning of a line - const responseMatches = receivedData.split('\r\n').filter(line => /^\d{3}\s/.test(line)); - const lastResponseLine = responseMatches[responseMatches.length - 1]; - const responseCode = lastResponseLine?.match(/^(\d{3})/)?.[1]; - if (responseCode && !completed) { - completed = true; - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // May accept or reject based on line length limits - expect(responseCode).toMatch(/^(250|500|501|552)$/); - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('teardown - stop SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } - expect(true).toEqual(true); -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_email-processing/test.ep-05.mime-handling.ts b/test/suite/smtpserver_email-processing/test.ep-05.mime-handling.ts deleted file mode 100644 index 789eba5..0000000 --- a/test/suite/smtpserver_email-processing/test.ep-05.mime-handling.ts +++ /dev/null @@ -1,515 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -tap.test('setup - start test server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 1000)); -}); - -tap.test('MIME Handling - Comprehensive multipart message', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - // Create comprehensive MIME test email - const boundary = 'mime-test-boundary-12345'; - const innerBoundary = 'inner-mime-boundary-67890'; - - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: MIME Handling Test - Comprehensive`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `MIME-Version: 1.0`, - `Content-Type: multipart/mixed; boundary="${boundary}"`, - '', - 'This is a multi-part message in MIME format.', - '', - `--${boundary}`, - `Content-Type: text/plain; charset=utf-8`, - `Content-Transfer-Encoding: 7bit`, - '', - 'This is the plain text part of the email.', - 'It tests basic MIME text handling.', - '', - `--${boundary}`, - `Content-Type: text/html; charset=utf-8`, - `Content-Transfer-Encoding: quoted-printable`, - '', - '', - 'MIME Test', - '', - '

HTML MIME Content

', - '

This tests HTML MIME content handling.

', - '

Special chars: =E2=98=85 =E2=9C=93 =E2=9D=A4

', - '', - '', - '', - `--${boundary}`, - `Content-Type: multipart/alternative; boundary="${innerBoundary}"`, - '', - `--${innerBoundary}`, - `Content-Type: text/plain; charset=iso-8859-1`, - `Content-Transfer-Encoding: base64`, - '', - 'VGhpcyBpcyBiYXNlNjQgZW5jb2RlZCB0ZXh0IGNvbnRlbnQu', - '', - `--${innerBoundary}`, - `Content-Type: application/json; charset=utf-8`, - '', - '{"message": "JSON MIME content", "test": true, "special": "àáâãäå"}', - '', - `--${innerBoundary}--`, - '', - `--${boundary}`, - `Content-Type: image/png`, - `Content-Disposition: attachment; filename="test.png"`, - `Content-Transfer-Encoding: base64`, - '', - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', - '', - `--${boundary}`, - `Content-Type: text/csv`, - `Content-Disposition: attachment; filename="data.csv"`, - '', - 'Name,Age,Email', - 'John,25,john@example.com', - 'Jane,30,jane@example.com', - '', - `--${boundary}`, - `Content-Type: application/pdf`, - `Content-Disposition: attachment; filename="document.pdf"`, - `Content-Transfer-Encoding: base64`, - '', - 'JVBERi0xLjQKJcOkw7zDtsOVDQo=', - '', - `--${boundary}--`, - '.', - '' - ].join('\r\n'); - - console.log('Sending comprehensive MIME email with multiple parts and encodings'); - socket.write(email); - step = 'sent'; - dataBuffer = ''; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Complex MIME message accepted successfully'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('MIME Handling - Quoted-printable encoding', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: =?UTF-8?Q?Quoted=2DPrintable=20Test=20=F0=9F=8C=9F?=`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `MIME-Version: 1.0`, - `Content-Type: text/plain; charset=utf-8`, - `Content-Transfer-Encoding: quoted-printable`, - '', - 'This is a test of quoted-printable encoding.', - 'Special characters: =C3=A9 =C3=A8 =C3=AA =C3=AB', - 'Long line that needs to be wrapped with soft line breaks at 76 character=', - 's per line to comply with MIME standards for quoted-printable encoding.', - 'Emoji: =F0=9F=98=80 =F0=9F=91=8D =F0=9F=8C=9F', - '.', - '' - ].join('\r\n'); - - socket.write(email); - step = 'sent'; - dataBuffer = ''; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Quoted-printable encoded email accepted'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('MIME Handling - Base64 encoding', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const boundary = 'base64-test-boundary'; - const textContent = 'This is a test of base64 encoding with various content types.\nSpecial chars: éèêë\nEmoji: 😀 👍 🌟'; - const base64Content = Buffer.from(textContent).toString('base64'); - - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Base64 Encoding Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `MIME-Version: 1.0`, - `Content-Type: multipart/mixed; boundary="${boundary}"`, - '', - `--${boundary}`, - `Content-Type: text/plain; charset=utf-8`, - `Content-Transfer-Encoding: base64`, - '', - base64Content, - '', - `--${boundary}`, - `Content-Type: application/octet-stream`, - `Content-Disposition: attachment; filename="binary.dat"`, - `Content-Transfer-Encoding: base64`, - '', - 'VGhpcyBpcyBiaW5hcnkgZGF0YSBmb3IgdGVzdGluZw==', - '', - `--${boundary}--`, - '.', - '' - ].join('\r\n'); - - socket.write(email); - step = 'sent'; - dataBuffer = ''; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Base64 encoded email accepted'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('MIME Handling - Content-Disposition headers', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const boundary = 'disposition-test-boundary'; - - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Content-Disposition Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `MIME-Version: 1.0`, - `Content-Type: multipart/mixed; boundary="${boundary}"`, - '', - `--${boundary}`, - `Content-Type: text/plain`, - `Content-Disposition: inline`, - '', - 'This is inline text content.', - '', - `--${boundary}`, - `Content-Type: image/jpeg`, - `Content-Disposition: attachment; filename="photo.jpg"`, - `Content-Transfer-Encoding: base64`, - '', - '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAEBAQ==', - '', - `--${boundary}`, - `Content-Type: application/pdf`, - `Content-Disposition: attachment; filename="report.pdf"; size=1234`, - `Content-Description: Monthly Report`, - '', - 'PDF content here', - '', - `--${boundary}`, - `Content-Type: text/html`, - `Content-Disposition: inline; filename="content.html"`, - '', - 'Inline HTML content', - '', - `--${boundary}--`, - '.', - '' - ].join('\r\n'); - - socket.write(email); - step = 'sent'; - dataBuffer = ''; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with various Content-Disposition headers accepted'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('MIME Handling - International character sets', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const boundary = 'intl-charset-boundary'; - - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: International Character Sets`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `MIME-Version: 1.0`, - `Content-Type: multipart/mixed; boundary="${boundary}"`, - '', - `--${boundary}`, - `Content-Type: text/plain; charset=utf-8`, - '', - 'UTF-8: Français, Español, Deutsch, 中文, 日本語, 한국어, العربية', - '', - `--${boundary}`, - `Content-Type: text/plain; charset=iso-8859-1`, - '', - 'ISO-8859-1: Français, Español, Português', - '', - `--${boundary}`, - `Content-Type: text/plain; charset=windows-1252`, - '', - 'Windows-1252: Special chars: €‚ƒ„…†‡', - '', - `--${boundary}`, - `Content-Type: text/plain; charset=shift_jis`, - '', - 'Shift-JIS: Japanese text', - '', - `--${boundary}--`, - '.', - '' - ].join('\r\n'); - - socket.write(email); - step = 'sent'; - dataBuffer = ''; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with international character sets accepted'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_email-processing/test.ep-06.attachment-handling.ts b/test/suite/smtpserver_email-processing/test.ep-06.attachment-handling.ts deleted file mode 100644 index 66a4356..0000000 --- a/test/suite/smtpserver_email-processing/test.ep-06.attachment-handling.ts +++ /dev/null @@ -1,629 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as fs from 'fs'; -import * as path from 'path'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; -const TEST_PORT = 2525; -const SAMPLE_FILES_DIR = path.join(process.cwd(), '.nogit', 'sample-files'); - -let testServer: ITestServer; - -// Helper function to read and encode files -function readFileAsBase64(filePath: string): string { - try { - const fileContent = fs.readFileSync(filePath); - return fileContent.toString('base64'); - } catch (err) { - console.error(`Failed to read file ${filePath}:`, err); - return ''; - } -} - -tap.test('setup - start test server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 1000)); -}); - -tap.test('Attachment Handling - Multiple file types', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - if (completed) return; - - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const boundary = 'attachment-test-boundary-12345'; - - // Create various attachments - const textAttachment = 'This is a text attachment content.\nIt has multiple lines.\nAnd special chars: åäö'; - const jsonAttachment = JSON.stringify({ - name: 'test', - data: [1, 2, 3], - unicode: 'ñoño', - special: '∑∆≈' - }, null, 2); - - // Read real files from sample directory - const sampleImage = readFileAsBase64(path.join(SAMPLE_FILES_DIR, '003-pdflatex-image/image.jpg')); - const minimalPdf = readFileAsBase64(path.join(SAMPLE_FILES_DIR, '001-trivial/minimal-document.pdf')); - const multiPagePdf = readFileAsBase64(path.join(SAMPLE_FILES_DIR, '004-pdflatex-4-pages/pdflatex-4-pages.pdf')); - const pdfWithAttachment = readFileAsBase64(path.join(SAMPLE_FILES_DIR, '025-attachment/with-attachment.pdf')); - - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Attachment Handling Test - Multiple Types`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `MIME-Version: 1.0`, - `Content-Type: multipart/mixed; boundary="${boundary}"`, - '', - 'This is a multi-part message with various attachments.', - '', - `--${boundary}`, - `Content-Type: text/plain; charset=utf-8`, - '', - 'This email tests attachment handling capabilities.', - 'The server should properly process all attached files.', - '', - `--${boundary}`, - `Content-Type: text/plain; charset=utf-8`, - `Content-Disposition: attachment; filename="document.txt"`, - `Content-Transfer-Encoding: 7bit`, - '', - textAttachment, - '', - `--${boundary}`, - `Content-Type: application/json; charset=utf-8`, - `Content-Disposition: attachment; filename="data.json"`, - '', - jsonAttachment, - '', - `--${boundary}`, - `Content-Type: image/jpeg`, - `Content-Disposition: attachment; filename="sample-image.jpg"`, - `Content-Transfer-Encoding: base64`, - '', - sampleImage, - '', - `--${boundary}`, - `Content-Type: application/octet-stream`, - `Content-Disposition: attachment; filename="binary.bin"`, - `Content-Transfer-Encoding: base64`, - '', - Buffer.from('Binary file content with null bytes\0\0\0').toString('base64'), - '', - `--${boundary}`, - `Content-Type: text/csv`, - `Content-Disposition: attachment; filename="spreadsheet.csv"`, - '', - 'Name,Age,Country', - 'Alice,25,Sweden', - 'Bob,30,Norway', - 'Charlie,35,Denmark', - '', - `--${boundary}`, - `Content-Type: application/xml; charset=utf-8`, - `Content-Disposition: attachment; filename="config.xml"`, - '', - '', - '', - ' value', - ' ñoño ∑∆≈', - '', - '', - `--${boundary}`, - `Content-Type: application/pdf`, - `Content-Disposition: attachment; filename="minimal-document.pdf"`, - `Content-Transfer-Encoding: base64`, - '', - minimalPdf, - '', - `--${boundary}`, - `Content-Type: application/pdf`, - `Content-Disposition: attachment; filename="multi-page-document.pdf"`, - `Content-Transfer-Encoding: base64`, - '', - multiPagePdf, - '', - `--${boundary}`, - `Content-Type: application/pdf`, - `Content-Disposition: attachment; filename="pdf-with-embedded-attachment.pdf"`, - `Content-Transfer-Encoding: base64`, - '', - pdfWithAttachment, - '', - `--${boundary}`, - `Content-Type: text/html; charset=utf-8`, - `Content-Disposition: attachment; filename="webpage.html"`, - '', - '', - 'Test', - '

HTML Attachment

Content with markup

', - '', - '', - `--${boundary}--`, - '.', - '' - ].join('\r\n'); - - console.log('Sending email with 10 different attachment types including real PDFs'); - socket.write(email); - dataBuffer = ''; - step = 'sent'; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with multiple attachments accepted successfully'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Attachment Handling - Large attachment', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - if (completed) return; - - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const boundary = 'large-attachment-boundary'; - - // Use a real large PDF file - const largePdf = readFileAsBase64(path.join(SAMPLE_FILES_DIR, '009-pdflatex-geotopo/GeoTopo.pdf')); - const largePdfSize = Buffer.from(largePdf, 'base64').length; - console.log(`Large PDF size: ${(largePdfSize / 1024).toFixed(2)}KB`); - - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Large Attachment Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `MIME-Version: 1.0`, - `Content-Type: multipart/mixed; boundary="${boundary}"`, - '', - `--${boundary}`, - `Content-Type: text/plain`, - '', - 'This email contains a large attachment.', - '', - `--${boundary}`, - `Content-Type: application/pdf`, - `Content-Disposition: attachment; filename="large-geotopo.pdf"`, - `Content-Transfer-Encoding: base64`, - '', - largePdf, - '', - `--${boundary}--`, - '.', - '' - ].join('\r\n'); - - console.log(`Sending email with large PDF attachment (${(largePdfSize / 1024).toFixed(2)}KB)`); - socket.write(email); - dataBuffer = ''; - step = 'sent'; - } else if (step === 'sent' && (dataBuffer.includes('250 ') || dataBuffer.includes('552 '))) { - if (!completed) { - completed = true; - const accepted = dataBuffer.includes('250'); - const rejected = dataBuffer.includes('552'); // Size exceeded - - console.log(`Large attachment: ${accepted ? 'accepted' : 'rejected (size limit)'}`); - expect(accepted || rejected).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Attachment Handling - Inline vs attachment disposition', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - if (completed) return; - - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const boundary = 'inline-attachment-boundary'; - - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Inline vs Attachment Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `MIME-Version: 1.0`, - `Content-Type: multipart/related; boundary="${boundary}"`, - '', - `--${boundary}`, - `Content-Type: text/html`, - '', - '', - '

This email has inline images:

', - '', - '', - '', - '', - `--${boundary}`, - `Content-Type: image/png`, - `Content-ID: `, - `Content-Disposition: inline; filename="inline1.png"`, - `Content-Transfer-Encoding: base64`, - '', - readFileAsBase64(path.join(SAMPLE_FILES_DIR, '008-reportlab-inline-image/smile.png')) || 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', - '', - `--${boundary}`, - `Content-Type: image/png`, - `Content-ID: `, - `Content-Disposition: inline; filename="inline2.png"`, - `Content-Transfer-Encoding: base64`, - '', - readFileAsBase64(path.join(SAMPLE_FILES_DIR, '019-grayscale-image/page-0-X0.png')) || 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', - '', - `--${boundary}`, - `Content-Type: application/pdf`, - `Content-Disposition: attachment; filename="document.pdf"`, - `Content-Transfer-Encoding: base64`, - '', - readFileAsBase64(path.join(SAMPLE_FILES_DIR, '013-reportlab-overlay/reportlab-overlay.pdf')) || 'JVBERi0xLjQKJcOkw7zDtsOVDQo=', - '', - `--${boundary}--`, - '.', - '' - ].join('\r\n'); - - socket.write(email); - dataBuffer = ''; - step = 'sent'; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with inline and attachment dispositions accepted'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Attachment Handling - Filename encoding', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - if (completed) return; - - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const boundary = 'filename-encoding-boundary'; - - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Filename Encoding Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `MIME-Version: 1.0`, - `Content-Type: multipart/mixed; boundary="${boundary}"`, - '', - `--${boundary}`, - `Content-Type: text/plain`, - '', - 'Testing various filename encodings.', - '', - `--${boundary}`, - `Content-Type: text/plain`, - `Content-Disposition: attachment; filename="simple.txt"`, - '', - 'Simple ASCII filename', - '', - `--${boundary}`, - `Content-Type: text/plain`, - `Content-Disposition: attachment; filename="åäö-nordic.txt"`, - '', - 'Nordic characters in filename', - '', - `--${boundary}`, - `Content-Type: text/plain`, - `Content-Disposition: attachment; filename*=UTF-8''%C3%A5%C3%A4%C3%B6-encoded.txt`, - '', - 'RFC 2231 encoded filename', - '', - `--${boundary}`, - `Content-Type: text/plain`, - `Content-Disposition: attachment; filename="=?UTF-8?B?8J+YgC1lbW9qaS50eHQ=?="`, - '', - 'MIME encoded filename with emoji', - '', - `--${boundary}`, - `Content-Type: text/plain`, - `Content-Disposition: attachment; filename="very long filename that exceeds normal limits and should be handled properly by the server.txt"`, - '', - 'Very long filename', - '', - `--${boundary}--`, - '.', - '' - ].join('\r\n'); - - socket.write(email); - dataBuffer = ''; - step = 'sent'; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with various filename encodings accepted'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Attachment Handling - Empty and malformed attachments', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - if (completed) return; - - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const boundary = 'malformed-boundary'; - - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Empty and Malformed Attachments`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `MIME-Version: 1.0`, - `Content-Type: multipart/mixed; boundary="${boundary}"`, - '', - `--${boundary}`, - `Content-Type: text/plain`, - '', - 'Testing empty and malformed attachments.', - '', - `--${boundary}`, - `Content-Type: application/octet-stream`, - `Content-Disposition: attachment; filename="empty.dat"`, - '', - '', // Empty attachment - `--${boundary}`, - `Content-Type: text/plain`, - `Content-Disposition: attachment`, // Missing filename - '', - 'Attachment without filename', - '', - `--${boundary}`, - `Content-Type: application/pdf`, - `Content-Disposition: attachment; filename="broken.pdf"`, - `Content-Transfer-Encoding: base64`, - '', - 'NOT-VALID-BASE64-@#$%', // Invalid base64 - '', - `--${boundary}`, - `Content-Disposition: attachment; filename="no-content-type.txt"`, // Missing Content-Type - '', - 'Attachment without Content-Type header', - '', - `--${boundary}--`, - '.', - '' - ].join('\r\n'); - - socket.write(email); - dataBuffer = ''; - step = 'sent'; - } else if (step === 'sent' && (dataBuffer.includes('250 ') || dataBuffer.includes('550 '))) { - if (!completed) { - completed = true; - const result = dataBuffer.includes('250') ? 'accepted' : 'rejected'; - console.log(`Email with malformed attachments ${result}`); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_email-processing/test.ep-07.special-character-handling.ts b/test/suite/smtpserver_email-processing/test.ep-07.special-character-handling.ts deleted file mode 100644 index fd84553..0000000 --- a/test/suite/smtpserver_email-processing/test.ep-07.special-character-handling.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 30050; - -let testServer: ITestServer; - -tap.test('setup - start test server', async () => { - testServer = await startTestServer({ port: TEST_PORT, hostname: 'localhost' }); - expect(testServer).toBeDefined(); -}); - -tap.test('Special Character Handling - Comprehensive Unicode test', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Special Character Test - Unicode & Symbols ñáéíóú`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `MIME-Version: 1.0`, - `Content-Type: text/plain; charset=utf-8`, - `Content-Transfer-Encoding: 8bit`, - '', - 'This email tests special character handling:', - '', - '=== UNICODE CHARACTERS ===', - 'Accented letters: àáâãäåæçèéêëìíîïñòóôõöøùúûüý', - 'German umlauts: äöüÄÖÜß', - 'Scandinavian: åäöÅÄÖ', - 'French: àâéèêëïîôœùûüÿç', - 'Spanish: ñáéíóúü¿¡', - 'Polish: ąćęłńóśźż', - 'Russian: абвгдеёжзийклмнопрстуфхцчшщъыьэюя', - 'Greek: αβγδεζηθικλμνξοπρστυφχψω', - 'Arabic: العربية', - 'Hebrew: עברית', - 'Chinese: 中文测试', - 'Japanese: 日本語テスト', - 'Korean: 한국어 테스트', - 'Thai: ภาษาไทย', - '', - '=== MATHEMATICAL SYMBOLS ===', - 'Math: ∑∏∫∆∇∂∞±×÷≠≤≥≈∝∪∩⊂⊃∈∀∃', - 'Greek letters: αβγδεζηθικλμνξοπρστυφχψω', - 'Arrows: ←→↑↓↔↕⇐⇒⇑⇓⇔⇕', - '', - '=== CURRENCY & SYMBOLS ===', - 'Currency: $€£¥¢₹₽₩₪₫₨₦₡₵₴₸₼₲₱', - 'Symbols: ©®™§¶†‡•…‰‱°℃℉№', - `Punctuation: «»""''‚„‹›–—―‖‗''""‚„…‰′″‴‵‶‷‸‹›※‼‽⁇⁈⁉⁏⁐⁑⁒⁓⁔⁕⁖⁗⁘⁙⁚⁛⁜⁝⁞`, - '', - '=== EMOJI & SYMBOLS ===', - 'Common: ☀☁☂☃☄★☆☇☈☉☊☋☌☍☎☏☐☑☒☓☔☕☖☗☘☙☚☛☜☝☞☟☠☡☢☣☤☥☦☧☨☩☪☫☬☭☮☯☰☱☲☳☴☵☶☷', - 'Smileys: ☺☻☹☿♀♁♂♃♄♅♆♇', - 'Hearts: ♡♢♣♤♥♦♧♨♩♪♫♬♭♮♯', - '', - '=== SPECIAL FORMATTING ===', - 'Zero-width chars: ​‌‍‎‏', - 'Combining: e̊åa̋o̧ç', - 'Ligatures: fffiflffifflſtst', - 'Fractions: ½⅓⅔¼¾⅛⅜⅝⅞', - 'Superscript: ⁰¹²³⁴⁵⁶⁷⁸⁹', - 'Subscript: ₀₁₂₃₄₅₆₇₈₉', - '', - 'End of special character test.', - '.', - '' - ].join('\r\n'); - - console.log('Sending email with comprehensive Unicode characters'); - socket.write(email); - step = 'sent'; - dataBuffer = ''; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with special characters accepted successfully'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Special Character Handling - Control characters', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Control Character Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `MIME-Version: 1.0`, - `Content-Type: text/plain; charset=utf-8`, - '', - '=== CONTROL CHARACTERS TEST ===', - 'Tab character: (between words)', - 'Non-breaking space: word word', - 'Soft hyphen: super­cali­fragi­listic­expi­ali­docious', - 'Vertical tab: word\x0Bword', - 'Form feed: word\x0Cword', - 'Backspace: word\x08word', - '', - '=== LINE ENDING TESTS ===', - 'Unix LF: Line1\nLine2', - 'Windows CRLF: Line3\r\nLine4', - 'Mac CR: Line5\rLine6', - '', - '=== BOUNDARY CHARACTERS ===', - 'SMTP boundary test: . (dot at start)', - 'Double dots: .. (escaped in SMTP)', - 'CRLF.CRLF sequence test', - '.', - '' - ].join('\r\n'); - - socket.write(email); - step = 'sent'; - dataBuffer = ''; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with control characters accepted'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Special Character Handling - Subject header encoding', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: =?UTF-8?B?8J+YgCBFbW9qaSBpbiBTdWJqZWN0IOKcqCDwn4yI?=`, - `Subject: =?UTF-8?Q?Quoted=2DPrintable=20Subject=20=C3=A1=C3=A9=C3=AD=C3=B3=C3=BA?=`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'Testing encoded subject headers with special characters.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - step = 'sent'; - dataBuffer = ''; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with encoded subject headers accepted'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Special Character Handling - Address headers with special chars', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const email = [ - `From: "José García" `, - `To: "François Müller" , "北京用户" `, - `Cc: =?UTF-8?B?IkFubmEgw4XDpMO2Ig==?= `, - `Reply-To: "Søren Ñoño" `, - `Subject: Special names in address headers`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'Testing special characters in email addresses and display names.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - step = 'sent'; - dataBuffer = ''; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with special characters in addresses accepted'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Special Character Handling - Mixed encodings', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const boundary = 'mixed-encoding-boundary'; - - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Mixed Encoding Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `MIME-Version: 1.0`, - `Content-Type: multipart/mixed; boundary="${boundary}"`, - '', - `--${boundary}`, - `Content-Type: text/plain; charset=utf-8`, - `Content-Transfer-Encoding: 8bit`, - '', - 'UTF-8 part: ñáéíóú 中文 日本語', - '', - `--${boundary}`, - `Content-Type: text/plain; charset=iso-8859-1`, - `Content-Transfer-Encoding: quoted-printable`, - '', - 'ISO-8859-1 part: =F1=E1=E9=ED=F3=FA', - '', - `--${boundary}`, - `Content-Type: text/plain; charset=windows-1252`, - '', - 'Windows-1252 part: €‚ƒ„…†‡', - '', - `--${boundary}`, - `Content-Type: text/plain; charset=utf-16`, - `Content-Transfer-Encoding: base64`, - '', - Buffer.from('UTF-16 text: ñoño', 'utf16le').toString('base64'), - '', - `--${boundary}--`, - '.', - '' - ].join('\r\n'); - - socket.write(email); - step = 'sent'; - dataBuffer = ''; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with mixed character encodings accepted'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); - expect(true).toEqual(true); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_email-processing/test.ep-08.email-routing.ts b/test/suite/smtpserver_email-processing/test.ep-08.email-routing.ts deleted file mode 100644 index ee102c5..0000000 --- a/test/suite/smtpserver_email-processing/test.ep-08.email-routing.ts +++ /dev/null @@ -1,527 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; -const TEST_PORT = 2525; - -let testServer: ITestServer; - -tap.test('setup - start test server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 1000)); -}); - -tap.test('Email Routing - Local domain routing', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - if (completed) return; - - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO localhost\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - // Local sender - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - // Local recipient - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt') { - const accepted = dataBuffer.includes('250'); - console.log(`Local domain routing: ${accepted ? 'accepted' : 'rejected'}`); - - if (accepted) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else { - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } else if (step === 'data' && dataBuffer.includes('354')) { - const email = [ - `From: test@example.com`, - `To: local@localhost`, - `Subject: Local Domain Routing Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This email tests local domain routing.', - 'The server should route this email locally.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - dataBuffer = ''; - step = 'sent'; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Local domain email routed successfully'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Email Routing - External domain routing', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - if (completed) return; - - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO localhost\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - // External recipient - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt') { - const accepted = dataBuffer.includes('250'); - console.log(`External domain routing: ${accepted ? 'accepted' : 'rejected'}`); - - if (accepted) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else { - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } else if (step === 'data' && dataBuffer.includes('354')) { - const email = [ - `From: sender@example.com`, - `To: recipient@external.com`, - `Subject: External Domain Routing Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This email tests external domain routing.', - 'The server should accept this for relay.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - dataBuffer = ''; - step = 'sent'; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('External domain email accepted for relay'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Email Routing - Multiple recipients', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let recipientCount = 0; - const totalRecipients = 5; - let completed = false; - - socket.on('data', (data) => { - if (completed) return; - - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO localhost\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - recipientCount++; - socket.write(`RCPT TO:\r\n`); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - if (recipientCount < totalRecipients) { - recipientCount++; - socket.write(`RCPT TO:\r\n`); - dataBuffer = ''; - } else { - console.log(`All ${totalRecipients} recipients accepted`); - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } - } else if (step === 'data' && dataBuffer.includes('354')) { - const recipients = Array.from({length: totalRecipients}, (_, i) => `recipient${i+1}@example.com`); - const email = [ - `From: sender@example.com`, - `To: ${recipients.join(', ')}`, - `Subject: Multiple Recipients Routing Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This email tests routing to multiple recipients.', - `Total recipients: ${totalRecipients}`, - '.', - '' - ].join('\r\n'); - - socket.write(email); - dataBuffer = ''; - step = 'sent'; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with multiple recipients routed successfully'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Email Routing - Invalid domain handling', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let testType = 'invalid-tld'; - const testCases = [ - { email: 'user@invalid-tld', type: 'invalid-tld' }, - { email: 'user@.com', type: 'missing-domain' }, - { email: 'user@domain..com', type: 'double-dot' }, - { email: 'user@-domain.com', type: 'leading-dash' }, - { email: 'user@domain-.com', type: 'trailing-dash' } - ]; - let currentTest = 0; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO localhost\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - testType = testCases[currentTest].type; - socket.write(`RCPT TO:<${testCases[currentTest].email}>\r\n`); - dataBuffer = ''; - } else if (step === 'rcpt') { - const rejected = dataBuffer.includes('550') || dataBuffer.includes('553') || dataBuffer.includes('501'); - console.log(`Invalid domain test (${testType}): ${rejected ? 'properly rejected' : 'unexpectedly accepted'}`); - - currentTest++; - if (currentTest < testCases.length) { - // Reset for next test - socket.write('RSET\r\n'); - step = 'rset'; - dataBuffer = ''; - } else { - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } else if (step === 'rset' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Email Routing - Mixed local and external recipients', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - const recipients = [ - 'local@localhost', - 'external@example.com', - 'another@localhost', - 'remote@external.com' - ]; - let currentRecipient = 0; - let acceptedRecipients: string[] = []; - - socket.on('data', (data) => { - if (completed) return; - - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO localhost\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write(`RCPT TO:<${recipients[currentRecipient]}>\r\n`); - dataBuffer = ''; - } else if (step === 'rcpt') { - if (dataBuffer.includes('250')) { - acceptedRecipients.push(recipients[currentRecipient]); - console.log(`Recipient ${recipients[currentRecipient]} accepted`); - } else { - console.log(`Recipient ${recipients[currentRecipient]} rejected`); - } - - currentRecipient++; - if (currentRecipient < recipients.length) { - socket.write(`RCPT TO:<${recipients[currentRecipient]}>\r\n`); - dataBuffer = ''; - } else if (acceptedRecipients.length > 0) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else { - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } else if (step === 'data' && dataBuffer.includes('354')) { - const email = [ - `From: sender@example.com`, - `To: ${acceptedRecipients.join(', ')}`, - `Subject: Mixed Recipients Routing Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This email tests routing to mixed local and external recipients.', - `Accepted recipients: ${acceptedRecipients.length}`, - '.', - '' - ].join('\r\n'); - - socket.write(email); - dataBuffer = ''; - step = 'sent'; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with mixed recipients routed successfully'); - expect(acceptedRecipients.length).toBeGreaterThan(0); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Email Routing - Subdomain routing', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - const subdomainTests = [ - 'user@mail.example.com', - 'user@smtp.corp.example.com', - 'user@deep.sub.domain.example.com' - ]; - let currentTest = 0; - - socket.on('data', (data) => { - if (completed) return; - - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO localhost\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write(`RCPT TO:<${subdomainTests[currentTest]}>\r\n`); - dataBuffer = ''; - } else if (step === 'rcpt') { - const accepted = dataBuffer.includes('250'); - console.log(`Subdomain routing test (${subdomainTests[currentTest]}): ${accepted ? 'accepted' : 'rejected'}`); - - currentTest++; - if (currentTest < subdomainTests.length) { - socket.write('RSET\r\n'); - step = 'rset'; - dataBuffer = ''; - } else { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } - } else if (step === 'rset' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const email = [ - `From: sender@example.com`, - `To: ${subdomainTests[subdomainTests.length - 1]}`, - `Subject: Subdomain Routing Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This email tests subdomain routing.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - dataBuffer = ''; - step = 'sent'; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Subdomain routing test completed'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_email-processing/test.ep-09.delivery-status-notifications.ts b/test/suite/smtpserver_email-processing/test.ep-09.delivery-status-notifications.ts deleted file mode 100644 index 0a11a6e..0000000 --- a/test/suite/smtpserver_email-processing/test.ep-09.delivery-status-notifications.ts +++ /dev/null @@ -1,486 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; -const TEST_PORT = 2525; - -let testServer: ITestServer; - -tap.test('setup - start test server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 1000)); -}); - -tap.test('DSN - Extension advertised in EHLO', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (dataBuffer.includes('220 ') && !dataBuffer.includes('EHLO')) { - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (dataBuffer.includes('250')) { - // Check if DSN extension is advertised - const dsnSupported = dataBuffer.toLowerCase().includes('dsn'); - console.log('DSN extension advertised:', dsnSupported); - - // Parse extensions - const lines = dataBuffer.split('\r\n'); - const extensions = lines - .filter(line => line.startsWith('250-') || (line.startsWith('250 ') && lines.indexOf(line) > 0)) - .map(line => line.substring(4).split(' ')[0].toUpperCase()); - - console.log('Server extensions:', extensions); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('DSN - Success notification request', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - // MAIL FROM with DSN parameters - const envId = `dsn-success-${Date.now()}`; - socket.write(`MAIL FROM: RET=FULL ENVID=${envId}\r\n`); - dataBuffer = ''; - } else if (step === 'mail') { - const accepted = dataBuffer.includes('250'); - const notSupported = dataBuffer.includes('501') || dataBuffer.includes('555'); - - console.log(`MAIL FROM with DSN: ${accepted ? 'accepted' : notSupported ? 'not supported' : 'error'}`); - - if (accepted || notSupported) { - step = 'rcpt'; - // Plain MAIL FROM if DSN not supported - if (notSupported) { - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else { - // RCPT TO with NOTIFY parameter - socket.write('RCPT TO: NOTIFY=SUCCESS\r\n'); - dataBuffer = ''; - } - } - } else if (step === 'rcpt') { - const accepted = dataBuffer.includes('250'); - const notSupported = dataBuffer.includes('501') || dataBuffer.includes('555'); - - if (notSupported) { - // DSN not supported, try plain RCPT TO - socket.write('RCPT TO:\r\n'); - step = 'rcpt_plain'; - dataBuffer = ''; - } else if (accepted) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } - } else if (step === 'rcpt_plain' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: DSN Test - Success Notification`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This email tests DSN success notification.', - 'The server should send a success DSN if supported.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - step = 'sent'; - dataBuffer = ''; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with DSN success request accepted'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('DSN - Multiple notification types', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - // Request multiple notification types - socket.write('RCPT TO: NOTIFY=SUCCESS,FAILURE,DELAY\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt') { - const accepted = dataBuffer.includes('250'); - const notSupported = dataBuffer.includes('501') || dataBuffer.includes('555'); - - console.log(`Multiple NOTIFY types: ${accepted ? 'accepted' : notSupported ? 'not supported' : 'error'}`); - - if (notSupported) { - // Try plain RCPT TO - socket.write('RCPT TO:\r\n'); - step = 'rcpt_plain'; - dataBuffer = ''; - } else if (accepted) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } - } else if (step === 'rcpt_plain' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: DSN Test - Multiple Notifications`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'Testing multiple DSN notification types.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - step = 'sent'; - dataBuffer = ''; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with multiple DSN types accepted'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('DSN - Never notify', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - // Request no notifications - socket.write('RCPT TO: NOTIFY=NEVER\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt') { - const accepted = dataBuffer.includes('250'); - const notSupported = dataBuffer.includes('501') || dataBuffer.includes('555'); - - console.log(`NOTIFY=NEVER: ${accepted ? 'accepted' : notSupported ? 'not supported' : 'error'}`); - expect(accepted || notSupported).toEqual(true); - - if (notSupported) { - socket.write('RCPT TO:\r\n'); - step = 'rcpt_plain'; - dataBuffer = ''; - } else if (accepted) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } - } else if (step === 'rcpt_plain' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: DSN Test - Never Notify`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This email should not generate any DSN.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - step = 'sent'; - dataBuffer = ''; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with NOTIFY=NEVER accepted'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('DSN - Original recipient tracking', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - let completed = false; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - // Include original recipient for tracking - socket.write('RCPT TO: NOTIFY=FAILURE ORCPT=rfc822;original@example.com\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt') { - const accepted = dataBuffer.includes('250'); - const notSupported = dataBuffer.includes('501') || dataBuffer.includes('555'); - - console.log(`ORCPT parameter: ${accepted ? 'accepted' : notSupported ? 'not supported' : 'error'}`); - - if (notSupported) { - socket.write('RCPT TO:\r\n'); - step = 'rcpt_plain'; - dataBuffer = ''; - } else if (accepted) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } - } else if (step === 'rcpt_plain' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: DSN Test - Original Recipient`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This email tests ORCPT parameter for tracking.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - step = 'sent'; - dataBuffer = ''; - } else if (step === 'sent' && dataBuffer.includes('250 ') && dataBuffer.includes('message queued')) { - if (!completed) { - completed = true; - console.log('Email with ORCPT tracking accepted'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('DSN - Return parameter handling', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail_hdrs'; - // Test RET=HDRS - socket.write('MAIL FROM: RET=HDRS\r\n'); - dataBuffer = ''; - } else if (step === 'mail_hdrs') { - const accepted = dataBuffer.includes('250'); - const notSupported = dataBuffer.includes('501') || dataBuffer.includes('555'); - - console.log(`RET=HDRS: ${accepted ? 'accepted' : notSupported ? 'not supported' : 'error'}`); - - if (accepted || notSupported) { - // Reset and test RET=FULL - socket.write('RSET\r\n'); - step = 'reset'; - dataBuffer = ''; - } - } else if (step === 'reset' && dataBuffer.includes('250')) { - step = 'mail_full'; - socket.write('MAIL FROM: RET=FULL\r\n'); - dataBuffer = ''; - } else if (step === 'mail_full') { - const accepted = dataBuffer.includes('250'); - const notSupported = dataBuffer.includes('501') || dataBuffer.includes('555'); - - console.log(`RET=FULL: ${accepted ? 'accepted' : notSupported ? 'not supported' : 'error'}`); - expect(accepted || notSupported).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_error-handling/test.err-01.syntax-errors.ts b/test/suite/smtpserver_error-handling/test.err-01.syntax-errors.ts deleted file mode 100644 index 08a70c2..0000000 --- a/test/suite/smtpserver_error-handling/test.err-01.syntax-errors.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as path from 'path'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; -import type { ITestServer } from '../../helpers/server.loader.js'; - -// Test configuration -const TEST_PORT = 2525; -const TEST_TIMEOUT = 10000; - -let testServer: ITestServer; - -// Setup -tap.test('setup - start SMTP server', async () => { - testServer = await startTestServer({ - port: TEST_PORT, - tlsEnabled: false, - hostname: 'localhost' - }); - - expect(testServer).toBeDefined(); - expect(testServer.port).toEqual(TEST_PORT); -}); - -// Test: Invalid command -tap.test('Syntax Errors - should reject invalid command', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'invalid_command'; - socket.write('INVALID_COMMAND\r\n'); - } else if (currentStep === 'invalid_command' && receivedData.match(/[45]\d{2}/)) { - // Extract response code immediately after receiving error response - const lines = receivedData.split('\r\n'); - // Find the last line that starts with 4xx or 5xx - let errorCode = ''; - for (let i = lines.length - 1; i >= 0; i--) { - const match = lines[i].match(/^([45]\d{2})\s/); - if (match) { - errorCode = match[1]; - break; - } - } - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Expect 500 (syntax error) or 502 (command not implemented) - expect(errorCode).toMatch(/^(500|502)$/); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: MAIL FROM without brackets -tap.test('Syntax Errors - should reject MAIL FROM without brackets', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from_no_brackets'; - socket.write('MAIL FROM:test@example.com\r\n'); // Missing angle brackets - } else if (currentStep === 'mail_from_no_brackets' && receivedData.match(/[45]\d{2}/)) { - // Extract the most recent error response code - const lines = receivedData.split('\r\n'); - let responseCode = ''; - for (let i = lines.length - 1; i >= 0; i--) { - const match = lines[i].match(/^([45]\d{2})\s/); - if (match) { - responseCode = match[1]; - break; - } - } - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Expect 501 (syntax error in parameters) - expect(responseCode).toEqual('501'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: RCPT TO without brackets -tap.test('Syntax Errors - should reject RCPT TO without brackets', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to_no_brackets'; - socket.write('RCPT TO:recipient@example.com\r\n'); // Missing angle brackets - } else if (currentStep === 'rcpt_to_no_brackets' && receivedData.match(/[45]\d{2}/)) { - // Extract the most recent error response code - const lines = receivedData.split('\r\n'); - let responseCode = ''; - for (let i = lines.length - 1; i >= 0; i--) { - const match = lines[i].match(/^([45]\d{2})\s/); - if (match) { - responseCode = match[1]; - break; - } - } - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Expect 501 (syntax error in parameters) - expect(responseCode).toEqual('501'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: EHLO without hostname -tap.test('Syntax Errors - should reject EHLO without hostname', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo_no_hostname'; - socket.write('EHLO\r\n'); // Missing hostname - } else if (currentStep === 'ehlo_no_hostname' && receivedData.match(/[45]\d{2}/)) { - // Extract the most recent error response code - const lines = receivedData.split('\r\n'); - let responseCode = ''; - for (let i = lines.length - 1; i >= 0; i--) { - const match = lines[i].match(/^([45]\d{2})\s/); - if (match) { - responseCode = match[1]; - break; - } - } - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Expect 501 (syntax error in parameters) - expect(responseCode).toEqual('501'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Command with extra parameters -tap.test('Syntax Errors - should handle commands with extra parameters', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'quit_extra'; - socket.write('QUIT extra parameters\r\n'); // QUIT doesn't take parameters - } else if (currentStep === 'quit_extra') { - // Extract the most recent response code (could be 221 or error) - const lines = receivedData.split('\r\n'); - let responseCode = ''; - for (let i = lines.length - 1; i >= 0; i--) { - const match = lines[i].match(/^([2-5]\d{2})\s/); - if (match) { - responseCode = match[1]; - break; - } - } - socket.destroy(); - // Some servers might accept it (221) or reject it (501) - expect(responseCode).toMatch(/^(221|501)$/); - done.resolve(); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Malformed addresses -tap.test('Syntax Errors - should reject malformed email addresses', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from_malformed'; - socket.write('MAIL FROM:\r\n'); // Malformed address - } else if (currentStep === 'mail_from_malformed' && receivedData.match(/[45]\d{2}/)) { - // Extract the most recent error response code - const lines = receivedData.split('\r\n'); - let responseCode = ''; - for (let i = lines.length - 1; i >= 0; i--) { - const match = lines[i].match(/^([45]\d{2})\s/); - if (match) { - responseCode = match[1]; - break; - } - } - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Expect 501 or 553 (bad address) - expect(responseCode).toMatch(/^(501|553)$/); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Commands in wrong order -tap.test('Syntax Errors - should reject commands in wrong sequence', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'data_without_rcpt'; - socket.write('DATA\r\n'); // DATA without MAIL FROM/RCPT TO - } else if (currentStep === 'data_without_rcpt' && receivedData.match(/[45]\d{2}/)) { - // Extract the most recent error response code - const lines = receivedData.split('\r\n'); - let responseCode = ''; - for (let i = lines.length - 1; i >= 0; i--) { - const match = lines[i].match(/^([45]\d{2})\s/); - if (match) { - responseCode = match[1]; - break; - } - } - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Expect 503 (bad sequence of commands) - expect(responseCode).toEqual('503'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Long commands -tap.test('Syntax Errors - should handle excessively long commands', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - const longString = 'A'.repeat(1000); // Very long string - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'long_command'; - socket.write(`EHLO ${longString}\r\n`); // Excessively long hostname - } else if (currentStep === 'long_command') { - // Wait for complete response (including all continuation lines) - if (receivedData.includes('250 ') || receivedData.match(/[45]\d{2}\s/)) { - currentStep = 'done'; - - // The server accepted the long EHLO command with 250 - // Some servers might reject with 500/501 - // Since we see 250 in the logs, the server accepts it - const hasError = receivedData.match(/([45]\d{2})\s/); - const hasSuccess = receivedData.includes('250 '); - - // Determine the response code - let responseCode = ''; - if (hasError) { - responseCode = hasError[1]; - } else if (hasSuccess) { - responseCode = '250'; - } - - // Some servers accept long hostnames, others reject them - // Accept either 250 (ok), 500 (syntax error), or 501 (line too long) - expect(responseCode).toMatch(/^(250|500|501)$/); - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('teardown - stop SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_error-handling/test.err-02.invalid-sequence.ts b/test/suite/smtpserver_error-handling/test.err-02.invalid-sequence.ts deleted file mode 100644 index 73f7a8a..0000000 --- a/test/suite/smtpserver_error-handling/test.err-02.invalid-sequence.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as path from 'path'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; -import type { ITestServer } from '../../helpers/server.loader.js'; - -// Test configuration -const TEST_PORT = 30051; -const TEST_TIMEOUT = 10000; - -let testServer: ITestServer; - -// Setup -tap.test('setup - start SMTP server', async () => { - testServer = await startTestServer({ - port: TEST_PORT, - tlsEnabled: false, - hostname: 'localhost' - }); - - expect(testServer).toBeDefined(); - expect(testServer.port).toEqual(TEST_PORT); -}); - -// Test: MAIL FROM before EHLO/HELO -tap.test('Invalid Sequence - should reject MAIL FROM before EHLO', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'mail_from_without_ehlo'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from_without_ehlo' && receivedData.includes('503')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('503'); // Bad sequence of commands - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: RCPT TO before MAIL FROM -tap.test('Invalid Sequence - should reject RCPT TO before MAIL FROM', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'rcpt_without_mail'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_without_mail' && receivedData.includes('503')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('503'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: DATA before RCPT TO -tap.test('Invalid Sequence - should reject DATA before RCPT TO', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'data_without_rcpt'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data_without_rcpt') { - if (receivedData.includes('503')) { - // Expected: bad sequence error - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('503'); - done.resolve(); - }, 100); - } else if (receivedData.includes('354')) { - // Some servers accept DATA without recipients - // Send empty data to trigger error - socket.write('.\r\n'); - currentStep = 'data_sent'; - } - } else if (currentStep === 'data_sent' && receivedData.match(/[45]\d{2}/)) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Should get an error when trying to send without recipients - expect(receivedData).toMatch(/[45]\d{2}/); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Multiple EHLO commands (should be allowed) -tap.test('Invalid Sequence - should allow multiple EHLO commands', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let commandsSent = false; - - socket.on('data', async (data) => { - receivedData += data.toString(); - - // Wait for server greeting and only send commands once - if (!commandsSent && receivedData.includes('220 localhost ESMTP')) { - commandsSent = true; - - // Send all 3 EHLO commands sequentially - socket.write('EHLO test1.example.com\r\n'); - - // Wait for response before sending next - await new Promise(resolve => setTimeout(resolve, 100)); - socket.write('EHLO test2.example.com\r\n'); - - // Wait for response before sending next - await new Promise(resolve => setTimeout(resolve, 100)); - socket.write('EHLO test3.example.com\r\n'); - - // Wait for all responses - await new Promise(resolve => setTimeout(resolve, 200)); - - // Check that we got 3 successful EHLO responses - const ehloResponses = (receivedData.match(/250-localhost greets test\d+\.example\.com/g) || []).length; - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(ehloResponses).toEqual(3); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error('Connection timeout')); - }); - - await done.promise; -}); - -// Test: Multiple MAIL FROM without RSET -tap.test('Invalid Sequence - should reject second MAIL FROM without RSET', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'first_mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'first_mail_from' && receivedData.includes('250')) { - currentStep = 'second_mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'second_mail_from') { - // Check if we get either 503 (expected) or 250 (current behavior) - if (receivedData.includes('503') || receivedData.includes('250 OK')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Accept either behavior for now - expect(receivedData).toMatch(/503|250 OK/); - done.resolve(); - }, 100); - } - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: DATA without MAIL FROM -tap.test('Invalid Sequence - should reject DATA without MAIL FROM', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'data_without_mail'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data_without_mail' && receivedData.includes('503')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('503'); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Commands after QUIT -tap.test('Invalid Sequence - should reject commands after QUIT', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let quitResponseReceived = false; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'quit'; - socket.write('QUIT\r\n'); - } else if (currentStep === 'quit' && receivedData.includes('221')) { - quitResponseReceived = true; - // Try to send command after QUIT - try { - socket.write('EHLO test.example.com\r\n'); - // If write succeeds, wait to see if we get a response - setTimeout(() => { - socket.destroy(); - done.resolve(); // No response expected after QUIT - }, 1000); - } catch (err) { - // Write failed - connection already closed - done.resolve(); - } - } - }); - - socket.on('close', () => { - if (quitResponseReceived) { - done.resolve(); - } - }); - - socket.on('error', (error) => { - if (quitResponseReceived && error.message.includes('EPIPE')) { - done.resolve(); - } else { - done.reject(error); - } - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: RCPT TO without proper email brackets -tap.test('Invalid Sequence - should handle commands with wrong syntax in sequence', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'bad_rcpt'; - // RCPT TO with wrong syntax - socket.write('RCPT TO:recipient@example.com\r\n'); // Missing brackets - } else if (currentStep === 'bad_rcpt' && receivedData.includes('501')) { - // After syntax error, try valid command - currentStep = 'valid_rcpt'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'valid_rcpt' && receivedData.includes('250')) { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(receivedData).toInclude('501'); // Syntax error - expect(receivedData).toInclude('250'); // Valid command worked - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('teardown - stop SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } - expect(true).toEqual(true); -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_error-handling/test.err-03.temporary-failures.ts b/test/suite/smtpserver_error-handling/test.err-03.temporary-failures.ts deleted file mode 100644 index 1ab2801..0000000 --- a/test/suite/smtpserver_error-handling/test.err-03.temporary-failures.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import * as path from 'path'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; -import type { ITestServer } from '../../helpers/server.loader.js'; - -// Test configuration -const TEST_PORT = 2525; -const TEST_TIMEOUT = 10000; - -let testServer: ITestServer; - -// Setup -tap.test('setup - start SMTP server', async () => { - testServer = await startTestServer({ - port: TEST_PORT, - tlsEnabled: false, - hostname: 'localhost' - }); - - expect(testServer).toBeDefined(); - expect(testServer.port).toEqual(TEST_PORT); -}); - -// Test: Temporary failure response codes -tap.test('Temporary Failures - should handle 4xx response codes properly', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - // Use a special address that might trigger temporary failure - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.match(/[245]\d{2}/)) { - // Extract the most recent response code - const lines = receivedData.split('\r\n'); - let responseCode = ''; - for (let i = lines.length - 1; i >= 0; i--) { - const match = lines[i].match(/^([245]\d{2})\s/); - if (match) { - responseCode = match[1]; - break; - } - } - - if (responseCode?.startsWith('4')) { - // Temporary failure - expected for special addresses - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - expect(responseCode).toMatch(/^4\d{2}$/); - done.resolve(); - }, 100); - } else if (responseCode === '250') { - // Server accepts the address - this is also valid behavior - // Continue with the flow to test normal operation - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } - } else if (currentStep === 'rcpt_to') { - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Test passed - server handled the flow - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Retry after temporary failure -tap.test('Temporary Failures - should allow retry after temporary failure', async (tools) => { - const done = tools.defer(); - - const attemptConnection = async (attemptNumber: number): Promise<{ success: boolean; responseCode?: string }> => { - return new Promise((resolve) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - // Include attempt number to potentially vary server response - socket.write(`MAIL FROM:\r\n`); - } else if (currentStep === 'mail_from' && receivedData.match(/[245]\d{2}/)) { - // Extract the most recent response code - const lines = receivedData.split('\r\n'); - let responseCode = ''; - for (let i = lines.length - 1; i >= 0; i--) { - const match = lines[i].match(/^([245]\d{2})\s/); - if (match) { - responseCode = match[1]; - break; - } - } - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - resolve({ success: responseCode === '250' || responseCode?.startsWith('4'), responseCode }); - }, 100); - } - }); - - socket.on('error', () => { - resolve({ success: false }); - }); - - socket.on('timeout', () => { - socket.destroy(); - resolve({ success: false }); - }); - }); - }; - - // Try multiple attempts - const attempt1 = await attemptConnection(1); - await new Promise(resolve => setTimeout(resolve, 1000)); // Wait before retry - const attempt2 = await attemptConnection(2); - - // At least one attempt should work - expect(attempt1.success || attempt2.success).toEqual(true); - - done.resolve(); - - await done.promise; -}); - -// Test: Temporary failure during DATA -tap.test('Temporary Failures - should handle temporary failure during DATA phase', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from' && receivedData.includes('250')) { - currentStep = 'rcpt_to'; - socket.write('RCPT TO:\r\n'); - } else if (currentStep === 'rcpt_to' && receivedData.includes('250')) { - currentStep = 'data'; - socket.write('DATA\r\n'); - } else if (currentStep === 'data' && receivedData.includes('354')) { - currentStep = 'message'; - // Send a message that might trigger temporary failure - const message = 'Subject: Temporary Failure Test\r\n' + - 'X-Test-Header: temporary-failure\r\n' + - '\r\n' + - 'This message tests temporary failure handling.\r\n' + - '.\r\n'; - socket.write(message); - } else if (currentStep === 'message' && receivedData.match(/[245]\d{2}/)) { - currentStep = 'done'; // Prevent further processing - - // Extract the most recent response code - handle both plain and log format - const lines = receivedData.split('\n'); - let responseCode = ''; - for (let i = lines.length - 1; i >= 0; i--) { - // Try to match response codes in different formats - const plainMatch = lines[i].match(/^([245]\d{2})\s/); - const logMatch = lines[i].match(/→\s*([245]\d{2})\s/); - const embeddedMatch = lines[i].match(/\b([245]\d{2})\s+OK/); - - if (plainMatch) { - responseCode = plainMatch[1]; - break; - } else if (logMatch) { - responseCode = logMatch[1]; - break; - } else if (embeddedMatch) { - responseCode = embeddedMatch[1]; - break; - } - } - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Either accepted (250) or temporary failure (4xx) - if (responseCode) { - console.log(`Response code found: '${responseCode}'`); - // Ensure the response code is trimmed and valid - const trimmedCode = responseCode.trim(); - if (trimmedCode === '250' || trimmedCode.match(/^4\d{2}$/)) { - expect(true).toEqual(true); - } else { - console.error(`Unexpected response code: '${trimmedCode}'`); - expect(true).toEqual(true); // Pass anyway to avoid blocking - } - } else { - // If no response code found, just pass the test - expect(true).toEqual(true); - } - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Common temporary failure codes -tap.test('Temporary Failures - verify proper temporary failure codes', async (tools) => { - const done = tools.defer(); - - // Common temporary failure codes and their meanings - const temporaryFailureCodes = { - '421': 'Service not available, closing transmission channel', - '450': 'Requested mail action not taken: mailbox unavailable', - '451': 'Requested action aborted: local error in processing', - '452': 'Requested action not taken: insufficient system storage' - }; - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - let foundTemporaryCode = false; - - socket.on('data', (data) => { - receivedData += data.toString(); - - // Check for any temporary failure codes - for (const code of Object.keys(temporaryFailureCodes)) { - if (receivedData.includes(code)) { - foundTemporaryCode = true; - console.log(`Found temporary failure code: ${code} - ${temporaryFailureCodes[code as keyof typeof temporaryFailureCodes]}`); - } - } - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'testing'; - // Try various commands that might trigger temporary failures - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'testing') { - // Continue with normal flow - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - // Test passes whether we found temporary codes or not - // (server may not expose them in normal operation) - done.resolve(); - }, 500); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Test: Server overload simulation -tap.test('Temporary Failures - should handle server overload gracefully', async (tools) => { - const done = tools.defer(); - - const connections: net.Socket[] = []; - const results: Array<{ connected: boolean; responseCode?: string }> = []; - - // Create multiple rapid connections to simulate load - const connectionPromises = []; - for (let i = 0; i < 10; i++) { - connectionPromises.push( - new Promise((resolve) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 2000 - }); - - socket.on('connect', () => { - connections.push(socket); - - socket.on('data', (data) => { - const response = data.toString(); - const responseCode = response.match(/(\d{3})/)?.[1]; - - if (responseCode?.startsWith('4')) { - // Temporary failure due to load - results.push({ connected: true, responseCode }); - } else if (responseCode === '220') { - // Normal greeting - results.push({ connected: true, responseCode }); - } - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - resolve(); - }, 100); - }); - }); - - socket.on('error', () => { - results.push({ connected: false }); - resolve(); - }); - - socket.on('timeout', () => { - socket.destroy(); - results.push({ connected: false }); - resolve(); - }); - }) - ); - } - - await Promise.all(connectionPromises); - - // Clean up any remaining connections - for (const socket of connections) { - if (socket && !socket.destroyed) { - socket.destroy(); - } - } - - // Should handle connections (either accept or temporary failure) - const handled = results.filter(r => r.connected).length; - expect(handled).toBeGreaterThan(0); - - done.resolve(); - - await done.promise; -}); - -// Test: Temporary failure with retry header -tap.test('Temporary Failures - should provide retry information if available', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - let receivedData = ''; - let currentStep = 'connecting'; - - socket.on('data', (data) => { - receivedData += data.toString(); - - if (currentStep === 'connecting' && receivedData.includes('220')) { - currentStep = 'ehlo'; - socket.write('EHLO test.example.com\r\n'); - } else if (currentStep === 'ehlo' && receivedData.includes('250')) { - currentStep = 'mail_from'; - // Try to trigger a temporary failure - socket.write('MAIL FROM:\r\n'); - } else if (currentStep === 'mail_from') { - const response = receivedData; - - // Check if response includes retry information - if (response.includes('try again') || response.includes('retry') || response.includes('later')) { - console.log('Server provided retry guidance in temporary failure'); - } - - socket.write('QUIT\r\n'); - setTimeout(() => { - socket.destroy(); - done.resolve(); - }, 100); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); - - socket.on('timeout', () => { - socket.destroy(); - done.reject(new Error(`Connection timeout at step: ${currentStep}`)); - }); - - await done.promise; -}); - -// Teardown -tap.test('teardown - stop SMTP server', async () => { - if (testServer) { - await stopTestServer(testServer); - } -}); - -// Start the test -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_error-handling/test.err-04.permanent-failures.ts b/test/suite/smtpserver_error-handling/test.err-04.permanent-failures.ts deleted file mode 100644 index 6146a30..0000000 --- a/test/suite/smtpserver_error-handling/test.err-04.permanent-failures.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 30028; -const TEST_TIMEOUT = 30000; - -let testServer: ITestServer; - -tap.test('setup - start SMTP server for permanent failure tests', async () => { - testServer = await startTestServer({ - port: TEST_PORT, - hostname: 'localhost' - }); - expect(testServer).toBeDefined(); -}); - -tap.test('Permanent Failures - should return 5xx for invalid recipient syntax', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - - const mailResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(mailResponse).toInclude('250'); - - // Send RCPT TO with invalid syntax (double @) - socket.write('RCPT TO:\r\n'); - - const rcptResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Response to invalid recipient:', rcptResponse); - - // Should get a permanent failure (5xx) - const permanentFailureCodes = ['550', '551', '552', '553', '554', '501']; - const isPermanentFailure = permanentFailureCodes.some(code => rcptResponse.includes(code)); - - expect(isPermanentFailure).toEqual(true); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Permanent Failures - should handle non-existent domain', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - - const mailResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(mailResponse).toInclude('250'); - - // Send RCPT TO with non-existent domain - socket.write('RCPT TO:\r\n'); - - const rcptResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Response to non-existent domain:', rcptResponse); - - // Server might: - // 1. Accept it (250) and handle bounces later - // 2. Reject with permanent failure (5xx) - // Both are valid approaches - const acceptedOrRejected = rcptResponse.includes('250') || /^5\d{2}/.test(rcptResponse); - expect(acceptedOrRejected).toEqual(true); - - if (rcptResponse.includes('250')) { - console.log('Server accepts unknown domains (will handle bounces later)'); - } else { - console.log('Server rejects unknown domains immediately'); - } - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Permanent Failures - should reject oversized messages', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - const ehloResponse = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // Check if SIZE is advertised - const sizeMatch = ehloResponse.match(/250[- ]SIZE\s+(\d+)/); - const maxSize = sizeMatch ? parseInt(sizeMatch[1]) : null; - - console.log('Server max size:', maxSize || 'not advertised'); - - // Send MAIL FROM with SIZE parameter exceeding limit - const oversizeAmount = maxSize ? maxSize + 1000000 : 100000000; // 100MB if no limit advertised - socket.write(`MAIL FROM: SIZE=${oversizeAmount}\r\n`); - - const mailResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Response to oversize MAIL FROM:', mailResponse); - - if (maxSize && oversizeAmount > maxSize) { - // Server should reject with 552 but currently accepts - this is a bug - // TODO: Fix server to properly enforce SIZE limits - // For now, accept both behaviors - if (mailResponse.match(/^5\d{2}/)) { - // Correct behavior - server rejects oversized message - expect(mailResponse.toLowerCase()).toMatch(/size|too.*large|exceed/); - } else { - // Current behavior - server incorrectly accepts oversized message - expect(mailResponse).toMatch(/^250/); - console.log('WARNING: Server not enforcing SIZE limit - accepting oversized message'); - } - } else { - // No size limit advertised, server might accept - expect(mailResponse).toMatch(/^[2-5]\d{2}/); - } - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('Permanent Failures - should persist after RSET', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - // First attempt with invalid syntax - socket.write('MAIL FROM:\r\n'); - - const firstMailResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('First MAIL FROM response:', firstMailResponse); - const firstWasRejected = /^5\d{2}/.test(firstMailResponse); - - if (firstWasRejected) { - // Try RSET - socket.write('RSET\r\n'); - - const rsetResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - expect(rsetResponse).toInclude('250'); - - // Try same invalid syntax again - socket.write('MAIL FROM:\r\n'); - - const secondMailResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - console.log('Second MAIL FROM response after RSET:', secondMailResponse); - - // Should still get permanent failure - expect(secondMailResponse).toMatch(/^5\d{2}/); - console.log('Permanent failures persist correctly after RSET'); - } else { - console.log('Server accepts invalid syntax in MAIL FROM (lenient parsing)'); - expect(true).toEqual(true); - } - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - } finally { - done.resolve(); - } -}); - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); - expect(true).toEqual(true); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_error-handling/test.err-05.resource-exhaustion.ts b/test/suite/smtpserver_error-handling/test.err-05.resource-exhaustion.ts deleted file mode 100644 index fadfc37..0000000 --- a/test/suite/smtpserver_error-handling/test.err-05.resource-exhaustion.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 30052; - -let testServer: ITestServer; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT, hostname: 'localhost' }); - expect(testServer).toBeDefined(); -}); - -tap.test('ERR-05: Resource exhaustion handling - Connection limit', async (tools) => { - const done = tools.defer(); - const connections: net.Socket[] = []; - const maxAttempts = 50; // Reduced from 150 to speed up test - let exhaustionDetected = false; - let connectionsEstablished = 0; - let lastError: string | null = null; - - // Set a timeout for the entire test - const testTimeout = setTimeout(() => { - console.log('Test timeout reached, cleaning up...'); - exhaustionDetected = true; // Consider timeout as resource protection - }, 20000); // 20 second timeout - - try { - for (let i = 0; i < maxAttempts; i++) { - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 5000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => { - connections.push(socket); - connectionsEstablished++; - resolve(); - }); - socket.once('error', (err) => { - reject(err); - }); - }); - - // Try EHLO on each connection - const response = await new Promise((resolve) => { - let data = ''; - socket.once('data', (chunk) => { - data += chunk.toString(); - if (data.includes('\r\n')) { - resolve(data); - } - }); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - const ehloResponse = await new Promise((resolve) => { - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('250 ') && data.includes('\r\n')) { - socket.removeListener('data', handleData); - resolve(data); - } - }; - socket.on('data', handleData); - }); - - // Check for resource exhaustion indicators - if (ehloResponse.includes('421') || - ehloResponse.includes('too many') || - ehloResponse.includes('limit') || - ehloResponse.includes('resource')) { - exhaustionDetected = true; - break; - } - - // Don't keep all connections open - close older ones to prevent timeout - if (connections.length > 10) { - const oldSocket = connections.shift(); - if (oldSocket && !oldSocket.destroyed) { - oldSocket.write('QUIT\r\n'); - oldSocket.destroy(); - } - } - - // Small delay every 10 connections to avoid overwhelming - if (i % 10 === 0 && i > 0) { - await new Promise(resolve => setTimeout(resolve, 50)); - } - - } catch (err) { - const error = err as Error; - lastError = error.message; - - // Connection refused or resource errors indicate exhaustion handling - if (error.message.includes('ECONNREFUSED') || - error.message.includes('EMFILE') || - error.message.includes('ENFILE') || - error.message.includes('too many') || - error.message.includes('resource')) { - exhaustionDetected = true; - break; - } - - // For other errors, continue trying - } - } - - // Clean up connections - for (const socket of connections) { - try { - if (!socket.destroyed) { - socket.write('QUIT\r\n'); - socket.end(); - } - } catch (e) { - // Ignore cleanup errors - } - } - - // Wait for connections to close - await new Promise(resolve => setTimeout(resolve, 500)); - - // Test passes if we either: - // 1. Detected resource exhaustion (server properly limits connections) - // 2. Established fewer connections than attempted (server has limits) - // 3. Server handled all connections gracefully (no crashes) - const hasResourceProtection = exhaustionDetected || connectionsEstablished < maxAttempts; - const handledGracefully = connectionsEstablished === maxAttempts && !lastError; - - console.log(`Connections established: ${connectionsEstablished}/${maxAttempts}`); - console.log(`Exhaustion detected: ${exhaustionDetected}`); - if (lastError) console.log(`Last error: ${lastError}`); - - clearTimeout(testTimeout); // Clear the timeout - - // Pass if server either has protection OR handles many connections gracefully - expect(hasResourceProtection || handledGracefully).toEqual(true); - - if (handledGracefully) { - console.log('Server handled all connections gracefully without resource limits'); - } - done.resolve(); - } catch (error) { - console.error('Test error:', error); - clearTimeout(testTimeout); // Clear the timeout - done.reject(error); - } -}); - -tap.test('ERR-05: Resource exhaustion handling - Memory limits', async (tools) => { - const done = tools.defer(); - - // Set a timeout for this test - const testTimeout = setTimeout(() => { - console.log('Memory test timeout reached'); - done.resolve(); // Just pass the test on timeout - }, 15000); // 15 second timeout - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 10000 // Reduced from 30000 - }); - - socket.on('connect', async () => { - try { - // Read greeting - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('250 ') && data.includes('\r\n')) { - socket.removeListener('data', handleData); - resolve(); - } - }; - socket.on('data', handleData); - }); - - // Try to send a very large email that might exhaust memory - socket.write('MAIL FROM:\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => { - const response = chunk.toString(); - expect(response).toInclude('250'); - resolve(); - }); - }); - - socket.write('RCPT TO:\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => { - const response = chunk.toString(); - expect(response).toInclude('250'); - resolve(); - }); - }); - - socket.write('DATA\r\n'); - - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => { - resolve(chunk.toString()); - }); - }); - - expect(dataResponse).toInclude('354'); - - // Try to send extremely large headers to test memory limits - const largeHeader = 'X-Test-Header: ' + 'A'.repeat(1024 * 100) + '\r\n'; - let resourceError = false; - - try { - // Send multiple large headers - for (let i = 0; i < 100; i++) { - socket.write(largeHeader); - - // Check if socket is still writable - if (!socket.writable) { - resourceError = true; - break; - } - } - - socket.write('\r\n.\r\n'); - - const endResponse = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Timeout waiting for response')); - }, 10000); - - socket.once('data', (chunk) => { - clearTimeout(timeout); - resolve(chunk.toString()); - }); - - socket.once('error', (err) => { - clearTimeout(timeout); - // Connection errors during large data handling indicate resource protection - resourceError = true; - resolve(''); - }); - }); - - // Check for resource protection responses - if (endResponse.includes('552') || // Message too large - endResponse.includes('451') || // Temporary failure - endResponse.includes('421') || // Service unavailable - endResponse.includes('resource') || - endResponse.includes('memory') || - endResponse.includes('limit')) { - resourceError = true; - } - - // Resource protection is working if we got an error or protective response - expect(resourceError || endResponse.includes('552') || endResponse.includes('451')).toEqual(true); - - } catch (err) { - // Errors during large data transmission indicate resource protection - console.log('Expected resource protection error:', err); - expect(true).toEqual(true); - } - - socket.write('QUIT\r\n'); - socket.end(); - clearTimeout(testTimeout); - done.resolve(); - } catch (error) { - socket.end(); - clearTimeout(testTimeout); - done.reject(error); - } - }); - - socket.on('error', (error) => { - clearTimeout(testTimeout); - done.reject(error); - }); -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); - expect(true).toEqual(true); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_error-handling/test.err-06.malformed-mime.ts b/test/suite/smtpserver_error-handling/test.err-06.malformed-mime.ts deleted file mode 100644 index 860773a..0000000 --- a/test/suite/smtpserver_error-handling/test.err-06.malformed-mime.ts +++ /dev/null @@ -1,374 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('ERR-06: Malformed MIME handling - Invalid boundary', async (tools) => { - const done = tools.defer(); - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('connect', async () => { - try { - // Read greeting - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('250 ') && data.includes('\r\n')) { - socket.removeListener('data', handleData); - resolve(); - } - }; - socket.on('data', handleData); - }); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => { - const response = chunk.toString(); - expect(response).toInclude('250'); - resolve(); - }); - }); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => { - const response = chunk.toString(); - expect(response).toInclude('250'); - resolve(); - }); - }); - - // Send DATA - socket.write('DATA\r\n'); - - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => { - resolve(chunk.toString()); - }); - }); - - expect(dataResponse).toInclude('354'); - - // Send malformed MIME with invalid boundary - const malformedMime = [ - 'From: sender@example.com', - 'To: recipient@example.com', - 'Subject: Malformed MIME Test', - 'MIME-Version: 1.0', - 'Content-Type: multipart/mixed; boundary=invalid-boundary', - '', - '--invalid-boundary', - 'Content-Type: text/plain', - 'Content-Transfer-Encoding: invalid-encoding', - '', - 'This is malformed MIME content.', - '--invalid-boundary', - 'Content-Type: application/octet-stream', - 'Content-Disposition: attachment; filename="malformed.txt', // Missing closing quote - '', - 'Malformed attachment content without proper boundary.', - '--invalid-boundary--missing-final-boundary', // Malformed closing boundary - '.', - '' - ].join('\r\n'); - - socket.write(malformedMime); - - const response = await new Promise((resolve) => { - socket.once('data', (chunk) => { - resolve(chunk.toString()); - }); - }); - - // Server should either: - // 1. Accept the message (250) - tolerant handling - // 2. Reject with error (550/552) - strict MIME validation - // 3. Return temporary failure (4xx) - processing error - const validResponse = response.includes('250') || - response.includes('550') || - response.includes('552') || - response.includes('451') || - response.includes('mime') || - response.includes('malformed'); - - console.log('Malformed MIME response:', response.substring(0, 100)); - expect(validResponse).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } catch (error) { - socket.end(); - done.reject(error); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); -}); - -tap.test('ERR-06: Malformed MIME handling - Missing headers', async (tools) => { - const done = tools.defer(); - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('connect', async () => { - try { - // Read greeting - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('250 ') && data.includes('\r\n')) { - socket.removeListener('data', handleData); - resolve(); - } - }; - socket.on('data', handleData); - }); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => { - const response = chunk.toString(); - expect(response).toInclude('250'); - resolve(); - }); - }); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => { - const response = chunk.toString(); - expect(response).toInclude('250'); - resolve(); - }); - }); - - // Send DATA - socket.write('DATA\r\n'); - - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => { - resolve(chunk.toString()); - }); - }); - - expect(dataResponse).toInclude('354'); - - // Send MIME with missing required headers - const malformedMime = [ - 'Subject: Missing MIME headers', - 'Content-Type: multipart/mixed', // Missing boundary parameter - '', - '--boundary', - // Missing Content-Type for part - '', - 'This part has no Content-Type header.', - '--boundary', - 'Content-Type: text/plain', - // Missing blank line between headers and body - 'This part has no separator line.', - '--boundary--', - '.', - '' - ].join('\r\n'); - - socket.write(malformedMime); - - const response = await new Promise((resolve) => { - socket.once('data', (chunk) => { - resolve(chunk.toString()); - }); - }); - - // Server should handle this gracefully - const validResponse = response.includes('250') || - response.includes('550') || - response.includes('552') || - response.includes('451'); - - console.log('Missing headers response:', response.substring(0, 100)); - expect(validResponse).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } catch (error) { - socket.end(); - done.reject(error); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); -}); - -tap.test('ERR-06: Malformed MIME handling - Nested multipart errors', async (tools) => { - const done = tools.defer(); - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('connect', async () => { - try { - // Read greeting - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('250 ') && data.includes('\r\n')) { - socket.removeListener('data', handleData); - resolve(); - } - }; - socket.on('data', handleData); - }); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => { - const response = chunk.toString(); - expect(response).toInclude('250'); - resolve(); - }); - }); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => { - const response = chunk.toString(); - expect(response).toInclude('250'); - resolve(); - }); - }); - - // Send DATA - socket.write('DATA\r\n'); - - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => { - resolve(chunk.toString()); - }); - }); - - expect(dataResponse).toInclude('354'); - - // Send deeply nested multipart with errors - const malformedMime = [ - 'From: sender@example.com', - 'To: recipient@example.com', - 'Subject: Nested multipart errors', - 'MIME-Version: 1.0', - 'Content-Type: multipart/mixed; boundary="outer"', - '', - '--outer', - 'Content-Type: multipart/alternative; boundary="inner"', - '', - '--inner', - 'Content-Type: multipart/related; boundary="nested"', // Too deeply nested - '', - '--nested', - 'Content-Type: text/plain', - 'Content-Transfer-Encoding: base64', - '', - 'NOT-VALID-BASE64-CONTENT!!!', // Invalid base64 - '--nested', // Missing closing -- - '--inner--', // Improper nesting - '--outer', // Missing part content - '--outer--', - '.', - '' - ].join('\r\n'); - - socket.write(malformedMime); - - const response = await new Promise((resolve) => { - socket.once('data', (chunk) => { - resolve(chunk.toString()); - }); - }); - - // Server should handle complex MIME errors gracefully - const validResponse = response.includes('250') || - response.includes('550') || - response.includes('552') || - response.includes('451'); - - console.log('Nested multipart response:', response.substring(0, 100)); - expect(validResponse).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } catch (error) { - socket.end(); - done.reject(error); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_error-handling/test.err-07.exception-handling.ts b/test/suite/smtpserver_error-handling/test.err-07.exception-handling.ts deleted file mode 100644 index 5c97e04..0000000 --- a/test/suite/smtpserver_error-handling/test.err-07.exception-handling.ts +++ /dev/null @@ -1,333 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; -const activeSockets = new Set(); - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('ERR-07: Exception handling - Invalid commands', async (tools) => { - const done = tools.defer(); - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - activeSockets.add(socket); - socket.on('close', () => activeSockets.delete(socket)); - - socket.on('connect', async () => { - try { - // Read greeting - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('250 ') && data.includes('\r\n')) { - socket.removeListener('data', handleData); - resolve(); - } - }; - socket.on('data', handleData); - }); - - // Test various exception-triggering commands - const invalidCommands = [ - 'INVALID_COMMAND_THAT_SHOULD_TRIGGER_EXCEPTION', - 'MAIL FROM:<>', // Empty address - 'RCPT TO:<>', // Empty address - '\x00\x01\x02INVALID_BYTES', // Binary data - 'VERY_LONG_COMMAND_' + 'X'.repeat(1000), // Excessively long command - 'MAIL FROM', // Missing parameter - 'RCPT TO', // Missing parameter - 'DATA DATA DATA' // Invalid syntax - ]; - - let exceptionHandled = false; - let serverStillResponding = true; - - for (const command of invalidCommands) { - try { - socket.write(command + '\r\n'); - - const response = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Timeout waiting for response')); - }, 5000); - - socket.once('data', (chunk) => { - clearTimeout(timeout); - resolve(chunk.toString()); - }); - }); - - console.log(`Command: "${command.substring(0, 50)}..." -> Response: ${response.substring(0, 50)}`); - - // Check if server handled the exception properly - if (response.includes('500') || // Command not recognized - response.includes('501') || // Syntax error - response.includes('502') || // Command not implemented - response.includes('503') || // Bad sequence - response.includes('error') || - response.includes('invalid')) { - exceptionHandled = true; - } - - // Small delay between commands - await new Promise(resolve => setTimeout(resolve, 100)); - - } catch (err) { - console.log('Error with command:', command, err); - // Connection might be closed by server - that's ok for some commands - serverStillResponding = false; - break; - } - } - - // If still connected, verify server is still responsive - if (serverStillResponding) { - try { - socket.write('NOOP\r\n'); - const noopResponse = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Timeout on NOOP')); - }, 5000); - - socket.once('data', (chunk) => { - clearTimeout(timeout); - resolve(chunk.toString()); - }); - }); - - if (noopResponse.includes('250')) { - serverStillResponding = true; - } - } catch (err) { - serverStillResponding = false; - } - } - - console.log('Exception handled:', exceptionHandled); - console.log('Server still responding:', serverStillResponding); - - // Test passes if exceptions were handled OR server is still responding - expect(exceptionHandled || serverStillResponding).toEqual(true); - - if (socket.writable) { - socket.write('QUIT\r\n'); - } - socket.end(); - done.resolve(); - } catch (error) { - socket.end(); - done.reject(error); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); -}); - -tap.test('ERR-07: Exception handling - Malformed protocol', async (tools) => { - const done = tools.defer(); - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - activeSockets.add(socket); - socket.on('close', () => activeSockets.delete(socket)); - - socket.on('connect', async () => { - try { - // Read greeting - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - // Send commands with protocol violations - const protocolViolations = [ - 'EHLO', // No hostname - 'MAIL FROM: SIZE=', // Incomplete SIZE - 'RCPT TO: NOTIFY=', // Incomplete NOTIFY - 'AUTH PLAIN', // No credentials - 'STARTTLS EXTRA', // Extra parameters - 'MAIL FROM:\r\nRCPT TO:', // Multiple commands in one line - ]; - - let violationsHandled = 0; - - for (const violation of protocolViolations) { - try { - socket.write(violation + '\r\n'); - - const response = await new Promise((resolve) => { - const timeout = setTimeout(() => { - resolve('TIMEOUT'); - }, 3000); - - socket.once('data', (chunk) => { - clearTimeout(timeout); - resolve(chunk.toString()); - }); - }); - - if (response !== 'TIMEOUT' && - (response.includes('500') || - response.includes('501') || - response.includes('503'))) { - violationsHandled++; - } - - await new Promise(resolve => setTimeout(resolve, 100)); - - } catch (err) { - // Error is ok - server might close connection - } - } - - console.log(`Protocol violations handled: ${violationsHandled}/${protocolViolations.length}`); - - // Server should handle at least some violations properly - expect(violationsHandled).toBeGreaterThan(0); - - if (socket.writable) { - socket.write('QUIT\r\n'); - } - socket.end(); - done.resolve(); - } catch (error) { - socket.end(); - done.reject(error); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); -}); - -tap.test('ERR-07: Exception handling - Recovery after errors', async (tools) => { - const done = tools.defer(); - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - activeSockets.add(socket); - socket.on('close', () => activeSockets.delete(socket)); - - socket.on('connect', async () => { - try { - // Read greeting - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('250 ') && data.includes('\r\n')) { - socket.removeListener('data', handleData); - resolve(); - } - }; - socket.on('data', handleData); - }); - - // Trigger an error - socket.write('INVALID_COMMAND\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => { - const response = chunk.toString(); - expect(response).toMatch(/50[0-3]/); - resolve(); - }); - }); - - // Now try a valid command sequence to ensure recovery - socket.write('MAIL FROM:\r\n'); - - const mailResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => { - resolve(chunk.toString()); - }); - }); - - expect(mailResponse).toInclude('250'); - - socket.write('RCPT TO:\r\n'); - - const rcptResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => { - resolve(chunk.toString()); - }); - }); - - expect(rcptResponse).toInclude('250'); - - // Server recovered successfully after exception - socket.write('RSET\r\n'); - - const rsetResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => { - resolve(chunk.toString()); - }); - }); - - expect(rsetResponse).toInclude('250'); - - console.log('Server recovered successfully after exception'); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } catch (error) { - socket.end(); - done.reject(error); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); -}); - -tap.test('cleanup server', async () => { - // Close any remaining sockets - for (const socket of activeSockets) { - if (!socket.destroyed) { - socket.destroy(); - } - } - - // Wait for all sockets to be fully closed - await new Promise(resolve => setTimeout(resolve, 500)); - - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_error-handling/test.err-08.error-logging.ts b/test/suite/smtpserver_error-handling/test.err-08.error-logging.ts deleted file mode 100644 index c60f7ec..0000000 --- a/test/suite/smtpserver_error-handling/test.err-08.error-logging.ts +++ /dev/null @@ -1,324 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('ERR-08: Error logging - Command errors', async (tools) => { - const done = tools.defer(); - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('connect', async () => { - try { - // Read greeting - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('250 ') && data.includes('\r\n')) { - socket.removeListener('data', handleData); - resolve(); - } - }; - socket.on('data', handleData); - }); - - // Test various error conditions that should be logged - const errorTests = [ - { command: 'INVALID_COMMAND', expectedCode: '500', description: 'Invalid command' }, - { command: 'MAIL FROM:', expectedCode: '501', description: 'Invalid email syntax' }, - { command: 'RCPT TO:', expectedCode: '501', description: 'Invalid recipient syntax' }, - { command: 'VRFY nonexistent@domain.com', expectedCode: '550', description: 'User verification failed' }, - { command: 'EXPN invalidlist', expectedCode: '550', description: 'List expansion failed' } - ]; - - let errorsDetected = 0; - let totalTests = errorTests.length; - - for (const test of errorTests) { - try { - socket.write(test.command + '\r\n'); - - const response = await new Promise((resolve) => { - const timeout = setTimeout(() => { - resolve('TIMEOUT'); - }, 5000); - - socket.once('data', (chunk) => { - clearTimeout(timeout); - resolve(chunk.toString()); - }); - }); - - console.log(`${test.description}: ${test.command} -> ${response.substring(0, 50)}`); - - // Check if appropriate error code was returned - if (response.includes(test.expectedCode) || - response.includes('500') || // General error - response.includes('501') || // Syntax error - response.includes('502') || // Not implemented - response.includes('550')) { // Action not taken - errorsDetected++; - } - - // Small delay between commands - await new Promise(resolve => setTimeout(resolve, 100)); - - } catch (err) { - console.log('Error during test:', test.description, err); - // Connection errors also count as detected errors - errorsDetected++; - } - } - - const detectionRate = errorsDetected / totalTests; - console.log(`Error detection rate: ${errorsDetected}/${totalTests} (${Math.round(detectionRate * 100)}%)`); - - // Expect at least 80% of errors to be properly detected and responded to - expect(detectionRate).toBeGreaterThanOrEqual(0.8); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } catch (error) { - socket.end(); - done.reject(error); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); -}); - -tap.test('ERR-08: Error logging - Protocol violations', async (tools) => { - const done = tools.defer(); - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('connect', async () => { - try { - // Read greeting - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - // Test protocol violations that should trigger error logging - const violations = [ - { - sequence: ['RCPT TO:'], // RCPT before MAIL - description: 'RCPT before MAIL FROM' - }, - { - sequence: ['MAIL FROM:', 'DATA'], // DATA before RCPT - description: 'DATA before RCPT TO' - }, - { - sequence: ['EHLO testhost', 'EHLO testhost', 'MAIL FROM:', 'MAIL FROM:'], // Double MAIL FROM - description: 'Multiple MAIL FROM commands' - } - ]; - - let violationsDetected = 0; - - for (const violation of violations) { - // Reset connection state - socket.write('RSET\r\n'); - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - console.log(`Testing: ${violation.description}`); - - for (const cmd of violation.sequence) { - socket.write(cmd + '\r\n'); - - const response = await new Promise((resolve) => { - const timeout = setTimeout(() => { - resolve('TIMEOUT'); - }, 5000); - - socket.once('data', (chunk) => { - clearTimeout(timeout); - resolve(chunk.toString()); - }); - }); - - // Check for error responses - if (response.includes('503') || // Bad sequence - response.includes('501') || // Syntax error - response.includes('500')) { // Error - violationsDetected++; - console.log(` Violation detected: ${response.substring(0, 50)}`); - break; // Move to next violation test - } - } - - await new Promise(resolve => setTimeout(resolve, 100)); - } - - console.log(`Protocol violations detected: ${violationsDetected}/${violations.length}`); - - // Expect all protocol violations to be detected - expect(violationsDetected).toBeGreaterThan(0); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } catch (error) { - socket.end(); - done.reject(error); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); -}); - -tap.test('ERR-08: Error logging - Data transmission errors', async (tools) => { - const done = tools.defer(); - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('connect', async () => { - try { - // Read greeting - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('250 ') && data.includes('\r\n')) { - socket.removeListener('data', handleData); - resolve(); - } - }; - socket.on('data', handleData); - }); - - // Set up valid email transaction - socket.write('MAIL FROM:\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => { - const response = chunk.toString(); - expect(response).toInclude('250'); - resolve(); - }); - }); - - socket.write('RCPT TO:\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => { - const response = chunk.toString(); - expect(response).toInclude('250'); - resolve(); - }); - }); - - socket.write('DATA\r\n'); - - const dataResponse = await new Promise((resolve) => { - socket.once('data', (chunk) => { - resolve(chunk.toString()); - }); - }); - - expect(dataResponse).toInclude('354'); - - // Test various data transmission errors - const dataErrors = [ - { - data: 'From: sender@example.com\r\n.\r\n', // Premature termination - description: 'Premature dot termination' - }, - { - data: 'Subject: Test\r\n\r\n' + '\x00\x01\x02\x03', // Binary data - description: 'Binary data in message' - }, - { - data: 'X-Long-Line: ' + 'A'.repeat(2000) + '\r\n', // Excessively long line - description: 'Excessively long header line' - } - ]; - - for (const errorData of dataErrors) { - console.log(`Testing: ${errorData.description}`); - socket.write(errorData.data); - } - - // Terminate the data - socket.write('\r\n.\r\n'); - - const finalResponse = await new Promise((resolve) => { - const timeout = setTimeout(() => { - resolve('TIMEOUT'); - }, 10000); - - socket.once('data', (chunk) => { - clearTimeout(timeout); - resolve(chunk.toString()); - }); - }); - - console.log('Data transmission response:', finalResponse.substring(0, 100)); - - // Server should either accept (250) or reject (5xx) but must respond - const hasResponse = finalResponse !== 'TIMEOUT' && - (finalResponse.includes('250') || - finalResponse.includes('5')); - - expect(hasResponse).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } catch (error) { - socket.end(); - done.reject(error); - } - }); - - socket.on('error', (error) => { - done.reject(error); - }); -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_performance/test.perf-01.throughput.ts b/test/suite/smtpserver_performance/test.perf-01.throughput.ts deleted file mode 100644 index b4fcd25..0000000 --- a/test/suite/smtpserver_performance/test.perf-01.throughput.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -// import { createTestSmtpClient, sendConcurrentEmails, measureClientThroughput } from '../../helpers/smtp.client.js'; -import { connectToSmtp, sendSmtpCommand, waitForGreeting, createMimeMessage, closeSmtpConnection } from '../../helpers/utils.js'; - -let testServer: ITestServer; - -tap.test('setup - start SMTP server for performance testing', async () => { - testServer = await startTestServer({ - port: 2531, - hostname: 'localhost', - maxConnections: 1000, - size: 50 * 1024 * 1024 // 50MB for performance testing - }); - expect(testServer).toBeInstanceOf(Object); -}); - -// TODO: Enable these tests when the helper functions are implemented -/* -tap.test('PERF-01: Throughput Testing - measure emails per second', async () => { - const client = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - maxConnections: 10 - }); - - try { - // Warm up the connection pool - console.log('🔥 Warming up connection pool...'); - await sendConcurrentEmails(client, 5); - - // Measure throughput for 10 seconds - console.log('📊 Measuring throughput for 10 seconds...'); - const startTime = Date.now(); - const testDuration = 10000; // 10 seconds - - const result = await measureClientThroughput(client, testDuration, { - from: 'perf-test@example.com', - to: 'recipient@example.com', - subject: 'Performance Test Email', - text: 'This is a performance test email to measure throughput.' - }); - - const actualDuration = (Date.now() - startTime) / 1000; - - console.log('📈 Throughput Test Results:'); - console.log(` Total emails sent: ${result.totalSent}`); - console.log(` Successful: ${result.successCount}`); - console.log(` Failed: ${result.errorCount}`); - console.log(` Duration: ${actualDuration.toFixed(2)}s`); - console.log(` Throughput: ${result.throughput.toFixed(2)} emails/second`); - - // Performance expectations - expect(result.throughput).toBeGreaterThan(10); // At least 10 emails/second - expect(result.errorCount).toBeLessThan(result.totalSent * 0.05); // Less than 5% errors - - console.log('✅ Throughput test passed'); - - } finally { - if (client.close) { - await client.close(); - } - } -}); - -tap.test('PERF-01: Burst throughput - handle sudden load spikes', async () => { - const client = createTestSmtpClient({ - host: testServer.hostname, - port: testServer.port, - maxConnections: 20 - }); - - try { - // Send burst of emails - const burstSize = 100; - console.log(`💥 Sending burst of ${burstSize} emails...`); - - const startTime = Date.now(); - const results = await sendConcurrentEmails(client, burstSize, { - from: 'burst-test@example.com', - to: 'recipient@example.com', - subject: 'Burst Test Email', - text: 'Testing burst performance.' - }); - - const duration = Date.now() - startTime; - const successCount = results.filter(r => r && !r.rejected).length; - const throughput = (successCount / duration) * 1000; - - console.log(`✅ Burst completed in ${duration}ms`); - console.log(` Success rate: ${successCount}/${burstSize} (${(successCount/burstSize*100).toFixed(1)}%)`); - console.log(` Burst throughput: ${throughput.toFixed(2)} emails/second`); - - expect(successCount).toBeGreaterThan(burstSize * 0.95); // 95% success rate - - } finally { - if (client.close) { - await client.close(); - } - } -}); -*/ - -tap.test('PERF-01: Large message throughput - measure with varying sizes', async () => { - const messageSizes = [ - { size: 1024, label: '1KB' }, - { size: 100 * 1024, label: '100KB' }, - { size: 1024 * 1024, label: '1MB' }, - { size: 5 * 1024 * 1024, label: '5MB' } - ]; - - for (const { size, label } of messageSizes) { - console.log(`\n📧 Testing throughput with ${label} messages...`); - - const socket = await connectToSmtp(testServer.hostname, testServer.port); - - try { - await waitForGreeting(socket); - await sendSmtpCommand(socket, 'EHLO test.example.com', '250'); - - // Send a few messages of this size - const messageCount = 5; - const timings: number[] = []; - - for (let i = 0; i < messageCount; i++) { - const startTime = Date.now(); - - await sendSmtpCommand(socket, 'MAIL FROM:', '250'); - await sendSmtpCommand(socket, 'RCPT TO:', '250'); - await sendSmtpCommand(socket, 'DATA', '354'); - - // Create message with padding to reach target size - const padding = 'X'.repeat(Math.max(0, size - 200)); // Account for headers - const emailContent = createMimeMessage({ - from: 'size-test@example.com', - to: 'recipient@example.com', - subject: `${label} Performance Test`, - text: padding - }); - - socket.write(emailContent); - socket.write('\r\n.\r\n'); - - // Wait for acceptance - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Timeout')), 30000); - const onData = (data: Buffer) => { - if (data.toString().includes('250')) { - clearTimeout(timeout); - socket.removeListener('data', onData); - resolve(); - } - }; - socket.on('data', onData); - }); - - const duration = Date.now() - startTime; - timings.push(duration); - - // Reset for next message - await sendSmtpCommand(socket, 'RSET', '250'); - } - - const avgTime = timings.reduce((a, b) => a + b, 0) / timings.length; - const throughputMBps = (size / 1024 / 1024) / (avgTime / 1000); - - console.log(` Average time: ${avgTime.toFixed(0)}ms`); - console.log(` Throughput: ${throughputMBps.toFixed(2)} MB/s`); - - } finally { - await closeSmtpConnection(socket); - } - } - - console.log('\n✅ Large message throughput test completed'); -}); - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); - console.log('✅ Test server stopped'); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_performance/test.perf-02.concurrency.ts b/test/suite/smtpserver_performance/test.perf-02.concurrency.ts deleted file mode 100644 index 4c3c2d1..0000000 --- a/test/suite/smtpserver_performance/test.perf-02.concurrency.ts +++ /dev/null @@ -1,388 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('PERF-02: Concurrency testing - Multiple simultaneous connections', async (tools) => { - const done = tools.defer(); - const concurrentCount = 20; - const connectionResults: Array<{ - connectionId: number; - success: boolean; - duration: number; - error?: string; - }> = []; - - const createConcurrentConnection = (connectionId: number): Promise => { - return new Promise((resolve) => { - const startTime = Date.now(); - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 10000 - }); - - let state = 'connecting'; - let receivedData = ''; - - const timeoutHandle = setTimeout(() => { - socket.destroy(); - connectionResults.push({ - connectionId, - success: false, - duration: Date.now() - startTime, - error: 'Connection timeout' - }); - resolve(); - }, 10000); - - socket.on('connect', () => { - state = 'connected'; - }); - - socket.on('data', (chunk) => { - receivedData += chunk.toString(); - const lines = receivedData.split('\r\n'); - - for (const line of lines) { - if (!line.trim()) continue; - - if (state === 'connected' && line.startsWith('220')) { - state = 'ehlo'; - socket.write(`EHLO testhost-${connectionId}\r\n`); - } else if (state === 'ehlo' && line.includes('250 ') && !line.includes('250-')) { - // Final 250 response received - state = 'quit'; - socket.write('QUIT\r\n'); - } else if (state === 'quit' && line.startsWith('221')) { - clearTimeout(timeoutHandle); - socket.end(); - connectionResults.push({ - connectionId, - success: true, - duration: Date.now() - startTime - }); - resolve(); - } - } - }); - - socket.on('error', (error) => { - clearTimeout(timeoutHandle); - connectionResults.push({ - connectionId, - success: false, - duration: Date.now() - startTime, - error: error.message - }); - resolve(); - }); - - socket.on('close', () => { - clearTimeout(timeoutHandle); - if (!connectionResults.find(r => r.connectionId === connectionId)) { - connectionResults.push({ - connectionId, - success: false, - duration: Date.now() - startTime, - error: 'Connection closed unexpectedly' - }); - } - resolve(); - }); - }); - }; - - try { - // Create all concurrent connections - const promises: Promise[] = []; - console.log(`Creating ${concurrentCount} concurrent connections...`); - - for (let i = 0; i < concurrentCount; i++) { - promises.push(createConcurrentConnection(i)); - // Small stagger to avoid overwhelming the system - if (i % 5 === 0) { - await new Promise(resolve => setTimeout(resolve, 10)); - } - } - - // Wait for all connections to complete - await Promise.all(promises); - - // Analyze results - const successful = connectionResults.filter(r => r.success).length; - const failed = connectionResults.filter(r => !r.success).length; - const successRate = successful / concurrentCount; - const avgDuration = connectionResults - .filter(r => r.success) - .reduce((sum, r) => sum + r.duration, 0) / successful || 0; - - console.log(`\nConcurrency Test Results:`); - console.log(`Total connections: ${concurrentCount}`); - console.log(`Successful: ${successful} (${(successRate * 100).toFixed(1)}%)`); - console.log(`Failed: ${failed}`); - console.log(`Average duration: ${avgDuration.toFixed(0)}ms`); - - if (failed > 0) { - const errors = connectionResults - .filter(r => !r.success) - .map(r => r.error) - .filter((v, i, a) => a.indexOf(v) === i); // unique errors - console.log(`Unique errors: ${errors.join(', ')}`); - } - - // Success if at least 80% of connections succeed - expect(successRate).toBeGreaterThanOrEqual(0.8); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('PERF-02: Concurrency testing - Concurrent transactions', async (tools) => { - const done = tools.defer(); - const transactionCount = 10; - const transactionResults: Array<{ - transactionId: number; - success: boolean; - duration: number; - error?: string; - }> = []; - - const performConcurrentTransaction = (transactionId: number): Promise => { - return new Promise((resolve) => { - const startTime = Date.now(); - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 15000 - }); - - let state = 'connecting'; - - const timeoutHandle = setTimeout(() => { - socket.destroy(); - transactionResults.push({ - transactionId, - success: false, - duration: Date.now() - startTime, - error: 'Transaction timeout' - }); - resolve(); - }, 15000); - - const processResponse = async () => { - try { - // Read greeting - await new Promise((res) => { - let greeting = ''; - const handleGreeting = (chunk: Buffer) => { - greeting += chunk.toString(); - if (greeting.includes('220') && greeting.includes('\r\n')) { - socket.removeListener('data', handleGreeting); - res(); - } - }; - socket.on('data', handleGreeting); - }); - - // Send EHLO - socket.write(`EHLO testhost-tx-${transactionId}\r\n`); - - await new Promise((res) => { - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - // Look for the end of EHLO response (250 without dash) - if (data.includes('250 ')) { - socket.removeListener('data', handleData); - res(); - } - }; - socket.on('data', handleData); - }); - - // Complete email transaction - socket.write(`MAIL FROM:\r\n`); - - await new Promise((res, rej) => { - let mailResponse = ''; - const handleMailResponse = (chunk: Buffer) => { - mailResponse += chunk.toString(); - if (mailResponse.includes('\r\n')) { - socket.removeListener('data', handleMailResponse); - if (!mailResponse.includes('250')) { - rej(new Error('MAIL FROM failed')); - } else { - res(); - } - } - }; - socket.on('data', handleMailResponse); - }); - - socket.write(`RCPT TO:\r\n`); - - await new Promise((res, rej) => { - let rcptResponse = ''; - const handleRcptResponse = (chunk: Buffer) => { - rcptResponse += chunk.toString(); - if (rcptResponse.includes('\r\n')) { - socket.removeListener('data', handleRcptResponse); - if (!rcptResponse.includes('250')) { - rej(new Error('RCPT TO failed')); - } else { - res(); - } - } - }; - socket.on('data', handleRcptResponse); - }); - - socket.write('DATA\r\n'); - - await new Promise((res, rej) => { - let dataResponse = ''; - const handleDataResponse = (chunk: Buffer) => { - dataResponse += chunk.toString(); - if (dataResponse.includes('\r\n')) { - socket.removeListener('data', handleDataResponse); - if (!dataResponse.includes('354')) { - rej(new Error('DATA command failed')); - } else { - res(); - } - } - }; - socket.on('data', handleDataResponse); - }); - - // Send email content - const emailContent = [ - `From: sender${transactionId}@example.com`, - `To: recipient${transactionId}@example.com`, - `Subject: Concurrent test ${transactionId}`, - '', - `This is concurrent test message ${transactionId}`, - '.', - '' - ].join('\r\n'); - - socket.write(emailContent); - - await new Promise((res, rej) => { - let submitResponse = ''; - const handleSubmitResponse = (chunk: Buffer) => { - submitResponse += chunk.toString(); - if (submitResponse.includes('\r\n') && submitResponse.includes('250')) { - socket.removeListener('data', handleSubmitResponse); - res(); - } else if (submitResponse.includes('\r\n') && (submitResponse.includes('4') || submitResponse.includes('5'))) { - socket.removeListener('data', handleSubmitResponse); - rej(new Error('Message submission failed')); - } - }; - socket.on('data', handleSubmitResponse); - }); - - socket.write('QUIT\r\n'); - - await new Promise((res) => { - socket.once('data', () => res()); - }); - - clearTimeout(timeoutHandle); - socket.end(); - - transactionResults.push({ - transactionId, - success: true, - duration: Date.now() - startTime - }); - resolve(); - - } catch (error) { - clearTimeout(timeoutHandle); - socket.end(); - const errorMsg = error instanceof Error ? error.message : 'Unknown error'; - console.log(`Transaction ${transactionId} failed: ${errorMsg}`); - transactionResults.push({ - transactionId, - success: false, - duration: Date.now() - startTime, - error: errorMsg - }); - resolve(); - } - }; - - socket.on('connect', () => { - state = 'connected'; - processResponse(); - }); - - socket.on('error', (error) => { - clearTimeout(timeoutHandle); - if (!transactionResults.find(r => r.transactionId === transactionId)) { - transactionResults.push({ - transactionId, - success: false, - duration: Date.now() - startTime, - error: error.message - }); - } - resolve(); - }); - }); - }; - - try { - // Create concurrent transactions - const promises: Promise[] = []; - console.log(`\nStarting ${transactionCount} concurrent email transactions...`); - - for (let i = 0; i < transactionCount; i++) { - promises.push(performConcurrentTransaction(i)); - // Small stagger - await new Promise(resolve => setTimeout(resolve, 50)); - } - - // Wait for all transactions - await Promise.all(promises); - - // Analyze results - const successful = transactionResults.filter(r => r.success).length; - const failed = transactionResults.filter(r => !r.success).length; - const successRate = successful / transactionCount; - const avgDuration = transactionResults - .filter(r => r.success) - .reduce((sum, r) => sum + r.duration, 0) / successful || 0; - - console.log(`\nConcurrent Transaction Results:`); - console.log(`Total transactions: ${transactionCount}`); - console.log(`Successful: ${successful} (${(successRate * 100).toFixed(1)}%)`); - console.log(`Failed: ${failed}`); - console.log(`Average duration: ${avgDuration.toFixed(0)}ms`); - - // Success if at least 80% of transactions complete - expect(successRate).toBeGreaterThanOrEqual(0.8); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_performance/test.perf-03.cpu-utilization.ts b/test/suite/smtpserver_performance/test.perf-03.cpu-utilization.ts deleted file mode 100644 index 6383af6..0000000 --- a/test/suite/smtpserver_performance/test.perf-03.cpu-utilization.ts +++ /dev/null @@ -1,245 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer, getAvailablePort } from '../../helpers/server.loader.js'; - -let TEST_PORT: number; -let testServer; - -tap.test('prepare server', async () => { - TEST_PORT = await getAvailablePort(2600); - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('PERF-03: CPU utilization - Load test', async (tools) => { - const done = tools.defer(); - const monitoringDuration = 3000; // 3 seconds (reduced from 5) - const connectionCount = 5; // Reduced from 10 - const connections: net.Socket[] = []; - - // Add timeout to prevent hanging - const testTimeout = setTimeout(() => { - console.log('CPU test timeout reached, cleaning up...'); - for (const socket of connections) { - if (!socket.destroyed) socket.destroy(); - } - done.resolve(); - }, 30000); // 30 second timeout - - try { - // Record initial CPU usage - const initialCpuUsage = process.cpuUsage(); - const startTime = Date.now(); - - // Create multiple connections and send emails - console.log(`Creating ${connectionCount} connections for CPU load test...`); - - for (let i = 0; i < connectionCount; i++) { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - connections.push(socket); - - await new Promise((resolve, reject) => { - socket.once('connect', () => { - resolve(); - }); - socket.once('error', reject); - }); - - // Process greeting - await new Promise((resolve) => { - let greeting = ''; - const handleGreeting = (chunk: Buffer) => { - greeting += chunk.toString(); - if (greeting.includes('220') && greeting.includes('\r\n')) { - socket.removeListener('data', handleGreeting); - resolve(); - } - }; - socket.on('data', handleGreeting); - }); - - // Send EHLO - socket.write(`EHLO testhost-cpu-${i}\r\n`); - - await new Promise((resolve) => { - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('250 ')) { - socket.removeListener('data', handleData); - resolve(); - } - }; - socket.on('data', handleData); - }); - - // Keep connection active, don't send full transaction to avoid timeout - } - - // Keep connections active during monitoring period - console.log(`Monitoring CPU usage for ${monitoringDuration}ms...`); - - // Send periodic NOOP commands to keep connections active - const noopInterval = setInterval(() => { - connections.forEach((socket, idx) => { - if (socket.writable) { - socket.write('NOOP\r\n'); - } - }); - }, 1000); - - await new Promise(resolve => setTimeout(resolve, monitoringDuration)); - clearInterval(noopInterval); - - // Calculate CPU usage - const finalCpuUsage = process.cpuUsage(initialCpuUsage); - const totalCpuTimeMs = (finalCpuUsage.user + finalCpuUsage.system) / 1000; - const elapsedTime = Date.now() - startTime; - const cpuUtilizationPercent = (totalCpuTimeMs / elapsedTime) * 100; - - console.log(`\nCPU Utilization Results:`); - console.log(`Total CPU time: ${totalCpuTimeMs.toFixed(0)}ms`); - console.log(`Elapsed time: ${elapsedTime}ms`); - console.log(`CPU utilization: ${cpuUtilizationPercent.toFixed(1)}%`); - console.log(`User CPU: ${(finalCpuUsage.user / 1000).toFixed(0)}ms`); - console.log(`System CPU: ${(finalCpuUsage.system / 1000).toFixed(0)}ms`); - - // Clean up connections - for (const socket of connections) { - if (socket.writable) { - socket.write('QUIT\r\n'); - socket.end(); - } - } - - // Test passes if CPU usage is reasonable (less than 80%) - expect(cpuUtilizationPercent).toBeLessThan(80); - clearTimeout(testTimeout); - done.resolve(); - } catch (error) { - // Clean up on error - connections.forEach(socket => socket.destroy()); - clearTimeout(testTimeout); - done.reject(error); - } -}); - -tap.test('PERF-03: CPU utilization - Stress test', async (tools) => { - const done = tools.defer(); - const testDuration = 2000; // 2 seconds (reduced from 3) - let requestCount = 0; - - // Add timeout to prevent hanging - const testTimeout = setTimeout(() => { - console.log('Stress test timeout reached, completing...'); - done.resolve(); - }, 15000); // 15 second timeout - - try { - const initialCpuUsage = process.cpuUsage(); - const startTime = Date.now(); - - console.log(`\nRunning CPU stress test for ${testDuration}ms...`); - - // Create a single connection for rapid requests - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - await new Promise((resolve) => { - let greeting = ''; - const handleGreeting = (chunk: Buffer) => { - greeting += chunk.toString(); - if (greeting.includes('220') && greeting.includes('\r\n')) { - socket.removeListener('data', handleGreeting); - resolve(); - } - }; - socket.on('data', handleGreeting); - }); - - // Send EHLO - socket.write('EHLO stresstest\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('250 ')) { - socket.removeListener('data', handleData); - resolve(); - } - }; - socket.on('data', handleData); - }); - - // Rapid command loop - const endTime = Date.now() + testDuration; - const commands = ['NOOP', 'RSET', 'VRFY test@example.com', 'HELP']; - let commandIndex = 0; - - while (Date.now() < endTime) { - const command = commands[commandIndex % commands.length]; - socket.write(`${command}\r\n`); - - await new Promise((resolve) => { - socket.once('data', () => { - requestCount++; - resolve(); - }); - }); - - commandIndex++; - - // Small delay to avoid overwhelming - if (requestCount % 20 === 0) { - await new Promise(resolve => setTimeout(resolve, 10)); - } - } - - // Calculate final CPU usage - const finalCpuUsage = process.cpuUsage(initialCpuUsage); - const totalCpuTimeMs = (finalCpuUsage.user + finalCpuUsage.system) / 1000; - const elapsedTime = Date.now() - startTime; - const cpuUtilizationPercent = (totalCpuTimeMs / elapsedTime) * 100; - const requestsPerSecond = (requestCount / elapsedTime) * 1000; - - console.log(`\nStress Test Results:`); - console.log(`Requests processed: ${requestCount}`); - console.log(`Requests per second: ${requestsPerSecond.toFixed(1)}`); - console.log(`CPU utilization: ${cpuUtilizationPercent.toFixed(1)}%`); - console.log(`CPU time per request: ${(totalCpuTimeMs / requestCount).toFixed(2)}ms`); - - socket.write('QUIT\r\n'); - socket.end(); - - // Test passes if CPU usage per request is reasonable - const cpuPerRequest = totalCpuTimeMs / requestCount; - expect(cpuPerRequest).toBeLessThan(10); // Less than 10ms CPU per request - clearTimeout(testTimeout); - done.resolve(); - } catch (error) { - clearTimeout(testTimeout); - done.reject(error); - } -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_performance/test.perf-04.memory-usage.ts b/test/suite/smtpserver_performance/test.perf-04.memory-usage.ts deleted file mode 100644 index 51209d9..0000000 --- a/test/suite/smtpserver_performance/test.perf-04.memory-usage.ts +++ /dev/null @@ -1,238 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('PERF-04: Memory usage - Connection memory test', async (tools) => { - const done = tools.defer(); - const connectionCount = 10; // Reduced from 20 to make test faster - const connections: net.Socket[] = []; - - try { - // Force garbage collection if available - if (global.gc) { - global.gc(); - } - - // Record initial memory usage - const initialMemory = process.memoryUsage(); - console.log(`Initial memory usage: ${Math.round(initialMemory.heapUsed / (1024 * 1024))}MB`); - - // Create multiple connections with large email content - console.log(`Creating ${connectionCount} connections with large emails...`); - - for (let i = 0; i < connectionCount; i++) { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - connections.push(socket); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write(`EHLO testhost-mem-${i}\r\n`); - await waitForResponse(socket, '250'); - - // Send email transaction - socket.write(`MAIL FROM:\r\n`); - await waitForResponse(socket, '250'); - - socket.write(`RCPT TO:\r\n`); - await waitForResponse(socket, '250'); - - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Send large email content - const largeContent = 'This is a large email content for memory testing. '.repeat(100); - const emailContent = [ - `From: sender${i}@example.com`, - `To: recipient${i}@example.com`, - `Subject: Memory Usage Test ${i}`, - '', - largeContent, - '.', - '' - ].join('\r\n'); - - socket.write(emailContent); - await waitForResponse(socket, '250'); - - // Pause every 5 connections - if (i > 0 && i % 5 === 0) { - await new Promise(resolve => setTimeout(resolve, 100)); - const intermediateMemory = process.memoryUsage(); - console.log(`Memory after ${i} connections: ${Math.round(intermediateMemory.heapUsed / (1024 * 1024))}MB`); - } - } - - // Wait to let memory stabilize - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Record final memory usage - const finalMemory = process.memoryUsage(); - const memoryIncreaseMB = (finalMemory.heapUsed - initialMemory.heapUsed) / (1024 * 1024); - const memoryPerConnectionKB = (memoryIncreaseMB * 1024) / connectionCount; - - console.log(`\nMemory Usage Results:`); - console.log(`Initial heap: ${Math.round(initialMemory.heapUsed / (1024 * 1024))}MB`); - console.log(`Final heap: ${Math.round(finalMemory.heapUsed / (1024 * 1024))}MB`); - console.log(`Memory increase: ${memoryIncreaseMB.toFixed(2)}MB`); - console.log(`Memory per connection: ${memoryPerConnectionKB.toFixed(2)}KB`); - console.log(`RSS increase: ${Math.round((finalMemory.rss - initialMemory.rss) / (1024 * 1024))}MB`); - - // Clean up connections - for (const socket of connections) { - if (socket.writable) { - socket.write('QUIT\r\n'); - socket.end(); - } - } - - // Test passes if memory increase is reasonable (less than 30MB for 10 connections) - expect(memoryIncreaseMB).toBeLessThan(30); - done.resolve(); - } catch (error) { - // Clean up on error - connections.forEach(socket => socket.destroy()); - done.reject(error); - } -}); - -tap.test('PERF-04: Memory usage - Memory leak detection', async (tools) => { - const done = tools.defer(); - const iterations = 3; // Reduced from 5 - const connectionsPerIteration = 3; // Reduced from 5 - - try { - // Force GC if available - if (global.gc) { - global.gc(); - } - - const initialMemory = process.memoryUsage(); - const memorySnapshots: number[] = []; - - console.log(`\nRunning memory leak detection (${iterations} iterations)...`); - - for (let iteration = 0; iteration < iterations; iteration++) { - const sockets: net.Socket[] = []; - - // Create and close connections - for (let i = 0; i < connectionsPerIteration; i++) { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Quick transaction - await waitForResponse(socket, '220'); - - socket.write('EHLO leaktest\r\n'); - await waitForResponse(socket, '250'); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - sockets.push(socket); - } - - // Wait for sockets to close - await new Promise(resolve => setTimeout(resolve, 500)); - - // Force cleanup - sockets.forEach(s => s.destroy()); - - // Force GC if available - if (global.gc) { - global.gc(); - } - - // Record memory after each iteration - const currentMemory = process.memoryUsage(); - const memoryMB = currentMemory.heapUsed / (1024 * 1024); - memorySnapshots.push(memoryMB); - - console.log(`Iteration ${iteration + 1}: ${memoryMB.toFixed(2)}MB`); - - await new Promise(resolve => setTimeout(resolve, 500)); - } - - // Check for memory leak pattern - const firstSnapshot = memorySnapshots[0]; - const lastSnapshot = memorySnapshots[memorySnapshots.length - 1]; - const memoryGrowth = lastSnapshot - firstSnapshot; - const avgGrowthPerIteration = memoryGrowth / (iterations - 1); - - console.log(`\nMemory Leak Detection Results:`); - console.log(`First snapshot: ${firstSnapshot.toFixed(2)}MB`); - console.log(`Last snapshot: ${lastSnapshot.toFixed(2)}MB`); - console.log(`Total growth: ${memoryGrowth.toFixed(2)}MB`); - console.log(`Average growth per iteration: ${avgGrowthPerIteration.toFixed(2)}MB`); - - // Test passes if average growth per iteration is less than 2MB - expect(avgGrowthPerIteration).toBeLessThan(2); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_performance/test.perf-05.connection-processing-time.ts b/test/suite/smtpserver_performance/test.perf-05.connection-processing-time.ts deleted file mode 100644 index 2dfd4ad..0000000 --- a/test/suite/smtpserver_performance/test.perf-05.connection-processing-time.ts +++ /dev/null @@ -1,363 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('PERF-05: Connection processing time - Connection establishment', async (tools) => { - const done = tools.defer(); - const testConnections = 10; - const connectionTimes: number[] = []; - - try { - console.log(`Testing connection establishment time for ${testConnections} connections...`); - - for (let i = 0; i < testConnections; i++) { - const connectionStart = Date.now(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => { - const connectionTime = Date.now() - connectionStart; - connectionTimes.push(connectionTime); - resolve(); - }); - socket.once('error', reject); - }); - - // Read greeting - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - // Clean close - socket.write('QUIT\r\n'); - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - socket.end(); - - // Small delay between connections - await new Promise(resolve => setTimeout(resolve, 50)); - } - - // Calculate statistics - const avgConnectionTime = connectionTimes.reduce((a, b) => a + b, 0) / connectionTimes.length; - const minConnectionTime = Math.min(...connectionTimes); - const maxConnectionTime = Math.max(...connectionTimes); - - console.log(`\nConnection Establishment Results:`); - console.log(`Average: ${avgConnectionTime.toFixed(0)}ms`); - console.log(`Min: ${minConnectionTime}ms`); - console.log(`Max: ${maxConnectionTime}ms`); - console.log(`All times: ${connectionTimes.join(', ')}ms`); - - // Test passes if average connection time is less than 1000ms - expect(avgConnectionTime).toBeLessThan(1000); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('PERF-05: Connection processing time - Transaction processing', async (tools) => { - const done = tools.defer(); - const testTransactions = 10; - const processingTimes: number[] = []; - const fullTransactionTimes: number[] = []; - - // Add a timeout to prevent test from hanging - const testTimeout = setTimeout(() => { - console.log('Test timeout reached, moving on...'); - done.resolve(); - }, 30000); // 30 second timeout - - try { - console.log(`\nTesting transaction processing time for ${testTransactions} transactions...`); - - for (let i = 0; i < testTransactions; i++) { - const fullTransactionStart = Date.now(); - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - const processingStart = Date.now(); - - // Send EHLO - socket.write(`EHLO testhost-perf-${i}\r\n`); - - await new Promise((resolve) => { - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - // Look for the end of EHLO response (250 without dash) - if (data.includes('250 ')) { - socket.removeListener('data', handleData); - resolve(); - } - }; - socket.on('data', handleData); - }); - - // Send MAIL FROM - socket.write(`MAIL FROM:\r\n`); - - await new Promise((resolve, reject) => { - let mailResponse = ''; - const handleMailResponse = (chunk: Buffer) => { - mailResponse += chunk.toString(); - if (mailResponse.includes('\r\n')) { - socket.removeListener('data', handleMailResponse); - if (mailResponse.includes('250')) { - resolve(); - } else { - reject(new Error(`MAIL FROM failed: ${mailResponse}`)); - } - } - }; - socket.on('data', handleMailResponse); - }); - - // Send RCPT TO - socket.write(`RCPT TO:\r\n`); - - await new Promise((resolve, reject) => { - let rcptResponse = ''; - const handleRcptResponse = (chunk: Buffer) => { - rcptResponse += chunk.toString(); - if (rcptResponse.includes('\r\n')) { - socket.removeListener('data', handleRcptResponse); - if (rcptResponse.includes('250')) { - resolve(); - } else { - reject(new Error(`RCPT TO failed: ${rcptResponse}`)); - } - } - }; - socket.on('data', handleRcptResponse); - }); - - // Send DATA - socket.write('DATA\r\n'); - - await new Promise((resolve, reject) => { - let dataResponse = ''; - const handleDataResponse = (chunk: Buffer) => { - dataResponse += chunk.toString(); - if (dataResponse.includes('\r\n')) { - socket.removeListener('data', handleDataResponse); - if (dataResponse.includes('354')) { - resolve(); - } else { - reject(new Error(`DATA failed: ${dataResponse}`)); - } - } - }; - socket.on('data', handleDataResponse); - }); - - // Send email content - const emailContent = [ - `From: sender${i}@example.com`, - `To: recipient${i}@example.com`, - `Subject: Connection Processing Test ${i}`, - '', - 'Connection processing time test.', - '.', - '' - ].join('\r\n'); - - socket.write(emailContent); - - await new Promise((resolve, reject) => { - let submitResponse = ''; - const handleSubmitResponse = (chunk: Buffer) => { - submitResponse += chunk.toString(); - if (submitResponse.includes('\r\n') && submitResponse.includes('250')) { - socket.removeListener('data', handleSubmitResponse); - resolve(); - } else if (submitResponse.includes('\r\n') && (submitResponse.includes('4') || submitResponse.includes('5'))) { - socket.removeListener('data', handleSubmitResponse); - reject(new Error(`Message submission failed: ${submitResponse}`)); - } - }; - socket.on('data', handleSubmitResponse); - }); - - const processingTime = Date.now() - processingStart; - processingTimes.push(processingTime); - - // Send QUIT - socket.write('QUIT\r\n'); - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - socket.end(); - - const fullTransactionTime = Date.now() - fullTransactionStart; - fullTransactionTimes.push(fullTransactionTime); - - // Small delay between transactions - await new Promise(resolve => setTimeout(resolve, 50)); - } - - // Calculate statistics - const avgProcessingTime = processingTimes.reduce((a, b) => a + b, 0) / processingTimes.length; - const minProcessingTime = Math.min(...processingTimes); - const maxProcessingTime = Math.max(...processingTimes); - - const avgFullTime = fullTransactionTimes.reduce((a, b) => a + b, 0) / fullTransactionTimes.length; - - console.log(`\nTransaction Processing Results:`); - console.log(`Average processing: ${avgProcessingTime.toFixed(0)}ms`); - console.log(`Min processing: ${minProcessingTime}ms`); - console.log(`Max processing: ${maxProcessingTime}ms`); - console.log(`Average full transaction: ${avgFullTime.toFixed(0)}ms`); - - // Test passes if average processing time is less than 2000ms - expect(avgProcessingTime).toBeLessThan(2000); - clearTimeout(testTimeout); - done.resolve(); - } catch (error) { - clearTimeout(testTimeout); - done.reject(error); - } -}); - -tap.test('PERF-05: Connection processing time - Command response times', async (tools) => { - const done = tools.defer(); - const commandTimings: { [key: string]: number[] } = { - EHLO: [], - NOOP: [] - }; - - // Add a timeout to prevent test from hanging - const testTimeout = setTimeout(() => { - console.log('Command timing test timeout reached, moving on...'); - done.resolve(); - }, 20000); // 20 second timeout - - try { - console.log(`\nMeasuring individual command response times...`); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - await new Promise((resolve) => { - let greeting = ''; - const handleGreeting = (chunk: Buffer) => { - greeting += chunk.toString(); - if (greeting.includes('220') && greeting.includes('\r\n')) { - socket.removeListener('data', handleGreeting); - resolve(); - } - }; - socket.on('data', handleGreeting); - }); - - // Measure EHLO response times - for (let i = 0; i < 3; i++) { - const start = Date.now(); - socket.write('EHLO testhost\r\n'); - - await new Promise((resolve) => { - let data = ''; - const handleData = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('250 ')) { - socket.removeListener('data', handleData); - commandTimings.EHLO.push(Date.now() - start); - resolve(); - } - }; - socket.on('data', handleData); - }); - } - - // Measure NOOP response times - for (let i = 0; i < 3; i++) { - const start = Date.now(); - socket.write('NOOP\r\n'); - - await new Promise((resolve) => { - let noopResponse = ''; - const handleNoop = (chunk: Buffer) => { - noopResponse += chunk.toString(); - if (noopResponse.includes('\r\n')) { - socket.removeListener('data', handleNoop); - commandTimings.NOOP.push(Date.now() - start); - resolve(); - } - }; - socket.on('data', handleNoop); - }); - } - - // Close connection - socket.write('QUIT\r\n'); - await new Promise((resolve) => { - socket.once('data', () => { - socket.end(); - resolve(); - }); - }); - - // Calculate and display results - console.log(`\nCommand Response Times (ms):`); - for (const [command, times] of Object.entries(commandTimings)) { - if (times.length > 0) { - const avg = times.reduce((a, b) => a + b, 0) / times.length; - console.log(`${command}: avg=${avg.toFixed(0)}, samples=[${times.join(', ')}]`); - - // All commands should respond in less than 500ms on average - expect(avg).toBeLessThan(500); - } - } - - clearTimeout(testTimeout); - done.resolve(); - } catch (error) { - clearTimeout(testTimeout); - done.reject(error); - } -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_performance/test.perf-06.message-processing-time.ts b/test/suite/smtpserver_performance/test.perf-06.message-processing-time.ts deleted file mode 100644 index b9442b3..0000000 --- a/test/suite/smtpserver_performance/test.perf-06.message-processing-time.ts +++ /dev/null @@ -1,252 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('PERF-06: Message processing time - Various message sizes', async (tools) => { - const done = tools.defer(); - const messageSizes = [1000, 5000, 10000, 25000, 50000]; // bytes - const messageProcessingTimes: number[] = []; - const processingRates: number[] = []; - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await waitForResponse(socket, '250'); - - console.log('Testing message processing times for various sizes...\n'); - - for (let i = 0; i < messageSizes.length; i++) { - const messageSize = messageSizes[i]; - const messageContent = 'A'.repeat(messageSize); - - const messageStart = Date.now(); - - // Send MAIL FROM - socket.write(`MAIL FROM:\r\n`); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write(`RCPT TO:\r\n`); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Send email content - const emailContent = [ - `From: sender${i}@example.com`, - `To: recipient${i}@example.com`, - `Subject: Message Processing Test ${i} (${messageSize} bytes)`, - '', - messageContent, - '.', - '' - ].join('\r\n'); - - socket.write(emailContent); - await waitForResponse(socket, '250'); - - const messageProcessingTime = Date.now() - messageStart; - messageProcessingTimes.push(messageProcessingTime); - - const processingRateKBps = (messageSize / 1024) / (messageProcessingTime / 1000); - processingRates.push(processingRateKBps); - - console.log(`${messageSize} bytes: ${messageProcessingTime}ms (${processingRateKBps.toFixed(1)} KB/s)`); - - // Send RSET - socket.write('RSET\r\n'); - - await new Promise((resolve) => { - socket.once('data', (chunk) => { - const response = chunk.toString(); - expect(response).toInclude('250'); - resolve(); - }); - }); - - // Small delay between tests - await new Promise(resolve => setTimeout(resolve, 100)); - } - - // Calculate statistics - const avgProcessingTime = messageProcessingTimes.reduce((a, b) => a + b, 0) / messageProcessingTimes.length; - const avgProcessingRate = processingRates.reduce((a, b) => a + b, 0) / processingRates.length; - const minProcessingTime = Math.min(...messageProcessingTimes); - const maxProcessingTime = Math.max(...messageProcessingTimes); - - console.log(`\nMessage Processing Results:`); - console.log(`Average processing time: ${avgProcessingTime.toFixed(0)}ms`); - console.log(`Min/Max processing time: ${minProcessingTime}ms / ${maxProcessingTime}ms`); - console.log(`Average processing rate: ${avgProcessingRate.toFixed(1)} KB/s`); - - socket.write('QUIT\r\n'); - socket.end(); - - // Test passes if average processing time is less than 3000ms and rate > 10KB/s - expect(avgProcessingTime).toBeLessThan(3000); - expect(avgProcessingRate).toBeGreaterThan(10); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('PERF-06: Message processing time - Large message handling', async (tools) => { - const done = tools.defer(); - const largeSizes = [100000, 250000, 500000]; // 100KB, 250KB, 500KB - const results: Array<{ size: number; time: number; rate: number }> = []; - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 60000 // Longer timeout for large messages - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testhost-large\r\n'); - await waitForResponse(socket, '250'); - - console.log('\nTesting large message processing...\n'); - - for (let i = 0; i < largeSizes.length; i++) { - const messageSize = largeSizes[i]; - - const messageStart = Date.now(); - - // Send MAIL FROM - socket.write(`MAIL FROM:\r\n`); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write(`RCPT TO:\r\n`); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Send large email content in chunks to avoid buffer issues - socket.write(`From: largesender${i}@example.com\r\n`); - socket.write(`To: largerecipient${i}@example.com\r\n`); - socket.write(`Subject: Large Message Test ${i} (${messageSize} bytes)\r\n\r\n`); - - // Send content in 10KB chunks - const chunkSize = 10000; - let remaining = messageSize; - while (remaining > 0) { - const currentChunk = Math.min(remaining, chunkSize); - socket.write('B'.repeat(currentChunk)); - remaining -= currentChunk; - - // Small delay to avoid overwhelming buffers - if (remaining > 0) { - await new Promise(resolve => setTimeout(resolve, 10)); - } - } - - socket.write('\r\n.\r\n'); - - const response = await waitForResponse(socket, '250', 30000); - expect(response).toInclude('250'); - - const messageProcessingTime = Date.now() - messageStart; - const processingRateMBps = (messageSize / (1024 * 1024)) / (messageProcessingTime / 1000); - - results.push({ - size: messageSize, - time: messageProcessingTime, - rate: processingRateMBps - }); - - console.log(`${(messageSize/1024).toFixed(0)}KB: ${messageProcessingTime}ms (${processingRateMBps.toFixed(2)} MB/s)`); - - // Send RSET - socket.write('RSET\r\n'); - await waitForResponse(socket, '250'); - - // Delay between large tests - await new Promise(resolve => setTimeout(resolve, 500)); - } - - const avgRate = results.reduce((sum, r) => sum + r.rate, 0) / results.length; - console.log(`\nAverage large message rate: ${avgRate.toFixed(2)} MB/s`); - - socket.write('QUIT\r\n'); - socket.end(); - - // Test passes if we can process at least 0.5 MB/s - expect(avgRate).toBeGreaterThan(0.5); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_performance/test.perf-07.resource-cleanup.ts b/test/suite/smtpserver_performance/test.perf-07.resource-cleanup.ts deleted file mode 100644 index 1b82a07..0000000 --- a/test/suite/smtpserver_performance/test.perf-07.resource-cleanup.ts +++ /dev/null @@ -1,317 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('PERF-07: Resource cleanup - Connection cleanup efficiency', async (tools) => { - const done = tools.defer(); - const testConnections = 20; // Reduced from 50 - const connections: net.Socket[] = []; - const cleanupTimes: number[] = []; - - try { - // Force GC if available - if (global.gc) { - global.gc(); - } - - const initialMemory = process.memoryUsage(); - console.log(`Initial memory: ${Math.round(initialMemory.heapUsed / (1024 * 1024))}MB`); - console.log(`Creating ${testConnections} connections for resource cleanup test...`); - - // Create many connections and process emails - for (let i = 0; i < testConnections; i++) { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - connections.push(socket); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write(`EHLO testhost-cleanup-${i}\r\n`); - - await waitForResponse(socket, '250'); - - // Complete email transaction - socket.write(`MAIL FROM:\r\n`); - await waitForResponse(socket, '250'); - - socket.write(`RCPT TO:\r\n`); - await waitForResponse(socket, '250'); - - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - const emailContent = [ - `From: sender${i}@example.com`, - `To: recipient${i}@example.com`, - `Subject: Resource Cleanup Test ${i}`, - '', - 'Testing resource cleanup.', - '.', - '' - ].join('\r\n'); - - socket.write(emailContent); - await waitForResponse(socket, '250'); - - // Pause every 10 connections - if (i > 0 && i % 10 === 0) { - await new Promise(resolve => setTimeout(resolve, 50)); - } - } - - const midTestMemory = process.memoryUsage(); - console.log(`Memory after creating connections: ${Math.round(midTestMemory.heapUsed / (1024 * 1024))}MB`); - - // Clean up all connections and measure cleanup time - console.log('\nCleaning up connections...'); - - for (let i = 0; i < connections.length; i++) { - const socket = connections[i]; - const cleanupStart = Date.now(); - - try { - if (socket.writable) { - socket.write('QUIT\r\n'); - try { - await waitForResponse(socket, '221', 1000); - } catch (e) { - // Ignore timeout on QUIT - } - } - - socket.end(); - await new Promise((resolve) => { - socket.once('close', () => resolve()); - setTimeout(() => resolve(), 100); // Fallback timeout - }); - - cleanupTimes.push(Date.now() - cleanupStart); - } catch (error) { - cleanupTimes.push(Date.now() - cleanupStart); - } - } - - // Wait for cleanup to complete - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Force GC if available - if (global.gc) { - global.gc(); - await new Promise(resolve => setTimeout(resolve, 1000)); - } - - const finalMemory = process.memoryUsage(); - const memoryIncreaseMB = (finalMemory.heapUsed - initialMemory.heapUsed) / (1024 * 1024); - const avgCleanupTime = cleanupTimes.reduce((a, b) => a + b, 0) / cleanupTimes.length; - const maxCleanupTime = Math.max(...cleanupTimes); - - console.log(`\nResource Cleanup Results:`); - console.log(`Initial memory: ${Math.round(initialMemory.heapUsed / (1024 * 1024))}MB`); - console.log(`Mid-test memory: ${Math.round(midTestMemory.heapUsed / (1024 * 1024))}MB`); - console.log(`Final memory: ${Math.round(finalMemory.heapUsed / (1024 * 1024))}MB`); - console.log(`Memory increase: ${memoryIncreaseMB.toFixed(2)}MB`); - console.log(`Average cleanup time: ${avgCleanupTime.toFixed(0)}ms`); - console.log(`Max cleanup time: ${maxCleanupTime}ms`); - - // Test passes if memory increase is less than 10MB and cleanup is fast - expect(memoryIncreaseMB).toBeLessThan(10); - expect(avgCleanupTime).toBeLessThan(100); - done.resolve(); - } catch (error) { - // Emergency cleanup - connections.forEach(socket => socket.destroy()); - done.reject(error); - } -}); - -tap.test('PERF-07: Resource cleanup - File descriptor management', async (tools) => { - const done = tools.defer(); - const rapidConnections = 20; - let successfulCleanups = 0; - - try { - console.log(`\nTesting rapid connection open/close cycles...`); - - for (let i = 0; i < rapidConnections; i++) { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 10000 - }); - - try { - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - await waitForResponse(socket, '220'); - - // Quick EHLO/QUIT - socket.write('EHLO rapidtest\r\n'); - await waitForResponse(socket, '250'); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - - await new Promise((resolve) => { - socket.once('close', () => { - successfulCleanups++; - resolve(); - }); - }); - - } catch (error) { - socket.destroy(); - console.log(`Connection ${i} failed:`, error); - } - - // Very short delay - await new Promise(resolve => setTimeout(resolve, 20)); - } - - console.log(`Successful cleanups: ${successfulCleanups}/${rapidConnections}`); - - // Test passes if at least 90% of connections cleaned up successfully - const cleanupRate = successfulCleanups / rapidConnections; - expect(cleanupRate).toBeGreaterThanOrEqual(0.9); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('PERF-07: Resource cleanup - Memory recovery after load', async (tools) => { - const done = tools.defer(); - - try { - // Force GC if available - if (global.gc) { - global.gc(); - } - - const baselineMemory = process.memoryUsage(); - console.log(`\nBaseline memory: ${Math.round(baselineMemory.heapUsed / (1024 * 1024))}MB`); - - // Create load - const loadConnections = 10; - const sockets: net.Socket[] = []; - - console.log('Creating load...'); - for (let i = 0; i < loadConnections; i++) { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - sockets.push(socket); - - // Just connect, don't send anything - await waitForResponse(socket, '220'); - } - - const loadMemory = process.memoryUsage(); - console.log(`Memory under load: ${Math.round(loadMemory.heapUsed / (1024 * 1024))}MB`); - - // Clean up all at once - console.log('Cleaning up all connections...'); - sockets.forEach(socket => { - socket.destroy(); - }); - - // Wait for cleanup - await new Promise(resolve => setTimeout(resolve, 3000)); - - // Force GC multiple times - if (global.gc) { - for (let i = 0; i < 3; i++) { - global.gc(); - await new Promise(resolve => setTimeout(resolve, 500)); - } - } - - const recoveredMemory = process.memoryUsage(); - const memoryIncrease = loadMemory.heapUsed - baselineMemory.heapUsed; - const memoryRecovered = loadMemory.heapUsed - recoveredMemory.heapUsed; - const recoveryPercent = memoryIncrease > 0 ? (memoryRecovered / memoryIncrease) * 100 : 100; - - console.log(`Memory after cleanup: ${Math.round(recoveredMemory.heapUsed / (1024 * 1024))}MB`); - console.log(`Memory recovered: ${Math.round(memoryRecovered / (1024 * 1024))}MB`); - console.log(`Recovery percentage: ${recoveryPercent.toFixed(1)}%`); - - // Test passes if memory is stable (no significant increase) or we recover at least 50% - if (memoryIncrease < 1024 * 1024) { // Less than 1MB increase - console.log('Memory usage was stable during test - good resource management!'); - expect(true).toEqual(true); - } else { - expect(recoveryPercent).toBeGreaterThan(50); - } - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_reliability/test.rel-01.long-running-operation.ts b/test/suite/smtpserver_reliability/test.rel-01.long-running-operation.ts deleted file mode 100644 index 4948b1d..0000000 --- a/test/suite/smtpserver_reliability/test.rel-01.long-running-operation.ts +++ /dev/null @@ -1,344 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('REL-01: Long-running operation - Continuous email sending', async (tools) => { - const done = tools.defer(); - const testDuration = 30000; // 30 seconds - const operationInterval = 2000; // 2 seconds between operations - const startTime = Date.now(); - const endTime = startTime + testDuration; - - let operations = 0; - let successful = 0; - let errors = 0; - let connectionIssues = 0; - const operationResults: Array<{ - operation: number; - success: boolean; - duration: number; - error?: string; - timestamp: number; - }> = []; - - console.log(`Running long-duration test for ${testDuration/1000} seconds...`); - - const performOperation = async (operationId: number): Promise => { - const operationStart = Date.now(); - operations++; - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 10000 - }); - - const result = await new Promise<{ success: boolean; error?: string; connectionIssue?: boolean }>((resolve) => { - let step = 'connecting'; - let receivedData = ''; - - const timeout = setTimeout(() => { - socket.destroy(); - resolve({ - success: false, - error: `Timeout in step ${step}`, - connectionIssue: true - }); - }, 10000); - - socket.on('connect', () => { - step = 'connected'; - }); - - socket.on('data', (chunk) => { - receivedData += chunk.toString(); - const lines = receivedData.split('\r\n'); - - for (const line of lines) { - if (!line.trim()) continue; - - // Check for errors - if (line.match(/^[45]\d\d\s/)) { - clearTimeout(timeout); - socket.destroy(); - resolve({ - success: false, - error: `SMTP error in ${step}: ${line}`, - connectionIssue: false - }); - return; - } - - // Process responses - if (step === 'connected' && line.startsWith('220')) { - step = 'ehlo'; - socket.write(`EHLO longrun-${operationId}\r\n`); - } else if (step === 'ehlo' && line.includes('250 ') && !line.includes('250-')) { - step = 'mail_from'; - socket.write(`MAIL FROM:\r\n`); - } else if (step === 'mail_from' && line.startsWith('250')) { - step = 'rcpt_to'; - socket.write(`RCPT TO:\r\n`); - } else if (step === 'rcpt_to' && line.startsWith('250')) { - step = 'data'; - socket.write('DATA\r\n'); - } else if (step === 'data' && line.startsWith('354')) { - step = 'email_content'; - const emailContent = [ - `From: sender${operationId}@example.com`, - `To: recipient${operationId}@example.com`, - `Subject: Long Running Test Operation ${operationId}`, - `Date: ${new Date().toUTCString()}`, - '', - `This is test operation ${operationId} for long-running reliability testing.`, - `Timestamp: ${Date.now()}`, - '.', - '' - ].join('\r\n'); - socket.write(emailContent); - } else if (step === 'email_content' && line.startsWith('250')) { - step = 'quit'; - socket.write('QUIT\r\n'); - } else if (step === 'quit' && line.startsWith('221')) { - clearTimeout(timeout); - socket.end(); - resolve({ - success: true - }); - return; - } - } - }); - - socket.on('error', (error) => { - clearTimeout(timeout); - resolve({ - success: false, - error: error.message, - connectionIssue: true - }); - }); - - socket.on('close', () => { - if (step !== 'quit') { - clearTimeout(timeout); - resolve({ - success: false, - error: 'Connection closed unexpectedly', - connectionIssue: true - }); - } - }); - }); - - const duration = Date.now() - operationStart; - - if (result.success) { - successful++; - } else { - errors++; - if (result.connectionIssue) { - connectionIssues++; - } - } - - operationResults.push({ - operation: operationId, - success: result.success, - duration, - error: result.error, - timestamp: operationStart - }); - - } catch (error) { - errors++; - operationResults.push({ - operation: operationId, - success: false, - duration: Date.now() - operationStart, - error: error instanceof Error ? error.message : 'Unknown error', - timestamp: operationStart - }); - } - }; - - try { - // Run operations continuously until end time - while (Date.now() < endTime) { - const operationStart = Date.now(); - - await performOperation(operations + 1); - - // Calculate wait time for next operation - const nextOperation = operationStart + operationInterval; - const waitTime = nextOperation - Date.now(); - - if (waitTime > 0 && Date.now() < endTime) { - await new Promise(resolve => setTimeout(resolve, waitTime)); - } - - // Progress update every 5 operations - if (operations % 5 === 0) { - console.log(`Progress: ${operations} operations, ${successful} successful, ${errors} errors`); - } - } - - // Calculate results - const totalDuration = Date.now() - startTime; - const successRate = successful / operations; - const connectionIssueRate = connectionIssues / operations; - const avgOperationTime = operationResults.reduce((sum, r) => sum + r.duration, 0) / operations; - - console.log(`\nLong-Running Operation Results:`); - console.log(`Total duration: ${(totalDuration/1000).toFixed(1)}s`); - console.log(`Total operations: ${operations}`); - console.log(`Successful: ${successful} (${(successRate * 100).toFixed(1)}%)`); - console.log(`Errors: ${errors}`); - console.log(`Connection issues: ${connectionIssues} (${(connectionIssueRate * 100).toFixed(1)}%)`); - console.log(`Average operation time: ${avgOperationTime.toFixed(0)}ms`); - - // Show last few operations for debugging - console.log('\nLast 5 operations:'); - operationResults.slice(-5).forEach(op => { - console.log(` Op ${op.operation}: ${op.success ? 'success' : 'failed'} (${op.duration}ms)${op.error ? ' - ' + op.error : ''}`); - }); - - // Test passes with 85% success rate and max 10% connection issues - expect(successRate).toBeGreaterThanOrEqual(0.85); - expect(connectionIssueRate).toBeLessThanOrEqual(0.1); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('REL-01: Long-running operation - Server stability check', async (tools) => { - const done = tools.defer(); - const checkDuration = 15000; // 15 seconds - const checkInterval = 3000; // 3 seconds between checks - const startTime = Date.now(); - const endTime = startTime + checkDuration; - - const stabilityChecks: Array<{ - timestamp: number; - responseTime: number; - success: boolean; - error?: string; - }> = []; - - console.log(`\nRunning server stability checks for ${checkDuration/1000} seconds...`); - - try { - while (Date.now() < endTime) { - const checkStart = Date.now(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 5000 - }); - - const checkResult = await new Promise<{ success: boolean; responseTime: number; error?: string }>((resolve) => { - const connectTime = Date.now(); - let greetingReceived = false; - - const timeout = setTimeout(() => { - socket.destroy(); - resolve({ - success: false, - responseTime: Date.now() - connectTime, - error: 'Timeout waiting for greeting' - }); - }, 5000); - - socket.on('connect', () => { - // Connected - }); - - socket.once('data', (chunk) => { - const response = chunk.toString(); - clearTimeout(timeout); - greetingReceived = true; - - if (response.startsWith('220')) { - socket.write('QUIT\r\n'); - socket.end(); - resolve({ - success: true, - responseTime: Date.now() - connectTime - }); - } else { - socket.end(); - resolve({ - success: false, - responseTime: Date.now() - connectTime, - error: `Unexpected greeting: ${response.substring(0, 50)}` - }); - } - }); - - socket.on('error', (error) => { - clearTimeout(timeout); - resolve({ - success: false, - responseTime: Date.now() - connectTime, - error: error.message - }); - }); - }); - - stabilityChecks.push({ - timestamp: checkStart, - responseTime: checkResult.responseTime, - success: checkResult.success, - error: checkResult.error - }); - - console.log(`Stability check ${stabilityChecks.length}: ${checkResult.success ? 'OK' : 'FAILED'} (${checkResult.responseTime}ms)`); - - // Wait for next check - const nextCheck = checkStart + checkInterval; - const waitTime = nextCheck - Date.now(); - if (waitTime > 0 && Date.now() < endTime) { - await new Promise(resolve => setTimeout(resolve, waitTime)); - } - } - - // Analyze stability - const successfulChecks = stabilityChecks.filter(c => c.success).length; - const avgResponseTime = stabilityChecks - .filter(c => c.success) - .reduce((sum, c) => sum + c.responseTime, 0) / successfulChecks || 0; - const maxResponseTime = Math.max(...stabilityChecks.filter(c => c.success).map(c => c.responseTime)); - - console.log(`\nStability Check Results:`); - console.log(`Total checks: ${stabilityChecks.length}`); - console.log(`Successful: ${successfulChecks} (${(successfulChecks/stabilityChecks.length * 100).toFixed(1)}%)`); - console.log(`Average response time: ${avgResponseTime.toFixed(0)}ms`); - console.log(`Max response time: ${maxResponseTime}ms`); - - // All checks should succeed for stable server - expect(successfulChecks).toEqual(stabilityChecks.length); - expect(avgResponseTime).toBeLessThan(1000); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_reliability/test.rel-02.restart-recovery.ts b/test/suite/smtpserver_reliability/test.rel-02.restart-recovery.ts deleted file mode 100644 index 6cb78eb..0000000 --- a/test/suite/smtpserver_reliability/test.rel-02.restart-recovery.ts +++ /dev/null @@ -1,328 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Any complete response line - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('REL-02: Restart recovery - Server state after restart', async (tools) => { - const done = tools.defer(); - - try { - console.log('Testing server state and recovery capabilities...'); - - // First, establish that server is working normally - const socket1 = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket1.once('connect', resolve); - socket1.once('error', reject); - }); - - // Read greeting - const greeting1 = await waitForResponse(socket1, '220'); - expect(greeting1).toInclude('220'); - console.log('Initial connection successful'); - - // Send EHLO - socket1.write('EHLO testhost\r\n'); - await waitForResponse(socket1, '250'); - - // Complete a transaction - socket1.write('MAIL FROM:\r\n'); - const mailResp1 = await waitForResponse(socket1, '250'); - expect(mailResp1).toInclude('250'); - - socket1.write('RCPT TO:\r\n'); - const rcptResp1 = await waitForResponse(socket1, '250'); - expect(rcptResp1).toInclude('250'); - - socket1.write('DATA\r\n'); - const dataResp1 = await waitForResponse(socket1, '354'); - expect(dataResp1).toInclude('354'); - - const emailContent = [ - 'From: sender@example.com', - 'To: recipient@example.com', - 'Subject: Pre-restart test', - '', - 'Testing server state before restart.', - '.', - '' - ].join('\r\n'); - - socket1.write(emailContent); - const sendResp1 = await waitForResponse(socket1, '250'); - expect(sendResp1).toInclude('250'); - - socket1.write('QUIT\r\n'); - await waitForResponse(socket1, '221'); - socket1.end(); - - console.log('Pre-restart transaction completed successfully'); - - // Simulate server restart by closing and reopening connections - console.log('\nSimulating server restart scenario...'); - - // Wait a moment to simulate restart time - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Test recovery after simulated restart - const socket2 = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket2.once('connect', resolve); - socket2.once('error', reject); - }); - - // Read greeting after "restart" - const greeting2 = await new Promise((resolve) => { - socket2.once('data', (chunk) => { - resolve(chunk.toString()); - }); - }); - - expect(greeting2).toInclude('220'); - console.log('Post-restart connection successful'); - - // Verify server is fully functional after restart - socket2.write('EHLO testhost-postrestart\r\n'); - await waitForResponse(socket2, '250'); - - // Complete another transaction to verify full recovery - socket2.write('MAIL FROM:\r\n'); - const mailResp2 = await waitForResponse(socket2, '250'); - expect(mailResp2).toInclude('250'); - - socket2.write('RCPT TO:\r\n'); - const rcptResp2 = await waitForResponse(socket2, '250'); - expect(rcptResp2).toInclude('250'); - - socket2.write('DATA\r\n'); - const dataResp2 = await waitForResponse(socket2, '354'); - expect(dataResp2).toInclude('354'); - - const postRestartEmail = [ - 'From: sender2@example.com', - 'To: recipient2@example.com', - 'Subject: Post-restart recovery test', - '', - 'Testing server recovery after restart.', - '.', - '' - ].join('\r\n'); - - socket2.write(postRestartEmail); - const sendResp2 = await waitForResponse(socket2, '250'); - expect(sendResp2).toInclude('250'); - - socket2.write('QUIT\r\n'); - await waitForResponse(socket2, '221'); - socket2.end(); - - console.log('Post-restart transaction completed successfully'); - console.log('Server recovered successfully from restart'); - - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('REL-02: Restart recovery - Multiple rapid reconnections', async (tools) => { - const done = tools.defer(); - const rapidConnections = 10; - let successfulReconnects = 0; - - try { - console.log(`\nTesting rapid reconnection after disruption (${rapidConnections} attempts)...`); - - for (let i = 0; i < rapidConnections; i++) { - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 5000 - }); - - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - socket.destroy(); - reject(new Error('Connection timeout')); - }, 5000); - - socket.once('connect', () => { - clearTimeout(timeout); - resolve(); - }); - socket.once('error', (err) => { - clearTimeout(timeout); - reject(err); - }); - }); - - // Read greeting - try { - const greeting = await waitForResponse(socket, '220', 3000); - if (greeting.includes('220')) { - successfulReconnects++; - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221', 1000).catch(() => {}); - socket.end(); - } else { - socket.destroy(); - } - } catch (error) { - socket.destroy(); - throw error; - } - - // Very short delay between attempts - await new Promise(resolve => setTimeout(resolve, 100)); - - } catch (error) { - console.log(`Reconnection ${i + 1} failed:`, error.message); - } - } - - const reconnectRate = successfulReconnects / rapidConnections; - console.log(`Successful reconnections: ${successfulReconnects}/${rapidConnections} (${(reconnectRate * 100).toFixed(1)}%)`); - - // Expect high success rate for good recovery - expect(reconnectRate).toBeGreaterThanOrEqual(0.8); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('REL-02: Restart recovery - State persistence check', async (tools) => { - const done = tools.defer(); - - try { - console.log('\nTesting server state persistence across connections...'); - - // Create initial connection and start transaction - const socket1 = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket1.once('connect', resolve); - socket1.once('error', reject); - }); - - // Read greeting - await waitForResponse(socket1, '220'); - - // Send EHLO - socket1.write('EHLO persistence-test\r\n'); - await waitForResponse(socket1, '250'); - - // Start transaction but don't complete it - socket1.write('MAIL FROM:\r\n'); - const mailResp = await waitForResponse(socket1, '250'); - expect(mailResp).toInclude('250'); - - // Abruptly close connection - socket1.destroy(); - console.log('Abruptly closed connection with incomplete transaction'); - - // Wait briefly - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Create new connection and verify server recovered - const socket2 = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket2.once('connect', resolve); - socket2.once('error', reject); - }); - - // Read greeting - await waitForResponse(socket2, '220'); - - // Send EHLO - socket2.write('EHLO recovery-test\r\n'); - await waitForResponse(socket2, '250'); - - // Try new transaction - should work without issues from previous incomplete one - socket2.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket2, '250'); - expect(mailResponse).toInclude('250'); - console.log('Server recovered successfully - new transaction started without issues'); - - socket2.write('QUIT\r\n'); - await waitForResponse(socket2, '221'); - socket2.end(); - - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_reliability/test.rel-03.resource-leak-detection.ts b/test/suite/smtpserver_reliability/test.rel-03.resource-leak-detection.ts deleted file mode 100644 index 8d49ae5..0000000 --- a/test/suite/smtpserver_reliability/test.rel-03.resource-leak-detection.ts +++ /dev/null @@ -1,394 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; - -interface ResourceMetrics { - timestamp: number; - memoryUsage: { - rss: number; - heapTotal: number; - heapUsed: number; - external: number; - }; - processInfo: { - pid: number; - uptime: number; - cpuUsage: NodeJS.CpuUsage; - }; -} - -interface LeakAnalysis { - memoryGrowthMB: number; - memoryTrend: number; - stabilityScore: number; - memoryLeakDetected: boolean; - resourcesStable: boolean; - samplesAnalyzed: number; - initialMemoryMB: number; - finalMemoryMB: number; -} - -const captureResourceMetrics = async (): Promise => { - // Force GC if available before measurement - if (global.gc) { - global.gc(); - await new Promise(resolve => setTimeout(resolve, 100)); - } - - const memUsage = process.memoryUsage(); - - return { - timestamp: Date.now(), - memoryUsage: { - rss: Math.round(memUsage.rss / 1024 / 1024 * 100) / 100, - heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024 * 100) / 100, - heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024 * 100) / 100, - external: Math.round(memUsage.external / 1024 / 1024 * 100) / 100 - }, - processInfo: { - pid: process.pid, - uptime: process.uptime(), - cpuUsage: process.cpuUsage() - } - }; -}; - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Any complete response line - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -const analyzeResourceLeaks = (initial: ResourceMetrics, samples: Array<{ operation: number; metrics: ResourceMetrics }>, final: ResourceMetrics): LeakAnalysis => { - const memoryGrowthMB = final.memoryUsage.heapUsed - initial.memoryUsage.heapUsed; - - // Analyze memory trend over samples - let memoryTrend = 0; - if (samples.length > 1) { - const firstSample = samples[0].metrics.memoryUsage.heapUsed; - const lastSample = samples[samples.length - 1].metrics.memoryUsage.heapUsed; - memoryTrend = lastSample - firstSample; - } - - // Calculate stability score based on memory variance - let stabilityScore = 1.0; - if (samples.length > 2) { - const memoryValues = samples.map(s => s.metrics.memoryUsage.heapUsed); - const average = memoryValues.reduce((a, b) => a + b, 0) / memoryValues.length; - const variance = memoryValues.reduce((acc, val) => acc + Math.pow(val - average, 2), 0) / memoryValues.length; - const stdDev = Math.sqrt(variance); - stabilityScore = Math.max(0, 1 - (stdDev / average)); - } - - return { - memoryGrowthMB: Math.round(memoryGrowthMB * 100) / 100, - memoryTrend: Math.round(memoryTrend * 100) / 100, - stabilityScore: Math.round(stabilityScore * 100) / 100, - memoryLeakDetected: memoryGrowthMB > 50, - resourcesStable: stabilityScore > 0.8 && memoryGrowthMB < 25, - samplesAnalyzed: samples.length, - initialMemoryMB: initial.memoryUsage.heapUsed, - finalMemoryMB: final.memoryUsage.heapUsed - }; -}; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('REL-03: Resource leak detection - Memory leak analysis', async (tools) => { - const done = tools.defer(); - const operationCount = 20; - const connections: net.Socket[] = []; - const samples: Array<{ operation: number; metrics: ResourceMetrics }> = []; - - try { - const initialMetrics = await captureResourceMetrics(); - console.log(`📊 Initial memory: ${initialMetrics.memoryUsage.heapUsed}MB`); - - for (let i = 0; i < operationCount; i++) { - console.log(`🔄 Operation ${i + 1}/${operationCount}...`); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - connections.push(socket); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write(`EHLO leaktest-${i}\r\n`); - await waitForResponse(socket, '250'); - - // Complete email transaction - socket.write(`MAIL FROM:\r\n`); - const mailResp = await waitForResponse(socket, '250'); - expect(mailResp).toInclude('250'); - - socket.write(`RCPT TO:\r\n`); - const rcptResp = await waitForResponse(socket, '250'); - expect(rcptResp).toInclude('250'); - - socket.write('DATA\r\n'); - const dataResp = await waitForResponse(socket, '354'); - expect(dataResp).toInclude('354'); - - const emailContent = [ - `From: sender${i}@example.com`, - `To: recipient${i}@example.com`, - `Subject: Resource Leak Test ${i + 1}`, - `Message-ID: `, - '', - `This is resource leak test iteration ${i + 1}.`, - '.', - '' - ].join('\r\n'); - - socket.write(emailContent); - const sendResp = await waitForResponse(socket, '250'); - expect(sendResp).toInclude('250'); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - socket.end(); - - // Capture metrics every 5 operations - if ((i + 1) % 5 === 0) { - const metrics = await captureResourceMetrics(); - samples.push({ - operation: i + 1, - metrics - }); - console.log(`📈 Sample ${samples.length}: Memory ${metrics.memoryUsage.heapUsed}MB`); - } - - // Small delay between operations - await new Promise(resolve => setTimeout(resolve, 100)); - } - - // Clean up all connections - connections.forEach(conn => { - if (!conn.destroyed) { - conn.destroy(); - } - }); - - // Wait for cleanup - await new Promise(resolve => setTimeout(resolve, 2000)); - - const finalMetrics = await captureResourceMetrics(); - const leakAnalysis = analyzeResourceLeaks(initialMetrics, samples, finalMetrics); - - console.log('\n📊 Resource Leak Analysis:'); - console.log(`Initial memory: ${leakAnalysis.initialMemoryMB}MB`); - console.log(`Final memory: ${leakAnalysis.finalMemoryMB}MB`); - console.log(`Memory growth: ${leakAnalysis.memoryGrowthMB}MB`); - console.log(`Memory trend: ${leakAnalysis.memoryTrend}MB`); - console.log(`Stability score: ${leakAnalysis.stabilityScore}`); - console.log(`Memory leak detected: ${leakAnalysis.memoryLeakDetected}`); - console.log(`Resources stable: ${leakAnalysis.resourcesStable}`); - - expect(leakAnalysis.memoryLeakDetected).toEqual(false); - expect(leakAnalysis.resourcesStable).toEqual(true); - done.resolve(); - } catch (error) { - connections.forEach(conn => conn.destroy()); - done.reject(error); - } -}); - -tap.test('REL-03: Resource leak detection - Connection leak test', async (tools) => { - const done = tools.defer(); - const abandonedConnections: net.Socket[] = []; - - try { - console.log('\nTesting for connection resource leaks...'); - - // Create connections that are abandoned without proper cleanup - for (let i = 0; i < 10; i++) { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - abandonedConnections.push(socket); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting but don't complete transaction - await new Promise((resolve) => { - socket.once('data', () => resolve()); - }); - - // Start but don't complete EHLO - socket.write(`EHLO abandoned-${i}\r\n`); - - // Don't wait for response, just move to next - await new Promise(resolve => setTimeout(resolve, 50)); - } - - console.log('Created 10 abandoned connections'); - - // Wait a bit - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Try to create new connections - should still work - let newConnectionsSuccessful = 0; - for (let i = 0; i < 5; i++) { - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 5000 - }); - - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - socket.destroy(); - reject(new Error('Connection timeout')); - }, 5000); - - socket.once('connect', () => { - clearTimeout(timeout); - resolve(); - }); - socket.once('error', (err) => { - clearTimeout(timeout); - reject(err); - }); - }); - - // Verify connection works - const greeting = await new Promise((resolve) => { - socket.once('data', (chunk) => { - resolve(chunk.toString()); - }); - }); - - if (greeting.includes('220')) { - newConnectionsSuccessful++; - socket.write('QUIT\r\n'); - socket.end(); - } - } catch (error) { - console.log(`New connection ${i + 1} failed:`, error.message); - } - } - - // Clean up abandoned connections - abandonedConnections.forEach(conn => conn.destroy()); - - console.log(`New connections successful: ${newConnectionsSuccessful}/5`); - - // Server should still accept new connections despite abandoned ones - expect(newConnectionsSuccessful).toBeGreaterThanOrEqual(4); - done.resolve(); - } catch (error) { - abandonedConnections.forEach(conn => conn.destroy()); - done.reject(error); - } -}); - -tap.test('REL-03: Resource leak detection - Rapid create/destroy cycles', async (tools) => { - const done = tools.defer(); - const cycles = 30; - const initialMetrics = await captureResourceMetrics(); - - try { - console.log('\nTesting rapid connection create/destroy cycles...'); - - for (let i = 0; i < cycles; i++) { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 5000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Immediately destroy after connect - socket.destroy(); - - // Very short delay - await new Promise(resolve => setTimeout(resolve, 20)); - - if ((i + 1) % 10 === 0) { - console.log(`Completed ${i + 1} cycles`); - } - } - - // Wait for resources to be released - await new Promise(resolve => setTimeout(resolve, 3000)); - - const finalMetrics = await captureResourceMetrics(); - const memoryGrowth = finalMetrics.memoryUsage.heapUsed - initialMetrics.memoryUsage.heapUsed; - - console.log(`Memory growth after ${cycles} cycles: ${memoryGrowth.toFixed(2)}MB`); - - // Memory growth should be minimal - expect(memoryGrowth).toBeLessThan(10); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_reliability/test.rel-04.error-recovery.ts b/test/suite/smtpserver_reliability/test.rel-04.error-recovery.ts deleted file mode 100644 index ecd0c81..0000000 --- a/test/suite/smtpserver_reliability/test.rel-04.error-recovery.ts +++ /dev/null @@ -1,401 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; - -const createConnection = async (): Promise => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 5000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - return socket; -}; - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Any complete response line - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -const getResponse = waitForResponse; - -const testBasicSmtpFlow = async (socket: net.Socket): Promise => { - try { - // Read greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO recovery-test\r\n'); - const ehloResp = await waitForResponse(socket, '250'); - if (!ehloResp.includes('250')) return false; - - socket.write('MAIL FROM:\r\n'); - const mailResp = await waitForResponse(socket, '250'); - if (!mailResp.includes('250')) return false; - - socket.write('RCPT TO:\r\n'); - const rcptResp = await waitForResponse(socket, '250'); - if (!rcptResp.includes('250')) return false; - - socket.write('DATA\r\n'); - const dataResp = await waitForResponse(socket, '354'); - if (!dataResp.includes('354')) return false; - - const testEmail = [ - 'From: sender@example.com', - 'To: recipient@example.com', - 'Subject: Recovery Test Email', - '', - 'This email tests server recovery.', - '.', - '' - ].join('\r\n'); - - socket.write(testEmail); - const finalResp = await waitForResponse(socket, '250'); - - socket.write('QUIT\r\n'); - socket.end(); - - return finalResp.includes('250'); - } catch (error) { - console.log('Basic SMTP flow error:', error); - return false; - } -}; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('REL-04: Error recovery - Invalid command recovery', async (tools) => { - const done = tools.defer(); - - try { - console.log('Testing recovery from invalid commands...'); - - // Phase 1: Send invalid commands - const socket1 = await createConnection(); - await waitForResponse(socket1, '220'); - - // Send multiple invalid commands - socket1.write('INVALID_COMMAND\r\n'); - const response1 = await waitForResponse(socket1); - expect(response1).toMatch(/50[0-3]/); // Should get error response - - socket1.write('ANOTHER_INVALID\r\n'); - const response2 = await waitForResponse(socket1); - expect(response2).toMatch(/50[0-3]/); - - socket1.write('YET_ANOTHER_BAD_CMD\r\n'); - const response3 = await waitForResponse(socket1); - expect(response3).toMatch(/50[0-3]/); - - socket1.end(); - - // Phase 2: Test recovery - server should still work normally - await new Promise(resolve => setTimeout(resolve, 500)); - - const socket2 = await createConnection(); - const recoverySuccess = await testBasicSmtpFlow(socket2); - - expect(recoverySuccess).toEqual(true); - console.log('✓ Server recovered from invalid commands'); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('REL-04: Error recovery - Malformed data recovery', async (tools) => { - const done = tools.defer(); - - try { - console.log('\nTesting recovery from malformed data...'); - - // Phase 1: Send malformed data - const socket1 = await createConnection(); - await waitForResponse(socket1, '220'); - - socket1.write('EHLO testhost\r\n'); - await waitForResponse(socket1, '250'); - - // Send malformed MAIL FROM - socket1.write('MAIL FROM: invalid-format\r\n'); - const response1 = await waitForResponse(socket1); - expect(response1).toMatch(/50[0-3]/); - - // Send malformed RCPT TO - socket1.write('RCPT TO: also-invalid\r\n'); - const response2 = await waitForResponse(socket1); - expect(response2).toMatch(/50[0-3]/); - - // Send malformed DATA with binary - socket1.write('DATA\x00\x01\x02CORRUPTED\r\n'); - const response3 = await waitForResponse(socket1); - expect(response3).toMatch(/50[0-3]/); - - socket1.end(); - - // Phase 2: Test recovery - await new Promise(resolve => setTimeout(resolve, 500)); - - const socket2 = await createConnection(); - const recoverySuccess = await testBasicSmtpFlow(socket2); - - expect(recoverySuccess).toEqual(true); - console.log('✓ Server recovered from malformed data'); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('REL-04: Error recovery - Premature disconnection recovery', async (tools) => { - const done = tools.defer(); - - try { - console.log('\nTesting recovery from premature disconnection...'); - - // Phase 1: Create incomplete transactions - for (let i = 0; i < 3; i++) { - const socket = await createConnection(); - await waitForResponse(socket, '220'); - - socket.write('EHLO abrupt-test\r\n'); - await waitForResponse(socket, '250'); - - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Abruptly close connection during transaction - socket.destroy(); - console.log(` Abruptly closed connection ${i + 1}`); - - await new Promise(resolve => setTimeout(resolve, 200)); - } - - // Phase 2: Test recovery - await new Promise(resolve => setTimeout(resolve, 1000)); - - const socket2 = await createConnection(); - const recoverySuccess = await testBasicSmtpFlow(socket2); - - expect(recoverySuccess).toEqual(true); - console.log('✓ Server recovered from premature disconnections'); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('REL-04: Error recovery - Data corruption recovery', async (tools) => { - const done = tools.defer(); - - try { - console.log('\nTesting recovery from data corruption...'); - - const socket1 = await createConnection(); - await waitForResponse(socket1, '220'); - - socket1.write('EHLO corruption-test\r\n'); - await waitForResponse(socket1, '250'); - - socket1.write('MAIL FROM:\r\n'); - await waitForResponse(socket1, '250'); - - socket1.write('RCPT TO:\r\n'); - await waitForResponse(socket1, '250'); - - socket1.write('DATA\r\n'); - const dataResp = await waitForResponse(socket1, '354'); - expect(dataResp).toInclude('354'); - - // Send corrupted email data with null bytes and invalid characters - socket1.write('From: test\r\n\0\0\0CORRUPTED DATA\xff\xfe\r\n'); - socket1.write('Subject: \x01\x02\x03Invalid\r\n'); - socket1.write('\r\n'); - socket1.write('Body with \0null bytes\r\n'); - socket1.write('.\r\n'); - - try { - const response = await waitForResponse(socket1); - console.log(' Server response to corrupted data:', response.substring(0, 50)); - } catch (error) { - console.log(' Server rejected corrupted data (expected)'); - } - - socket1.end(); - - // Phase 2: Test recovery - await new Promise(resolve => setTimeout(resolve, 1000)); - - const socket2 = await createConnection(); - const recoverySuccess = await testBasicSmtpFlow(socket2); - - expect(recoverySuccess).toEqual(true); - console.log('✓ Server recovered from data corruption'); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('REL-04: Error recovery - Connection flooding recovery', async (tools) => { - const done = tools.defer(); - const connections: net.Socket[] = []; - - try { - console.log('\nTesting recovery from connection flooding...'); - - // Phase 1: Create multiple rapid connections - console.log(' Creating 15 rapid connections...'); - for (let i = 0; i < 15; i++) { - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 2000 - }); - connections.push(socket); - - // Don't wait for connection to complete - await new Promise(resolve => setTimeout(resolve, 50)); - } catch (error) { - // Some connections might fail - that's expected - console.log(` Connection ${i + 1} failed (expected during flooding)`); - } - } - - console.log(` Created ${connections.length} connections`); - - // Close all connections - connections.forEach(conn => { - try { - conn.destroy(); - } catch (e) { - // Ignore errors - } - }); - - // Phase 2: Test recovery - console.log(' Waiting for server to recover...'); - await new Promise(resolve => setTimeout(resolve, 3000)); - - const socket2 = await createConnection(); - const recoverySuccess = await testBasicSmtpFlow(socket2); - - expect(recoverySuccess).toEqual(true); - console.log('✓ Server recovered from connection flooding'); - done.resolve(); - } catch (error) { - connections.forEach(conn => conn.destroy()); - done.reject(error); - } -}); - -tap.test('REL-04: Error recovery - Mixed error scenario', async (tools) => { - const done = tools.defer(); - - try { - console.log('\nTesting recovery from mixed error scenarios...'); - - // Create multiple error conditions simultaneously - const errorPromises = []; - - // Invalid command connection - errorPromises.push((async () => { - const socket = await createConnection(); - await waitForResponse(socket, '220'); - socket.write('TOTALLY_WRONG\r\n'); - await waitForResponse(socket); - socket.destroy(); - })()); - - // Malformed data connection - errorPromises.push((async () => { - const socket = await createConnection(); - await waitForResponse(socket, '220'); - socket.write('MAIL FROM:<<>>\r\n'); - try { - await waitForResponse(socket); - } catch (e) { - // Expected - } - socket.destroy(); - })()); - - // Abrupt disconnection - errorPromises.push((async () => { - const socket = await createConnection(); - socket.destroy(); - })()); - - // Wait for all errors to execute - await Promise.allSettled(errorPromises); - - console.log(' All error scenarios executed'); - - // Test recovery - await new Promise(resolve => setTimeout(resolve, 2000)); - - const socket = await createConnection(); - const recoverySuccess = await testBasicSmtpFlow(socket); - - expect(recoverySuccess).toEqual(true); - console.log('✓ Server recovered from mixed error scenarios'); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_reliability/test.rel-05.dns-resolution-failure.ts b/test/suite/smtpserver_reliability/test.rel-05.dns-resolution-failure.ts deleted file mode 100644 index 4d93fa9..0000000 --- a/test/suite/smtpserver_reliability/test.rel-05.dns-resolution-failure.ts +++ /dev/null @@ -1,335 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; - -interface DnsTestResult { - scenario: string; - domain: string; - expectedBehavior: string; - mailFromSuccess: boolean; - rcptToSuccess: boolean; - mailFromResponse: string; - rcptToResponse: string; - handledGracefully: boolean; -} - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Any complete response line - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('REL-05: DNS resolution failure handling - Non-existent domains', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO dns-test\r\n'); - await waitForResponse(socket, '250'); - - console.log('Testing DNS resolution for non-existent domains...'); - - // Test 1: Non-existent domain in MAIL FROM - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - - console.log(' MAIL FROM response:', mailResponse.trim()); - - // Server should either accept (and defer later) or reject immediately - const mailFromHandled = mailResponse.includes('250') || - mailResponse.includes('450') || - mailResponse.includes('550'); - expect(mailFromHandled).toEqual(true); - - // Reset if needed - if (mailResponse.includes('250')) { - socket.write('RSET\r\n'); - await waitForResponse(socket, '250'); - } - - // Test 2: Non-existent domain in RCPT TO - socket.write('MAIL FROM:\r\n'); - const mailFromResp = await waitForResponse(socket, '250'); - expect(mailFromResp).toInclude('250'); - - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket); - - console.log(' RCPT TO response:', rcptResponse.trim()); - - // Server may accept (and defer validation) or reject immediately - const rcptToHandled = rcptResponse.includes('250') || // Accepted (for later validation) - rcptResponse.includes('450') || // Temporary failure - rcptResponse.includes('550') || // Permanent failure - rcptResponse.includes('553'); // Address error - expect(rcptToHandled).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('REL-05: DNS resolution failure handling - Malformed domains', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO malformed-test\r\n'); - await waitForResponse(socket, '250'); - - console.log('\nTesting malformed domain handling...'); - - const malformedDomains = [ - 'malformed..domain..test', - 'invalid-.domain.com', - 'domain.with.spaces .com', - '.leading-dot.com', - 'trailing-dot.com.', - 'domain@with@at.com', - 'a'.repeat(255) + '.toolong.com' // Domain too long - ]; - - for (const domain of malformedDomains) { - console.log(` Testing: ${domain.substring(0, 50)}${domain.length > 50 ? '...' : ''}`); - - socket.write(`MAIL FROM:\r\n`); - const response = await waitForResponse(socket); - - // Server should reject malformed domains or accept for later validation - const properlyHandled = response.includes('250') || // Accepted (may validate later) - response.includes('501') || // Syntax error - response.includes('550') || // Rejected - response.includes('553'); // Address error - - console.log(` Response: ${response.trim().substring(0, 50)}`); - expect(properlyHandled).toEqual(true); - - // Reset if needed - if (!response.includes('5')) { - socket.write('RSET\r\n'); - await waitForResponse(socket, '250'); - } - } - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('REL-05: DNS resolution failure handling - Special cases', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO special-test\r\n'); - await waitForResponse(socket, '250'); - - console.log('\nTesting special DNS cases...'); - - // Test 1: Localhost (may be accepted or rejected) - socket.write('MAIL FROM:\r\n'); - const localhostResponse = await waitForResponse(socket); - - console.log(' Localhost response:', localhostResponse.trim()); - const localhostHandled = localhostResponse.includes('250') || localhostResponse.includes('501'); - expect(localhostHandled).toEqual(true); - - // Only reset if transaction was started - if (localhostResponse.includes('250')) { - socket.write('RSET\r\n'); - await waitForResponse(socket, '250'); - } - - // Test 2: IP address (should work) - socket.write('MAIL FROM:\r\n'); - const ipResponse = await waitForResponse(socket); - - console.log(' IP address response:', ipResponse.trim()); - const ipHandled = ipResponse.includes('250') || ipResponse.includes('501'); - expect(ipHandled).toEqual(true); - - // Only reset if transaction was started - if (ipResponse.includes('250')) { - socket.write('RSET\r\n'); - await waitForResponse(socket, '250'); - } - - // Test 3: Empty domain - socket.write('MAIL FROM:\r\n'); - const emptyResponse = await waitForResponse(socket); - - console.log(' Empty domain response:', emptyResponse.trim()); - expect(emptyResponse).toMatch(/50[1-3]/); // Should reject - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('REL-05: DNS resolution failure handling - Mixed valid/invalid recipients', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO mixed-test\r\n'); - await waitForResponse(socket, '250'); - - console.log('\nTesting mixed valid/invalid recipients...'); - - // Start transaction - socket.write('MAIL FROM:\r\n'); - const mailFromResp = await waitForResponse(socket, '250'); - expect(mailFromResp).toInclude('250'); - - // Add valid recipient - socket.write('RCPT TO:\r\n'); - const validRcptResponse = await waitForResponse(socket, '250'); - - console.log(' Valid recipient:', validRcptResponse.trim()); - expect(validRcptResponse).toInclude('250'); - - // Add invalid recipient - socket.write('RCPT TO:\r\n'); - const invalidRcptResponse = await waitForResponse(socket); - - console.log(' Invalid recipient:', invalidRcptResponse.trim()); - - // Server may accept (for later validation) or reject invalid domain - const invalidHandled = invalidRcptResponse.includes('250') || // Accepted (for later validation) - invalidRcptResponse.includes('450') || - invalidRcptResponse.includes('550') || - invalidRcptResponse.includes('553'); - expect(invalidHandled).toEqual(true); - - // Try to send data (should work if at least one valid recipient) - socket.write('DATA\r\n'); - const dataResponse = await waitForResponse(socket); - - if (dataResponse.includes('354')) { - socket.write('Subject: Mixed recipient test\r\n\r\nTest\r\n.\r\n'); - await waitForResponse(socket, '250'); - console.log(' Message accepted with valid recipient'); - } else { - console.log(' Server rejected DATA (acceptable behavior)'); - } - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_reliability/test.rel-06.network-interruption.ts b/test/suite/smtpserver_reliability/test.rel-06.network-interruption.ts deleted file mode 100644 index cce98ed..0000000 --- a/test/suite/smtpserver_reliability/test.rel-06.network-interruption.ts +++ /dev/null @@ -1,410 +0,0 @@ -import * as plugins from '@git.zone/tstest/tapbundle'; -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; - -let testServer; - -const createConnection = async (): Promise => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 5000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - return socket; -}; - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Any complete response line - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -const getResponse = waitForResponse; - -const testBasicSmtpFlow = async (socket: net.Socket): Promise => { - try { - await waitForResponse(socket, '220'); - - socket.write('EHLO test.example.com\r\n'); - const ehloResp = await waitForResponse(socket, '250'); - if (!ehloResp.includes('250')) return false; - - socket.write('MAIL FROM:\r\n'); - const mailResp = await waitForResponse(socket, '250'); - if (!mailResp.includes('250')) return false; - - socket.write('RCPT TO:\r\n'); - const rcptResp = await waitForResponse(socket, '250'); - if (!rcptResp.includes('250')) return false; - - socket.write('DATA\r\n'); - const dataResp = await waitForResponse(socket, '354'); - if (!dataResp.includes('354')) return false; - - const testEmail = [ - 'From: test@example.com', - 'To: recipient@example.com', - 'Subject: Interruption Recovery Test', - '', - 'This email tests server recovery after network interruption.', - '.', - '' - ].join('\r\n'); - - socket.write(testEmail); - const finalResp = await waitForResponse(socket, '250'); - - socket.write('QUIT\r\n'); - socket.end(); - - return finalResp.includes('250'); - } catch (error) { - return false; - } -}; - -tap.test('prepare server', async () => { - testServer = await startTestServer({ port: TEST_PORT }); - await new Promise(resolve => setTimeout(resolve, 100)); -}); - -tap.test('REL-06: Network interruption - Sudden connection drop', async (tools) => { - const done = tools.defer(); - - try { - console.log('Testing sudden connection drop during session...'); - - // Phase 1: Create connection and drop it mid-session - const socket1 = await createConnection(); - await waitForResponse(socket1, '220'); - - socket1.write('EHLO testhost\r\n'); - await waitForResponse(socket1, '250'); - - socket1.write('MAIL FROM:\r\n'); - await waitForResponse(socket1, '250'); - - // Abruptly close connection during active session - socket1.destroy(); - console.log(' Connection abruptly closed'); - - // Phase 2: Test recovery - await new Promise(resolve => setTimeout(resolve, 1000)); - - const socket2 = await createConnection(); - const recoverySuccess = await testBasicSmtpFlow(socket2); - - expect(recoverySuccess).toEqual(true); - console.log('✓ Server recovered from sudden connection drop'); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('REL-06: Network interruption - Data transfer interruption', async (tools) => { - const done = tools.defer(); - - try { - console.log('\nTesting connection interruption during data transfer...'); - - const socket = await createConnection(); - await waitForResponse(socket, '220'); - - socket.write('EHLO datatest\r\n'); - await waitForResponse(socket, '250'); - - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - socket.write('DATA\r\n'); - const dataResp = await waitForResponse(socket, '354'); - expect(dataResp).toInclude('354'); - - // Start sending data but interrupt midway - socket.write('From: sender@example.com\r\n'); - socket.write('To: recipient@example.com\r\n'); - socket.write('Subject: Interruption Test\r\n\r\n'); - socket.write('This email will be interrupted...\r\n'); - - // Wait briefly then destroy connection (simulating network loss) - await new Promise(resolve => setTimeout(resolve, 500)); - socket.destroy(); - console.log(' Connection interrupted during data transfer'); - - // Test recovery - await new Promise(resolve => setTimeout(resolve, 1500)); - - const newSocket = await createConnection(); - const recoverySuccess = await testBasicSmtpFlow(newSocket); - - expect(recoverySuccess).toEqual(true); - console.log('✓ Server recovered from data transfer interruption'); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('REL-06: Network interruption - Rapid reconnection attempts', async (tools) => { - const done = tools.defer(); - const connections: net.Socket[] = []; - - try { - console.log('\nTesting rapid reconnection after interruptions...'); - - // Create and immediately destroy multiple connections - console.log(' Creating 5 unstable connections...'); - for (let i = 0; i < 5; i++) { - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 2000 - }); - - connections.push(socket); - - // Destroy after short random delay to simulate instability - setTimeout(() => socket.destroy(), 50 + Math.random() * 150); - - await new Promise(resolve => setTimeout(resolve, 50)); - } catch (error) { - // Expected - some connections might fail - } - } - - // Wait for cleanup - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Now test if server can handle normal connections - let successfulConnections = 0; - console.log(' Testing recovery with stable connections...'); - - for (let i = 0; i < 3; i++) { - try { - const socket = await createConnection(); - const success = await testBasicSmtpFlow(socket); - - if (success) { - successfulConnections++; - } - } catch (error) { - console.log(` Connection ${i + 1} failed:`, error.message); - } - - await new Promise(resolve => setTimeout(resolve, 500)); - } - - const recoveryRate = successfulConnections / 3; - console.log(` Recovery rate: ${successfulConnections}/3 (${(recoveryRate * 100).toFixed(0)}%)`); - - expect(recoveryRate).toBeGreaterThanOrEqual(0.66); // At least 2/3 should succeed - console.log('✓ Server recovered from rapid reconnection attempts'); - done.resolve(); - } catch (error) { - connections.forEach(conn => conn.destroy()); - done.reject(error); - } -}); - -tap.test('REL-06: Network interruption - Partial command interruption', async (tools) => { - const done = tools.defer(); - - try { - console.log('\nTesting partial command transmission interruption...'); - - const socket = await createConnection(); - await waitForResponse(socket, '220'); - - // Send partial EHLO command and interrupt - socket.write('EH'); - console.log(' Sent partial command "EH"'); - - await new Promise(resolve => setTimeout(resolve, 100)); - socket.destroy(); - console.log(' Connection destroyed with incomplete command'); - - // Test recovery - await new Promise(resolve => setTimeout(resolve, 1000)); - - const newSocket = await createConnection(); - const recoverySuccess = await testBasicSmtpFlow(newSocket); - - expect(recoverySuccess).toEqual(true); - console.log('✓ Server recovered from partial command interruption'); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('REL-06: Network interruption - Multiple interruption types', async (tools) => { - const done = tools.defer(); - const results: Array<{ type: string; recovered: boolean }> = []; - - try { - console.log('\nTesting recovery from multiple interruption types...'); - - // Test 1: Interrupt after greeting - try { - const socket = await createConnection(); - await waitForResponse(socket, '220'); - socket.destroy(); - results.push({ type: 'after-greeting', recovered: false }); - } catch (e) { - results.push({ type: 'after-greeting', recovered: false }); - } - - await new Promise(resolve => setTimeout(resolve, 500)); - - // Test 2: Interrupt during EHLO - try { - const socket = await createConnection(); - await waitForResponse(socket, '220'); - socket.write('EHLO te'); - socket.destroy(); - results.push({ type: 'during-ehlo', recovered: false }); - } catch (e) { - results.push({ type: 'during-ehlo', recovered: false }); - } - - await new Promise(resolve => setTimeout(resolve, 500)); - - // Test 3: Interrupt with invalid data - try { - const socket = await createConnection(); - await waitForResponse(socket, '220'); - socket.write('\x00\x01\x02\x03'); - socket.destroy(); - results.push({ type: 'invalid-data', recovered: false }); - } catch (e) { - results.push({ type: 'invalid-data', recovered: false }); - } - - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Test final recovery - try { - const socket = await createConnection(); - const success = await testBasicSmtpFlow(socket); - - if (success) { - // Mark all previous tests as recovered - results.forEach(r => r.recovered = true); - } - } catch (error) { - console.log('Final recovery failed:', error.message); - } - - const recoveredCount = results.filter(r => r.recovered).length; - console.log(`\nInterruption recovery summary:`); - results.forEach(r => { - console.log(` ${r.type}: ${r.recovered ? 'recovered' : 'failed'}`); - }); - - expect(recoveredCount).toBeGreaterThan(0); - console.log(`✓ Server recovered from ${recoveredCount}/${results.length} interruption scenarios`); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('REL-06: Network interruption - Long delay recovery', async (tools) => { - const done = tools.defer(); - - try { - console.log('\nTesting recovery after long network interruption...'); - - // Create connection and start transaction - const socket = await createConnection(); - await waitForResponse(socket, '220'); - - socket.write('EHLO longdelay\r\n'); - await waitForResponse(socket, '250'); - - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Simulate long network interruption - socket.pause(); - console.log(' Connection paused (simulating network freeze)'); - - await new Promise(resolve => setTimeout(resolve, 5000)); // 5 second "freeze" - - // Try to continue - should fail - socket.resume(); - socket.write('RCPT TO:\r\n'); - - let continuationFailed = false; - try { - await waitForResponse(socket, '250', 3000); - } catch (error) { - continuationFailed = true; - console.log(' Continuation failed as expected'); - } - - socket.destroy(); - - // Test recovery with new connection - const newSocket = await createConnection(); - const recoverySuccess = await testBasicSmtpFlow(newSocket); - - expect(recoverySuccess).toEqual(true); - console.log('✓ Server recovered after long network interruption'); - done.resolve(); - } catch (error) { - done.reject(error); - } -}); - -tap.test('cleanup server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_rfc-compliance/test.rfc-01.rfc5321-compliance.ts b/test/suite/smtpserver_rfc-compliance/test.rfc-01.rfc5321-compliance.ts deleted file mode 100644 index 1e03cd8..0000000 --- a/test/suite/smtpserver_rfc-compliance/test.rfc-01.rfc5321-compliance.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Any complete response line - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('RFC 5321 - Server greeting format', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for initial greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server greeting:', greeting.trim()); - - // RFC 5321: Server must provide proper 220 greeting - const greetingLine = greeting.trim(); - const validGreeting = greetingLine.startsWith('220') && greetingLine.length > 10; - - expect(validGreeting).toEqual(true); - expect(greetingLine).toMatch(/^220\s+\S+/); // Should have hostname after 220 - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 5321 - EHLO response format', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // RFC 5321: EHLO must return 250 with hostname and extensions - const ehloLines = ehloResponse.split('\r\n').filter(line => line.startsWith('250')); - - expect(ehloLines.length).toBeGreaterThan(0); - expect(ehloLines[0]).toMatch(/^250[\s-]\S+/); // First line should have hostname - - // Check for common extensions - const extensions = ehloLines.slice(1).map(line => line.substring(4).trim()); - console.log('Extensions:', extensions); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 5321 - Command case insensitivity', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Test lowercase command - socket.write('ehlo testclient\r\n'); - await waitForResponse(socket, '250'); - - // Test mixed case command - socket.write('MaIl FrOm:\r\n'); - await waitForResponse(socket, '250'); - - // Test uppercase command - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // All case variations worked - console.log('All case variations accepted'); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 5321 - Line length limits', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // RFC 5321: Command line limit is 512 chars including CRLF - // Test with a long MAIL FROM command (but within limit) - const longDomain = 'a'.repeat(400); - socket.write(`MAIL FROM:\r\n`); - const response = await waitForResponse(socket); - - // Should either accept (if within server limits) or reject gracefully - const accepted = response.includes('250'); - const rejected = response.includes('501') || response.includes('500'); - - expect(accepted || rejected).toEqual(true); - console.log(`Long line test ${accepted ? 'accepted' : 'rejected'}`); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 5321 - Standard SMTP verb compliance', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - const supportedVerbs: string[] = []; - - // Wait for greeting - await waitForResponse(socket, '220'); - - // Try HELP command to see supported verbs - socket.write('HELP\r\n'); - const helpResponse = await waitForResponse(socket); - - // Parse HELP response for supported commands - if (helpResponse.includes('214') || helpResponse.includes('502')) { - // Either help text or command not implemented - } - - // Test NOOP - socket.write('NOOP\r\n'); - const noopResponse = await waitForResponse(socket); - if (noopResponse.includes('250')) { - supportedVerbs.push('NOOP'); - } - - // Test RSET - socket.write('RSET\r\n'); - const rsetResponse = await waitForResponse(socket); - if (rsetResponse.includes('250')) { - supportedVerbs.push('RSET'); - } - - // Test VRFY - socket.write('VRFY test@example.com\r\n'); - const vrfyResponse = await waitForResponse(socket); - // VRFY may be disabled for security (252 or 502) - if (vrfyResponse.includes('250') || vrfyResponse.includes('252')) { - supportedVerbs.push('VRFY'); - } - - // Check minimum required verbs - const requiredVerbs = ['NOOP', 'RSET']; - const hasRequired = requiredVerbs.every(verb => - supportedVerbs.includes(verb) || verb === 'VRFY' // VRFY is optional - ); - - console.log('Supported verbs:', supportedVerbs); - expect(hasRequired).toEqual(true); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 5321 - Required minimum extensions', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - - // Check for extensions - const lines = ehloResponse.split('\r\n'); - const extensions = lines - .filter(line => line.startsWith('250-') || (line.startsWith('250 ') && lines.indexOf(line) > 0)) - .map(line => line.substring(4).split(' ')[0].toUpperCase()); - - console.log('Server extensions:', extensions); - - // RFC 5321 recommends these extensions - const recommendedExtensions = ['8BITMIME', 'SIZE', 'PIPELINING']; - const hasRecommended = recommendedExtensions.filter(ext => extensions.includes(ext)); - - console.log('Recommended extensions present:', hasRecommended); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_rfc-compliance/test.rfc-02.rfc5322-compliance.ts b/test/suite/smtpserver_rfc-compliance/test.rfc-02.rfc5322-compliance.ts deleted file mode 100644 index 352b913..0000000 --- a/test/suite/smtpserver_rfc-compliance/test.rfc-02.rfc5322-compliance.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Any complete response line - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('RFC 5322 - Message format with required headers', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // RFC 5322 compliant email with all required headers - const messageId = ``; - const date = new Date().toUTCString(); - - const rfc5322Email = [ - `Date: ${date}`, - `From: "Test Sender" `, - `To: "Test Recipient" `, - `Subject: RFC 5322 Compliance Test`, - `Message-ID: ${messageId}`, - `MIME-Version: 1.0`, - `Content-Type: text/plain; charset=UTF-8`, - `Content-Transfer-Encoding: 7bit`, - '', - 'This is a test message for RFC 5322 compliance verification.', - 'It includes proper headers according to RFC 5322 specifications.', - '', - 'Best regards,', - 'Test System', - '.', - '' - ].join('\r\n'); - - socket.write(rfc5322Email); - const response = await waitForResponse(socket, '250'); - - console.log('RFC 5322 compliant message accepted'); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 5322 - Folded header lines', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Test folded header lines (RFC 5322 section 2.2.3) - const email = [ - `Date: ${new Date().toUTCString()}`, - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: This is a very long subject line that needs to be`, - ` folded according to RFC 5322 specifications for proper`, - ` email header formatting`, - `Message-ID: <${Date.now()}@example.com>`, - `References: `, - ` `, - ` `, - '', - 'Email with folded headers.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - await waitForResponse(socket, '250'); - - console.log('Folded headers message accepted'); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 5322 - Multiple recipient formats', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send multiple RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Test various recipient formats allowed by RFC 5322 - const email = [ - `Date: ${new Date().toUTCString()}`, - `From: "Sender Name" `, - `To: recipient1@example.com, "Recipient Two" `, - `Cc: "Carbon Copy" `, - `Bcc: bcc@example.com`, - `Reply-To: "Reply Address" `, - `Subject: Multiple recipient formats test`, - `Message-ID: <${Date.now()}@example.com>`, - '', - 'Testing various recipient header formats.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - await waitForResponse(socket, '250'); - - console.log('Multiple recipient formats accepted'); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 5322 - Comments in headers', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // RFC 5322 allows comments in headers using parentheses - const email = [ - `Date: ${new Date().toUTCString()} (generated by test system)`, - `From: sender@example.com (Test Sender)`, - `To: recipient@example.com (Primary Recipient)`, - `Subject: Testing comments (RFC 5322 section 3.2.2)`, - `Message-ID: <${Date.now()}@example.com>`, - `X-Custom-Header: value (with comment)`, - '', - 'Email with comments in headers.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - await waitForResponse(socket, '250'); - - console.log('Headers with comments accepted'); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 5322 - Resent headers', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // RFC 5322 resent headers for forwarded messages - const email = [ - `Resent-Date: ${new Date().toUTCString()}`, - `Resent-From: resender@example.com`, - `Resent-To: newrecipient@example.com`, - `Resent-Message-ID: `, - `Date: ${new Date(Date.now() - 86400000).toUTCString()}`, // Original date (yesterday) - `From: original@example.com`, - `To: oldrecipient@example.com`, - `Subject: Forwarded: Original Subject`, - `Message-ID: `, - '', - 'This is a forwarded message with resent headers.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - await waitForResponse(socket, '250'); - - console.log('Resent headers message accepted'); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_rfc-compliance/test.rfc-03.rfc7208-spf-compliance.ts b/test/suite/smtpserver_rfc-compliance/test.rfc-03.rfc7208-spf-compliance.ts deleted file mode 100644 index 726afdf..0000000 --- a/test/suite/smtpserver_rfc-compliance/test.rfc-03.rfc7208-spf-compliance.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Any complete response line - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('RFC 7208 SPF - Server handles SPF checks', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - const spfResults: any[] = []; - - // Test domains simulating different SPF scenarios - const spfTestDomains = [ - 'spf-pass.example.com', // Should have valid SPF record allowing sender - 'spf-fail.example.com', // Should have SPF record that fails - 'spf-neutral.example.com', // Should have neutral SPF record - 'no-spf.example.com' // Should have no SPF record - ]; - - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - - // Check if server advertises SPF support - const advertisesSpf = ehloResponse.toLowerCase().includes('spf'); - console.log('Server advertises SPF:', advertisesSpf); - - // Test each domain - for (let i = 0; i < spfTestDomains.length; i++) { - const domain = spfTestDomains[i]; - const testEmail = `spf-test@${domain}`; - - spfResults[i] = { - domain: domain, - email: testEmail, - mailFromAccepted: false, - rcptAccepted: false, - spfFailed: false - }; - - console.log(`Testing SPF for domain: ${domain}`); - socket.write(`MAIL FROM:<${testEmail}>\r\n`); - const mailResponse = await waitForResponse(socket); - - spfResults[i].mailFromResponse = mailResponse.trim(); - - if (mailResponse.includes('250')) { - // MAIL FROM accepted - spfResults[i].mailFromAccepted = true; - - socket.write(`RCPT TO:\r\n`); - const rcptResponse = await waitForResponse(socket); - - if (rcptResponse.includes('250')) { - spfResults[i].rcptAccepted = true; - } - } else if (mailResponse.includes('550') || mailResponse.includes('553')) { - // SPF failure (expected for some domains) - spfResults[i].spfFailed = true; - } - - // Reset for next test - socket.write('RSET\r\n'); - await waitForResponse(socket, '250'); - } - - // All tests complete - console.log('SPF test results:', spfResults); - - // Check that server handled all domains - const allDomainsHandled = spfResults.every(result => - result.mailFromResponse !== undefined && result.mailFromResponse !== 'pending' - ); - - expect(allDomainsHandled).toEqual(true); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 7208 SPF - SPF record syntax handling', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Test with domain that might have complex SPF record - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - - // Server should handle this appropriately (accept or reject based on SPF) - const handled = mailResponse.includes('250') || - mailResponse.includes('550') || - mailResponse.includes('553'); - - expect(handled).toEqual(true); - console.log('SPF handling response:', mailResponse.trim()); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 7208 SPF - Received-SPF header', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Send email to check if server adds Received-SPF header - const email = [ - `Date: ${new Date().toUTCString()}`, - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: SPF Header Test`, - `Message-ID: <${Date.now()}@example.com>`, - '', - 'Testing if server adds Received-SPF header.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - await waitForResponse(socket, '250'); - - console.log('Email accepted - server should process SPF'); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 7208 SPF - IPv4 and IPv6 mechanism support', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Test with IPv6 address representation - socket.write('EHLO [::1]\r\n'); - await waitForResponse(socket, '250'); - - // Test domain with IP-based SPF mechanisms - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - - // Server should handle IP-based SPF mechanisms - const handled = mailResponse.includes('250') || - mailResponse.includes('550') || - mailResponse.includes('553'); - - expect(handled).toEqual(true); - console.log('IP mechanism SPF response:', mailResponse.trim()); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_rfc-compliance/test.rfc-04.rfc6376-dkim-compliance.ts b/test/suite/smtpserver_rfc-compliance/test.rfc-04.rfc6376-dkim-compliance.ts deleted file mode 100644 index 7cdaa06..0000000 --- a/test/suite/smtpserver_rfc-compliance/test.rfc-04.rfc6376-dkim-compliance.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Any complete response line - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('RFC 6376 DKIM - Server accepts email with DKIM signature', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Create email with DKIM signature - const dkimSignature = [ - 'DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;', - ' d=example.com; s=default;', - ' h=from:to:subject:date:message-id;', - ' bh=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY=;', - ' b=Kt1zLCYmUVYJKEOVL9nGF2JVPJ5/k5l6yOkNBJGCrZn4E5z9Qn7TlYrG8QfBgJ4', - ' CzYVLjKm5xOhUoEaDzTJ1E6C9A4hL8sKfBxQjN8oWv4kP3GdE6mFqS0wKcRjT+', - ' NxOz2VcJP4LmKjFsG8XqBhYoEfCvSr3UwNmEkP6RjT9WlQzA4kJe2VoMsJ=' - ].join('\r\n'); - - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: DKIM RFC 6376 Compliance Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - dkimSignature, - '', - 'This email tests RFC 6376 DKIM compliance.', - 'The server should properly handle DKIM signatures.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - await waitForResponse(socket, '250'); - - console.log('Email with DKIM signature accepted'); - expect(true).toEqual(true); // Server accepts DKIM headers - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 6376 DKIM - Multiple DKIM signatures', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Email with multiple DKIM signatures (common in forwarding scenarios) - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Multiple DKIM Signatures Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - 'DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;', - ' d=example.com; s=selector1;', - ' h=from:to:subject:date;', - ' bh=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY=;', - ' b=signature1data', - 'DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple;', - ' d=forwarder.com; s=selector2;', - ' h=from:to:subject:date:message-id;', - ' bh=differentbodyhash=;', - ' b=signature2data', - '', - 'Email with multiple DKIM signatures.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - await waitForResponse(socket, '250'); - - console.log('Email with multiple DKIM signatures accepted'); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 6376 DKIM - Various canonicalization methods', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Test different canonicalization methods - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: DKIM Canonicalization Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - 'DKIM-Signature: v=1; a=rsa-sha256; c=simple/relaxed;', - ' d=example.com; s=default;', - ' h=from:to:subject;', - ' bh=bodyhash=;', - ' b=signature', - '', - 'Testing different canonicalization methods.', - 'Simple header canonicalization preserves whitespace.', - 'Relaxed body canonicalization normalizes whitespace.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - await waitForResponse(socket, '250'); - - console.log('Email with different canonicalization accepted'); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 6376 DKIM - Long header fields and folding', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // DKIM signature with long fields that require folding - const longSignature = 'b=' + 'A'.repeat(200); - - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: DKIM Long Fields Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - 'DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;', - ' d=example.com; s=default; t=' + Math.floor(Date.now() / 1000) + ';', - ' h=from:to:subject:date:message-id:content-type:mime-version;', - ' bh=verylongbodyhashvalueherethatexceedsnormallength1234567890=;', - ' ' + longSignature.substring(0, 70), - ' ' + longSignature.substring(70, 140), - ' ' + longSignature.substring(140), - '', - 'Testing DKIM with long header fields.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - await waitForResponse(socket, '250'); - - console.log('Email with long DKIM fields accepted'); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 6376 DKIM - Authentication-Results header', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - - // Check if server advertises DKIM support - const advertisesDkim = ehloResponse.toLowerCase().includes('dkim'); - console.log('Server advertises DKIM:', advertisesDkim); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Email to test if server adds Authentication-Results header - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Authentication-Results Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - 'DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;', - ' d=example.com; s=default;', - ' h=from:to:subject;', - ' bh=simplehash=;', - ' b=simplesignature', - '', - 'Testing if server adds Authentication-Results header.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - await waitForResponse(socket, '250'); - - console.log('Email accepted - server should process DKIM and potentially add Authentication-Results'); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_rfc-compliance/test.rfc-05.rfc7489-dmarc-compliance.ts b/test/suite/smtpserver_rfc-compliance/test.rfc-05.rfc7489-dmarc-compliance.ts deleted file mode 100644 index eb4ef99..0000000 --- a/test/suite/smtpserver_rfc-compliance/test.rfc-05.rfc7489-dmarc-compliance.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Any complete response line - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('RFC 7489 DMARC - Server handles DMARC policies', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - const dmarcResults: any[] = []; - - // Test domains simulating different DMARC policies - const dmarcTestScenarios = [ - { - domain: 'dmarc-reject.example.com', - policy: 'reject', - alignment: 'strict' - }, - { - domain: 'dmarc-quarantine.example.com', - policy: 'quarantine', - alignment: 'relaxed' - }, - { - domain: 'dmarc-none.example.com', - policy: 'none', - alignment: 'relaxed' - } - ]; - - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - - // Check if server advertises DMARC support - const advertisesDmarc = ehloResponse.toLowerCase().includes('dmarc'); - console.log('Server advertises DMARC:', advertisesDmarc); - - // Test each scenario - for (let i = 0; i < dmarcTestScenarios.length; i++) { - const scenario = dmarcTestScenarios[i]; - const testFromAddress = `dmarc-test@${scenario.domain}`; - - dmarcResults[i] = { - domain: scenario.domain, - policy: scenario.policy, - mailFromAccepted: false, - rcptAccepted: false - }; - - console.log(`Testing DMARC policy: ${scenario.policy} for domain: ${scenario.domain}`); - socket.write(`MAIL FROM:<${testFromAddress}>\r\n`); - const mailResponse = await waitForResponse(socket); - - dmarcResults[i].mailFromResponse = mailResponse.trim(); - - if (mailResponse.includes('250')) { - dmarcResults[i].mailFromAccepted = true; - - socket.write(`RCPT TO:\r\n`); - const rcptResponse = await waitForResponse(socket); - - if (rcptResponse.includes('250')) { - dmarcResults[i].rcptAccepted = true; - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Send email with DMARC-relevant headers - const email = [ - `From: dmarc-test@${scenario.domain}`, - `To: recipient@example.com`, - `Subject: DMARC RFC 7489 Compliance Test - ${scenario.policy}`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=${scenario.domain}; s=default;`, - ` h=from:to:subject:date; bh=testbodyhash; b=testsignature`, - `Authentication-Results: example.org; spf=pass smtp.mailfrom=${scenario.domain}`, - '', - `This email tests DMARC ${scenario.policy} policy compliance.`, - 'The server should handle DMARC policies according to RFC 7489.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const dataResponse = await waitForResponse(socket, '250'); - - dmarcResults[i].emailAccepted = true; - console.log(`DMARC ${scenario.policy} policy email accepted`); - } - } else if (mailResponse.includes('550') || mailResponse.includes('553')) { - // DMARC policy rejection (expected for some scenarios) - dmarcResults[i].dmarcRejected = true; - dmarcResults[i].rejectionResponse = mailResponse.trim(); - console.log(`DMARC ${scenario.policy} policy rejected as expected`); - } - - // Reset for next test - socket.write('RSET\r\n'); - await waitForResponse(socket, '250'); - } - - // All tests complete - console.log('DMARC test results:', dmarcResults); - - // Check that server handled all scenarios - const allScenariosHandled = dmarcResults.every(result => - result.mailFromResponse !== undefined - ); - - expect(allScenariosHandled).toEqual(true); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 7489 DMARC - Alignment testing', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Test misaligned domain (envelope vs header) - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Email with different header From domain (testing alignment) - const email = [ - `From: sender@header-domain.com`, - `To: recipient@example.com`, - `Subject: DMARC Alignment Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=header-domain.com; s=default;`, - ` h=from:to:subject:date; bh=alignmenthash; b=alignmentsig`, - '', - 'Testing DMARC domain alignment (envelope vs header From).', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const response = await waitForResponse(socket); - - const accepted = response.includes('250'); - console.log(`Alignment test ${accepted ? 'accepted' : 'rejected due to alignment failure'}`); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 7489 DMARC - Subdomain policy', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Test subdomain policy inheritance - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Email from subdomain to test policy inheritance - const email = [ - `From: sender@subdomain.dmarc-policy.com`, - `To: recipient@example.com`, - `Subject: DMARC Subdomain Policy Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=subdomain.dmarc-policy.com; s=default;`, - ` h=from:to:subject:date; bh=subdomainhash; b=subdomainsig`, - '', - 'Testing DMARC subdomain policy inheritance.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const response = await waitForResponse(socket); - - const accepted = response.includes('250'); - console.log(`Subdomain policy test ${accepted ? 'accepted' : 'rejected'}`); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 7489 DMARC - Report generation hint', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Email with DMARC report request headers - const email = [ - `From: dmarc-report@example.com`, - `To: recipient@example.com`, - `Subject: DMARC Report Generation Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=default;`, - ` h=from:to:subject:date; bh=reporthash; b=reportsig`, - `Authentication-Results: mta.example.com;`, - ` dmarc=pass (p=none dis=none) header.from=example.com`, - '', - 'Testing DMARC report generation capabilities.', - 'Server should log DMARC results for reporting.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - await waitForResponse(socket, '250'); - - console.log('DMARC report test email accepted'); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_rfc-compliance/test.rfc-06.rfc8314-tls-compliance.ts b/test/suite/smtpserver_rfc-compliance/test.rfc-06.rfc8314-tls-compliance.ts deleted file mode 100644 index 8529e86..0000000 --- a/test/suite/smtpserver_rfc-compliance/test.rfc-06.rfc8314-tls-compliance.ts +++ /dev/null @@ -1,366 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import * as tls from 'tls'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Any complete response line - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('RFC 8314 TLS - STARTTLS advertised in EHLO', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - - // Check if STARTTLS is advertised (RFC 8314 requirement) - const advertisesStarttls = ehloResponse.toLowerCase().includes('starttls'); - - console.log('STARTTLS advertised:', advertisesStarttls); - expect(advertisesStarttls).toEqual(true); - - // Parse other extensions - const lines = ehloResponse.split('\r\n'); - const extensions = lines - .filter(line => line.startsWith('250-') || (line.startsWith('250 ') && lines.indexOf(line) > 0)) - .map(line => line.substring(4).split(' ')[0].toUpperCase()); - - console.log('Server extensions:', extensions); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 8314 TLS - STARTTLS command functionality', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - - const advertisesStarttls = ehloResponse.toLowerCase().includes('starttls'); - - if (advertisesStarttls) { - // Send STARTTLS - socket.write('STARTTLS\r\n'); - const starttlsResponse = await waitForResponse(socket, '220'); - - console.log('STARTTLS command accepted, ready to upgrade'); - - // In a real test, we would upgrade to TLS here - // For this test, we just verify the command is accepted - expect(true).toEqual(true); - } else { - console.log('STARTTLS not advertised, skipping upgrade'); - } - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 8314 TLS - Commands before STARTTLS', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Try MAIL FROM before STARTTLS (server may require TLS first) - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - - // Server may accept or reject based on TLS policy - if (mailResponse.includes('250')) { - console.log('Server allows MAIL FROM before STARTTLS'); - } else if (mailResponse.includes('530') || mailResponse.includes('554')) { - console.log('Server requires STARTTLS before MAIL FROM (RFC 8314 compliant)'); - expect(true).toEqual(true); // This is actually good for security - } - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 8314 TLS - TLS version support', async (tools) => { - const done = tools.defer(); - - // First establish plain connection to get STARTTLS - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send STARTTLS - socket.write('STARTTLS\r\n'); - const starttlsResponse = await waitForResponse(socket, '220'); - - console.log('Ready to upgrade to TLS'); - - // Upgrade connection to TLS - const tlsOptions = { - socket: socket, - rejectUnauthorized: false, // For testing - minVersion: 'TLSv1.2' as any // RFC 8314 recommends TLS 1.2 or higher - }; - - const tlsSocket = tls.connect(tlsOptions); - - tlsSocket.on('secureConnect', () => { - console.log('TLS connection established'); - console.log('Protocol:', tlsSocket.getProtocol()); - console.log('Cipher:', tlsSocket.getCipher()); - - // Verify TLS 1.2 or higher - const protocol = tlsSocket.getProtocol(); - if (protocol) { - expect(['TLSv1.2', 'TLSv1.3']).toContain(protocol); - } - - tlsSocket.write('EHLO testclient\r\n'); - }); - - tlsSocket.on('data', (data) => { - const response = data.toString(); - console.log('TLS response:', response); - - if (response.includes('250')) { - console.log('EHLO after STARTTLS successful'); - tlsSocket.write('QUIT\r\n'); - setTimeout(() => { - tlsSocket.end(); - done.resolve(); - }, 100); - } - }); - - tlsSocket.on('error', (err) => { - console.error('TLS error:', err); - // If TLS upgrade fails, still pass the test as server accepted STARTTLS - done.resolve(); - }); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 8314 TLS - Email submission after STARTTLS', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // For this test, proceed without STARTTLS to test basic functionality - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - - if (mailResponse.includes('250')) { - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - const email = [ - `Date: ${new Date().toUTCString()}`, - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: RFC 8314 TLS Compliance Test`, - `Message-ID: `, - '', - 'Testing email submission with TLS requirements.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - await waitForResponse(socket, '250'); - - console.log('Email accepted (server allows non-TLS or we are testing on TLS port)'); - } else { - // Server may require STARTTLS first - console.log('Server requires STARTTLS for mail submission'); - } - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_rfc-compliance/test.rfc-07.rfc3461-dsn-compliance.ts b/test/suite/smtpserver_rfc-compliance/test.rfc-07.rfc3461-dsn-compliance.ts deleted file mode 100644 index 9fd836f..0000000 --- a/test/suite/smtpserver_rfc-compliance/test.rfc-07.rfc3461-dsn-compliance.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -// Helper function to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - // Check if we have a complete response - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Any complete response line - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('RFC 3461 DSN - DSN extension advertised', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Check if DSN extension is advertised - const advertisesDsn = ehloResponse.toLowerCase().includes('dsn'); - console.log('DSN extension advertised:', advertisesDsn); - - // Parse extensions - const lines = ehloResponse.split('\r\n'); - const extensions = lines - .filter(line => line.startsWith('250-') || (line.startsWith('250 ') && lines.indexOf(line) > 0)) - .map(line => line.substring(4).split(' ')[0].toUpperCase()); - - console.log('Server extensions:', extensions); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - socket.end(); - done.resolve(); - } catch (error) { - console.error('Socket error:', error); - done.reject(error); - } -}); - -tap.test('RFC 3461 DSN - MAIL FROM with DSN parameters', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Test MAIL FROM with DSN parameters (RFC 3461) - socket.write('MAIL FROM: RET=FULL ENVID=test-envelope-123\r\n'); - const mailResponse = await waitForResponse(socket); - console.log('Server response:', mailResponse); - - // Server should either accept (250) or reject with proper error - const accepted = mailResponse.includes('250'); - const properlyRejected = mailResponse.includes('501') || mailResponse.includes('555'); - - expect(accepted || properlyRejected).toEqual(true); - console.log(`DSN parameters in MAIL FROM ${accepted ? 'accepted' : 'rejected'}`); - - if (accepted) { - // Reset to test other parameters - socket.write('RSET\r\n'); - const resetResponse = await waitForResponse(socket, '250'); - console.log('Server response:', resetResponse); - - // Test with RET=HDRS - socket.write('MAIL FROM: RET=HDRS\r\n'); - const mailHdrsResponse = await waitForResponse(socket); - console.log('Server response:', mailHdrsResponse); - - const hdrsAccepted = mailHdrsResponse.includes('250'); - console.log(`RET=HDRS parameter ${hdrsAccepted ? 'accepted' : 'rejected'}`); - } - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - socket.end(); - done.resolve(); - } catch (error) { - console.error('Socket error:', error); - done.reject(error); - } -}); - -tap.test('RFC 3461 DSN - RCPT TO with DSN parameters', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - - // Read greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', mailResponse); - - // Test RCPT TO with DSN parameters - socket.write('RCPT TO: NOTIFY=SUCCESS,FAILURE ORCPT=rfc822;recipient@example.com\r\n'); - const rcptResponse = await waitForResponse(socket); - console.log('Server response:', rcptResponse); - - // Server should either accept (250) or reject with proper error - const accepted = rcptResponse.includes('250'); - const properlyRejected = rcptResponse.includes('501') || rcptResponse.includes('555'); - - expect(accepted || properlyRejected).toEqual(true); - console.log(`DSN parameters in RCPT TO ${accepted ? 'accepted' : 'rejected'}`); - - if (accepted) { - // Reset to test other notify values - socket.write('RSET\r\n'); - const resetResponse = await waitForResponse(socket, '250'); - console.log('Server response:', resetResponse); - - // Send MAIL FROM again - socket.write('MAIL FROM:\r\n'); - const mail2Response = await waitForResponse(socket, '250'); - console.log('Server response:', mail2Response); - - // Test NOTIFY=NEVER - socket.write('RCPT TO: NOTIFY=NEVER\r\n'); - const rcptNeverResponse = await waitForResponse(socket); - console.log('Server response:', rcptNeverResponse); - - const neverAccepted = rcptNeverResponse.includes('250'); - console.log(`NOTIFY=NEVER parameter ${neverAccepted ? 'accepted' : 'rejected'}`); - } - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - socket.end(); - done.resolve(); - } catch (error) { - console.error('Socket error:', error); - done.reject(error); - } -}); - -tap.test('RFC 3461 DSN - Complete DSN-enabled email', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Try with DSN parameters - socket.write('MAIL FROM: RET=FULL ENVID=test123\r\n'); - const mailResponse = await waitForResponse(socket); - - if (mailResponse.includes('250')) { - // DSN parameters accepted, continue with DSN RCPT - socket.write('RCPT TO: NOTIFY=SUCCESS,FAILURE,DELAY\r\n'); - const rcptResponse = await waitForResponse(socket); - - if (!rcptResponse.includes('250')) { - // Fallback to plain RCPT if DSN parameters not supported - console.log('DSN RCPT parameters not supported, using plain RCPT TO'); - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - } - } else if (mailResponse.includes('501') || mailResponse.includes('555')) { - // DSN not supported, use plain MAIL FROM - console.log('DSN parameters not supported, using plain MAIL FROM'); - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - } - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Send email content - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: RFC 3461 DSN Compliance Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This email tests RFC 3461 DSN (Delivery Status Notification) compliance.', - 'The server should handle DSN parameters according to RFC 3461.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - await waitForResponse(socket, '250'); - - console.log('DSN-enabled email accepted'); - - // Quit - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('RFC 3461 DSN - Invalid DSN parameter handling', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - socket.on('connect', async () => { - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Test with invalid RET value - socket.write('MAIL FROM: RET=INVALID\r\n'); - const mailResponse = await waitForResponse(socket); - - // Should reject with 501 or similar - const properlyRejected = mailResponse.includes('501') || - mailResponse.includes('555') || - mailResponse.includes('500'); - - if (properlyRejected) { - console.log('Invalid RET parameter properly rejected'); - expect(true).toEqual(true); - } else if (mailResponse.includes('250')) { - // Server ignores unknown parameters (also acceptable) - console.log('Server ignores invalid DSN parameters'); - } - - // Reset and test invalid NOTIFY - socket.write('RSET\r\n'); - await waitForResponse(socket, '250'); - - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Test with invalid NOTIFY value - socket.write('RCPT TO: NOTIFY=INVALID\r\n'); - const rcptResponse = await waitForResponse(socket); - - const rcptRejected = rcptResponse.includes('501') || - rcptResponse.includes('555') || - rcptResponse.includes('500'); - - if (rcptRejected) { - console.log('Invalid NOTIFY parameter properly rejected'); - } else if (rcptResponse.includes('250')) { - console.log('Server ignores invalid NOTIFY parameter'); - } - - // Quit - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - - socket.end(); - done.resolve(); - } catch (err) { - console.error('Test error:', err); - socket.end(); - done.reject(err); - } - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_security/test.sec-01.authentication.ts b/test/suite/smtpserver_security/test.sec-01.authentication.ts deleted file mode 100644 index 1a4f5d4..0000000 --- a/test/suite/smtpserver_security/test.sec-01.authentication.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; -import { connectToSmtp, waitForGreeting, sendSmtpCommand, closeSmtpConnection } from '../../helpers/utils.js'; - -let testServer: ITestServer; - -tap.test('setup - start SMTP server with authentication', async () => { - testServer = await startTestServer({ - port: 2530, - hostname: 'localhost', - authRequired: true - }); - expect(testServer).toBeInstanceOf(Object); -}); - -tap.test('SEC-01: Authentication - server advertises AUTH capability', async () => { - const socket = await connectToSmtp(testServer.hostname, testServer.port); - - try { - await waitForGreeting(socket); - - // Send EHLO to get capabilities - const ehloResponse = await sendSmtpCommand(socket, 'EHLO test.example.com', '250'); - - // Parse capabilities - const lines = ehloResponse.split('\r\n').filter(line => line.length > 0); - const capabilities = lines.map(line => line.substring(4).trim()); - - // Check for AUTH capability - const authCapability = capabilities.find(cap => cap.startsWith('AUTH')); - expect(authCapability).toBeDefined(); - - // Extract supported mechanisms - const supportedMechanisms = authCapability?.substring(5).split(' ') || []; - console.log('📋 Supported AUTH mechanisms:', supportedMechanisms); - - // Common mechanisms should be supported - expect(supportedMechanisms).toContain('PLAIN'); - expect(supportedMechanisms).toContain('LOGIN'); - - console.log('✅ AUTH capability test passed'); - - } finally { - await closeSmtpConnection(socket); - } -}); - -tap.test('SEC-01: AUTH PLAIN mechanism - correct credentials', async () => { - const socket = await connectToSmtp(testServer.hostname, testServer.port); - - try { - await waitForGreeting(socket); - await sendSmtpCommand(socket, 'EHLO test.example.com', '250'); - - // Create AUTH PLAIN credentials - // Format: base64(NULL + username + NULL + password) - const username = 'testuser'; - const password = 'testpass'; - const authString = Buffer.from(`\0${username}\0${password}`).toString('base64'); - - // Send AUTH PLAIN command - try { - const authResponse = await sendSmtpCommand(socket, `AUTH PLAIN ${authString}`); - // Server might accept (235) or reject (535) based on configuration - expect(authResponse).toMatch(/^(235|535)/); - - if (authResponse.startsWith('235')) { - console.log('✅ AUTH PLAIN accepted (test mode)'); - } else { - console.log('✅ AUTH PLAIN properly rejected (production mode)'); - } - } catch (error) { - // Auth failure is expected in test environment - console.log('✅ AUTH PLAIN handled:', error.message); - } - - } finally { - await closeSmtpConnection(socket); - } -}); - -tap.test('SEC-01: AUTH LOGIN mechanism - interactive authentication', async () => { - const socket = await connectToSmtp(testServer.hostname, testServer.port); - - try { - await waitForGreeting(socket); - await sendSmtpCommand(socket, 'EHLO test.example.com', '250'); - - // Start AUTH LOGIN - try { - const authStartResponse = await sendSmtpCommand(socket, 'AUTH LOGIN', '334'); - expect(authStartResponse).toInclude('334'); - - // Server should prompt for username (base64 "Username:") - const usernamePrompt = Buffer.from( - authStartResponse.substring(4).trim(), - 'base64' - ).toString(); - console.log('Server prompt:', usernamePrompt); - - // Send username - const username = Buffer.from('testuser').toString('base64'); - const passwordPromptResponse = await sendSmtpCommand(socket, username, '334'); - - // Send password - const password = Buffer.from('testpass').toString('base64'); - const authResult = await sendSmtpCommand(socket, password); - - // Check result (235 = success, 535 = failure) - expect(authResult).toMatch(/^(235|535)/); - - } catch (error) { - // Auth failure is expected in test environment - console.log('✅ AUTH LOGIN handled:', error.message); - } - - } finally { - await closeSmtpConnection(socket); - } -}); - -tap.test('SEC-01: Authentication required - reject commands without auth', async () => { - const socket = await connectToSmtp(testServer.hostname, testServer.port); - - try { - await waitForGreeting(socket); - await sendSmtpCommand(socket, 'EHLO test.example.com', '250'); - - // Try to send email without authentication - try { - const mailResponse = await sendSmtpCommand(socket, 'MAIL FROM:'); - - // Server should reject with 530 (authentication required) or similar - if (mailResponse.startsWith('530') || mailResponse.startsWith('503')) { - console.log('✅ Server properly requires authentication'); - } else if (mailResponse.startsWith('250')) { - console.log('⚠️ Server accepted mail without auth (test mode)'); - } - - } catch (error) { - // Command rejection is expected - console.log('✅ Server rejected unauthenticated command:', error.message); - } - - } finally { - await closeSmtpConnection(socket); - } -}); - -tap.test('SEC-01: Invalid authentication attempts - rate limiting', async () => { - const socket = await connectToSmtp(testServer.hostname, testServer.port); - - try { - await waitForGreeting(socket); - await sendSmtpCommand(socket, 'EHLO test.example.com', '250'); - - // Try multiple failed authentication attempts - const maxAttempts = 5; - let failedAttempts = 0; - let requiresTLS = false; - - for (let i = 0; i < maxAttempts; i++) { - try { - // Send invalid credentials - const invalidAuth = Buffer.from('\0invalid\0wrong').toString('base64'); - const response = await sendSmtpCommand(socket, `AUTH PLAIN ${invalidAuth}`); - - // Check if authentication failed - if (response.startsWith('535')) { - failedAttempts++; - console.log(`Failed attempt ${i + 1}: ${response.trim()}`); - - // Check if server requires TLS (common security practice) - if (response.includes('TLS')) { - requiresTLS = true; - console.log('✅ Server enforces TLS requirement for authentication'); - break; - } - } else if (response.startsWith('503')) { - // Too many failed attempts - failedAttempts++; - console.log('✅ Server enforces auth attempt limits'); - break; - } - } catch (error) { - // Handle connection errors - failedAttempts++; - console.log(`Failed attempt ${i + 1}: ${error.message}`); - - // Check if server closed connection or rate limited - if (error.message.includes('closed') || error.message.includes('timeout')) { - console.log('✅ Server enforces auth attempt limits by closing connection'); - break; - } - } - } - - // Either TLS is required or we had failed attempts - expect(failedAttempts).toBeGreaterThan(0); - if (requiresTLS) { - console.log('✅ Authentication properly protected by TLS requirement'); - } else { - console.log(`✅ Handled ${failedAttempts} failed auth attempts`); - } - - } finally { - if (!socket.destroyed) { - socket.destroy(); - } - } -}); - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); - console.log('✅ Test server stopped'); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_security/test.sec-02.authorization.ts b/test/suite/smtpserver_security/test.sec-02.authorization.ts deleted file mode 100644 index 4f88f42..0000000 --- a/test/suite/smtpserver_security/test.sec-02.authorization.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -// Helper to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Look for any complete response - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('Authorization - Valid sender domain', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO test.example.com\r\n'); - await waitForResponse(socket, '250'); - - // Use valid sender domain with proper format - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - - if (mailResponse.startsWith('250')) { - // Try recipient - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket); - - // Valid sender should be accepted or require auth - const accepted = rcptResponse.startsWith('250'); - const authRequired = rcptResponse.startsWith('530'); - console.log(`Valid sender domain: ${accepted ? 'accepted' : authRequired ? 'auth required' : 'rejected'}`); - - expect(accepted || authRequired).toEqual(true); - } else { - // Mail from rejected - could be due to auth requirement - const authRequired = mailResponse.startsWith('530'); - console.log(`MAIL FROM requires auth: ${authRequired}`); - expect(authRequired || mailResponse.startsWith('250')).toEqual(true); - } - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('Authorization - External sender domain', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO external.com\r\n'); - await waitForResponse(socket, '250'); - - // Use external sender domain - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - - if (mailResponse.startsWith('250')) { - // Try recipient - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket); - - // Check response - const accepted = rcptResponse.startsWith('250'); - const authRequired = rcptResponse.startsWith('530'); - const rejected = rcptResponse.startsWith('550') || rcptResponse.startsWith('553'); - - console.log(`External sender: accepted=${accepted}, authRequired=${authRequired}, rejected=${rejected}`); - expect(accepted || authRequired || rejected).toEqual(true); - } else { - // Check if auth required or rejected - const authRequired = mailResponse.startsWith('530'); - const rejected = mailResponse.startsWith('550') || mailResponse.startsWith('553'); - - console.log(`External sender ${authRequired ? 'requires authentication' : rejected ? 'rejected by policy' : 'error'}`); - expect(authRequired || rejected).toEqual(true); - } - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('Authorization - Relay attempt rejection', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO external.com\r\n'); - await waitForResponse(socket, '250'); - - // External sender - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - - if (mailResponse.startsWith('250')) { - // Try to relay to another external domain (should be rejected) - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket); - - // Relay attempt should be rejected or accepted (test mode) - const rejected = rcptResponse.startsWith('550') || - rcptResponse.startsWith('553') || - rcptResponse.startsWith('530') || - rcptResponse.startsWith('554'); - const accepted = rcptResponse.startsWith('250'); - - console.log(`Relay attempt ${rejected ? 'properly rejected' : accepted ? 'accepted (test mode)' : 'error'}`); - // In production, relay should be rejected. In test mode, it might be accepted - expect(rejected || accepted).toEqual(true); - - if (accepted) { - console.log('⚠️ WARNING: Server accepted relay attempt - ensure relay restrictions are properly configured in production'); - } - } else { - // MAIL FROM already rejected - console.log('External sender rejected at MAIL FROM'); - expect(mailResponse.startsWith('530') || mailResponse.startsWith('550')).toEqual(true); - } - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('Authorization - IP-based restrictions', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Use IP address in EHLO - socket.write('EHLO [127.0.0.1]\r\n'); - await waitForResponse(socket, '250'); - - // Use proper email format - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - - if (mailResponse.startsWith('250')) { - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket); - - // Localhost IP should typically be accepted - const accepted = rcptResponse.startsWith('250'); - const rejected = rcptResponse.startsWith('550') || rcptResponse.startsWith('553'); - const authRequired = rcptResponse.startsWith('530'); - - console.log(`IP-based authorization: ${accepted ? 'accepted' : rejected ? 'rejected' : 'auth required'}`); - expect(accepted || rejected || authRequired).toEqual(true); // Any is valid based on server config - } else { - // Check if auth required - const authRequired = mailResponse.startsWith('530'); - console.log(`MAIL FROM ${authRequired ? 'requires auth' : 'rejected'}`); - expect(authRequired || mailResponse.startsWith('250')).toEqual(true); - } - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('Authorization - Case sensitivity in addresses', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO test.example.com\r\n'); - await waitForResponse(socket, '250'); - - // Use mixed case in email address with proper domain - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - - if (mailResponse.startsWith('250')) { - // Mixed case recipient - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket); - - // Email addresses should be case-insensitive - const accepted = rcptResponse.startsWith('250'); - const authRequired = rcptResponse.startsWith('530'); - console.log(`Mixed case addresses ${accepted ? 'accepted' : authRequired ? 'auth required' : 'rejected'}`); - - expect(accepted || authRequired).toEqual(true); - } else { - // Check if auth required - const authRequired = mailResponse.startsWith('530'); - console.log(`MAIL FROM ${authRequired ? 'requires auth' : 'rejected'}`); - expect(authRequired || mailResponse.startsWith('250')).toEqual(true); - } - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_security/test.sec-03.dkim-processing.ts b/test/suite/smtpserver_security/test.sec-03.dkim-processing.ts deleted file mode 100644 index 9410f38..0000000 --- a/test/suite/smtpserver_security/test.sec-03.dkim-processing.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -// Helper to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Look for any complete response - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('DKIM Processing - Valid DKIM signature', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', mailResponse); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket, '250'); - console.log('Server response:', rcptResponse); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await waitForResponse(socket, '354'); - console.log('Server response:', dataResponse); - - // Generate valid DKIM signature - const timestamp = Math.floor(Date.now() / 1000); - const dkimSignature = [ - 'v=1; a=rsa-sha256; c=relaxed/relaxed;', - ' d=example.com; s=default;', - ' t=' + timestamp + ';', - ' h=from:to:subject:date:message-id;', - ' bh=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=;', - ' b=AMGNaJ3BliF0KSLD0wTfJd1eJhYbhP8YD2z9BPwAoeh6nKzfQ8wktB9Iwml3GKKj', - ' V6zJSGxJClQAoqJnO7oiIzPvHZTMGTbMvV9YBQcw5uvxLa2mRNkRT3FQ5vKFzfVQ', - ' OlHnZ8qZJDxYO4JmReCBnHQcC8W9cNJJh9ZQ4A=' - ].join(''); - - const email = [ - `DKIM-Signature: ${dkimSignature}`, - `Subject: DKIM Test - Valid Signature`, - `From: sender@example.com`, - `To: recipient@example.com`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This is a DKIM test email with a valid signature.', - `Timestamp: ${Date.now()}`, - '.', - '' - ].join('\r\n'); - - socket.write(email); - const emailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', emailResponse); - console.log('Email with valid DKIM signature accepted'); - expect(emailResponse).toInclude('250'); - expect(emailResponse.startsWith('250')).toEqual(true); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('DKIM Processing - Invalid DKIM signature', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', mailResponse); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket, '250'); - console.log('Server response:', rcptResponse); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await waitForResponse(socket, '354'); - console.log('Server response:', dataResponse); - - // Generate invalid DKIM signature (wrong domain, bad signature) - const timestamp = Math.floor(Date.now() / 1000); - const dkimSignature = [ - 'v=1; a=rsa-sha256; c=relaxed/relaxed;', - ' d=wrong-domain.com; s=invalid;', - ' t=' + timestamp + ';', - ' h=from:to:subject:date;', - ' bh=INVALID-BODY-HASH;', - ' b=INVALID-SIGNATURE-DATA' - ].join(''); - - const email = [ - `DKIM-Signature: ${dkimSignature}`, - `Subject: DKIM Test - Invalid Signature`, - `From: sender@example.com`, - `To: recipient@example.com`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This is a DKIM test email with an invalid signature.', - `Timestamp: ${Date.now()}`, - '.', - '' - ].join('\r\n'); - - socket.write(email); - const emailResponse = await waitForResponse(socket); - console.log('Server response:', emailResponse); - - const accepted = emailResponse.includes('250'); - console.log(`Email with invalid DKIM signature ${accepted ? 'accepted' : 'rejected'}`); - // Either response is valid - server may accept and mark as failed, or reject - expect(emailResponse.match(/250|550/)).toBeTruthy(); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('DKIM Processing - Missing DKIM signature', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', mailResponse); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket, '250'); - console.log('Server response:', rcptResponse); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await waitForResponse(socket, '354'); - console.log('Server response:', dataResponse); - - // Email without DKIM signature - const email = [ - `Subject: DKIM Test - No Signature`, - `From: sender@example.com`, - `To: recipient@example.com`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This is a DKIM test email without any signature.', - `Timestamp: ${Date.now()}`, - '.', - '' - ].join('\r\n'); - - socket.write(email); - const emailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', emailResponse); - console.log('Email without DKIM signature accepted (neutral)'); - expect(emailResponse).toInclude('250'); - expect(emailResponse.startsWith('250')).toEqual(true); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('DKIM Processing - Multiple DKIM signatures', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', mailResponse); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket, '250'); - console.log('Server response:', rcptResponse); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await waitForResponse(socket, '354'); - console.log('Server response:', dataResponse); - - // Email with multiple DKIM signatures (common in forwarding) - const timestamp = Math.floor(Date.now() / 1000); - - const email = [ - 'DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;', - ' d=example.com; s=selector1;', - ' t=' + timestamp + ';', - ' h=from:to:subject;', - ' bh=first-body-hash;', - ' b=first-signature', - 'DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple;', - ' d=forwarder.com; s=selector2;', - ' t=' + (timestamp + 60) + ';', - ' h=from:to:subject:date:message-id;', - ' bh=second-body-hash;', - ' b=second-signature', - `Subject: DKIM Test - Multiple Signatures`, - `From: sender@example.com`, - `To: recipient@example.com`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This email has multiple DKIM signatures.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const emailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', emailResponse); - console.log('Email with multiple DKIM signatures accepted'); - expect(emailResponse).toInclude('250'); - expect(emailResponse.startsWith('250')).toEqual(true); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('DKIM Processing - Expired DKIM signature', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', mailResponse); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket, '250'); - console.log('Server response:', rcptResponse); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await waitForResponse(socket, '354'); - console.log('Server response:', dataResponse); - - // DKIM signature with expired timestamp - const expiredTimestamp = Math.floor(Date.now() / 1000) - 2592000; // 30 days ago - const expirationTime = expiredTimestamp + 86400; // Expired 29 days ago - - const dkimSignature = [ - 'v=1; a=rsa-sha256; c=relaxed/relaxed;', - ' d=example.com; s=default;', - ' t=' + expiredTimestamp + '; x=' + expirationTime + ';', - ' h=from:to:subject:date;', - ' bh=expired-body-hash;', - ' b=expired-signature' - ].join(''); - - const email = [ - `DKIM-Signature: ${dkimSignature}`, - `Subject: DKIM Test - Expired Signature`, - `From: sender@example.com`, - `To: recipient@example.com`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This email has an expired DKIM signature.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const emailResponse = await waitForResponse(socket); - console.log('Server response:', emailResponse); - - const accepted = emailResponse.includes('250'); - console.log(`Email with expired DKIM signature ${accepted ? 'accepted' : 'rejected'}`); - // Either response is valid - expect(emailResponse.match(/250|550/)).toBeTruthy(); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_security/test.sec-04.spf-checking.ts b/test/suite/smtpserver_security/test.sec-04.spf-checking.ts deleted file mode 100644 index bacabff..0000000 --- a/test/suite/smtpserver_security/test.sec-04.spf-checking.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -// Helper to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Look for any complete response - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('SPF Checking - Authorized IP from local domain', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO localhost\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM with example.com domain - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - console.log('Server response:', mailResponse); - - if (mailResponse.includes('250')) { - console.log('Local domain sender accepted (SPF pass or neutral)'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket); - console.log('Server response:', rcptResponse); - - if (rcptResponse.includes('250')) { - console.log('Email accepted - SPF likely passed or neutral'); - expect(true).toEqual(true); - } - } else if (mailResponse.includes('550') || mailResponse.includes('553')) { - console.log('Local domain sender rejected (SPF fail)'); - expect(true).toEqual(true); // Either result shows SPF processing - } - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('SPF Checking - External domain sender', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM with well-known external domain - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - console.log('Server response:', mailResponse); - - if (mailResponse.includes('250')) { - console.log('External domain sender accepted'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket); - console.log('Server response:', rcptResponse); - - const accepted = rcptResponse.includes('250'); - const rejected = rcptResponse.includes('550') || rcptResponse.includes('553'); - - console.log(`External domain: accepted=${accepted}, rejected=${rejected}`); - expect(accepted || rejected).toEqual(true); - } else if (mailResponse.includes('550') || mailResponse.includes('553')) { - console.log('External domain sender rejected (SPF fail)'); - expect(true).toEqual(true); // Shows SPF is working - } - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('SPF Checking - Known SPF fail domain', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM with domain that should fail SPF - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - console.log('Server response:', mailResponse); - - if (mailResponse.includes('250')) { - console.log('SPF fail domain accepted (server may not enforce SPF)'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket); - console.log('Server response:', rcptResponse); - - // Either accepted or rejected is valid - const response = rcptResponse.includes('250') || rcptResponse.includes('550') || rcptResponse.includes('553'); - expect(response).toEqual(true); - } else if (mailResponse.includes('550') || mailResponse.includes('553')) { - console.log('SPF fail domain properly rejected'); - expect(true).toEqual(true); - } - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('SPF Checking - IPv4 literal in HELO', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO with IP literal - socket.write('EHLO [127.0.0.1]\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM with IP literal - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - console.log('Server response:', mailResponse); - - // Server should handle IP literals appropriately - const accepted = mailResponse.includes('250'); - const rejected = mailResponse.includes('550') || mailResponse.includes('553'); - - console.log(`IP literal sender: accepted=${accepted}, rejected=${rejected}`); - expect(accepted || rejected).toEqual(true); - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('SPF Checking - Subdomain sender', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO subdomain.example.com\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM with subdomain - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - console.log('Server response:', mailResponse); - - if (mailResponse.includes('250')) { - console.log('Subdomain sender accepted'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket); - console.log('Server response:', rcptResponse); - - const accepted = rcptResponse.includes('250'); - console.log(`Subdomain SPF test: ${accepted ? 'passed' : 'failed'}`); - expect(true).toEqual(true); - } else if (mailResponse.includes('550') || mailResponse.includes('553')) { - console.log('Subdomain sender rejected'); - expect(true).toEqual(true); - } - - // Send QUIT - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_security/test.sec-05.dmarc-policy.ts b/test/suite/smtpserver_security/test.sec-05.dmarc-policy.ts deleted file mode 100644 index 485bd1b..0000000 --- a/test/suite/smtpserver_security/test.sec-05.dmarc-policy.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -// Helper to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Look for any complete response - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('DMARC Policy - Reject policy enforcement', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Check if server advertises DMARC support - const advertisesDmarc = ehloResponse.toLowerCase().includes('dmarc'); - console.log('DMARC advertised:', advertisesDmarc); - - // Send MAIL FROM with domain that has reject policy - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - console.log('Server response:', mailResponse); - - if (mailResponse.includes('550') || mailResponse.includes('553')) { - // DMARC reject policy enforced at MAIL FROM - console.log('DMARC reject policy enforced at MAIL FROM'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } else if (mailResponse.includes('250')) { - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket, '250'); - console.log('Server response:', rcptResponse); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await waitForResponse(socket, '354'); - console.log('Server response:', dataResponse); - - // Send email with DMARC-relevant headers - const email = [ - `From: test@dmarc-reject.example.com`, - `To: recipient@example.com`, - `Subject: DMARC Policy Test - Reject`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=dmarc-reject.example.com; s=default;`, - ` h=from:to:subject:date; bh=test; b=test`, - '', - 'Testing DMARC reject policy enforcement.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const finalResponse = await waitForResponse(socket); - console.log('Server response:', finalResponse); - - const accepted = finalResponse.includes('250'); - const rejected = finalResponse.includes('550'); - - console.log(`DMARC reject policy: accepted=${accepted}, rejected=${rejected}`); - expect(accepted || rejected).toEqual(true); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } - } finally { - socket.destroy(); - } -}); - -tap.test('DMARC Policy - Quarantine policy', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM with domain that has quarantine policy - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', mailResponse); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket, '250'); - console.log('Server response:', rcptResponse); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await waitForResponse(socket, '354'); - console.log('Server response:', dataResponse); - - // Send email with DMARC-relevant headers - const email = [ - `From: test@dmarc-quarantine.example.com`, - `To: recipient@example.com`, - `Subject: DMARC Policy Test - Quarantine`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'Testing DMARC quarantine policy.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const finalResponse = await waitForResponse(socket); - console.log('Server response:', finalResponse); - - const accepted = finalResponse.includes('250'); - console.log(`DMARC quarantine policy: ${accepted ? 'accepted (may be quarantined)' : 'rejected'}`); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('DMARC Policy - None policy', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM with domain that has none policy (monitoring only) - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', mailResponse); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket, '250'); - console.log('Server response:', rcptResponse); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await waitForResponse(socket, '354'); - console.log('Server response:', dataResponse); - - // Send email with DMARC-relevant headers - const email = [ - `From: test@dmarc-none.example.com`, - `To: recipient@example.com`, - `Subject: DMARC Policy Test - None`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'Testing DMARC none policy (monitoring only).', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const finalResponse = await waitForResponse(socket, '250'); - console.log('Server response:', finalResponse); - - console.log('DMARC none policy: email accepted (monitoring only)'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('DMARC Policy - Alignment testing', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM with envelope domain - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', mailResponse); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket, '250'); - console.log('Server response:', rcptResponse); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await waitForResponse(socket, '354'); - console.log('Server response:', dataResponse); - - // Send email with different header From (tests alignment) - const email = [ - `From: test@header-domain.com`, - `To: recipient@example.com`, - `Subject: DMARC Alignment Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=header-domain.com; s=default;`, - ` h=from:to:subject:date; bh=test; b=test`, - '', - 'Testing DMARC domain alignment (envelope vs header From).', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const finalResponse = await waitForResponse(socket); - console.log('Server response:', finalResponse); - - const result = finalResponse.includes('250') ? 'accepted' : 'rejected'; - console.log(`DMARC alignment test: ${result}`); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('DMARC Policy - Percentage testing', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM with domain that has percentage-based DMARC policy - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', mailResponse); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket, '250'); - console.log('Server response:', rcptResponse); - - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await waitForResponse(socket, '354'); - console.log('Server response:', dataResponse); - - // Send email with DMARC-relevant headers - const email = [ - `From: test@dmarc-pct.example.com`, - `To: recipient@example.com`, - `Subject: DMARC Percentage Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'Testing DMARC with percentage-based policy application.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const finalResponse = await waitForResponse(socket); - console.log('Server response:', finalResponse); - - const result = finalResponse.includes('250') ? 'accepted' : 'rejected'; - console.log(`DMARC percentage policy: ${result} (may vary based on percentage)`); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_security/test.sec-06.ip-reputation.ts b/test/suite/smtpserver_security/test.sec-06.ip-reputation.ts deleted file mode 100644 index 8799d77..0000000 --- a/test/suite/smtpserver_security/test.sec-06.ip-reputation.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -// Helper to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Look for any complete response - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('IP Reputation - Suspicious hostname in EHLO', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Use suspicious hostname - socket.write('EHLO suspicious-host.badreputation.com\r\n'); - const ehloResponse = await waitForResponse(socket); - console.log('Server response:', ehloResponse); - - const accepted = ehloResponse.includes('250'); - const rejected = ehloResponse.includes('550') || ehloResponse.includes('521'); - - console.log(`Suspicious hostname: accepted=${accepted}, rejected=${rejected}`); - expect(accepted || rejected).toEqual(true); - - if (rejected) { - console.log('IP reputation check working - suspicious host rejected at EHLO'); - } - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('IP Reputation - Blacklisted sender domain', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Use known spam/blacklisted domain - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket); - console.log('Server response:', mailResponse); - - if (mailResponse.includes('250')) { - console.log('Blacklisted sender accepted at MAIL FROM'); - - // Try RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket); - console.log('Server response:', rcptResponse); - - const accepted = rcptResponse.includes('250'); - const rejected = rcptResponse.includes('550') || rcptResponse.includes('553'); - - console.log(`Blacklisted domain at RCPT: accepted=${accepted}, rejected=${rejected}`); - expect(accepted || rejected).toEqual(true); - } else if (mailResponse.includes('550') || mailResponse.includes('553')) { - console.log('Blacklisted sender rejected - IP reputation check working'); - expect(true).toEqual(true); - } - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('IP Reputation - Known good sender', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO localhost\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Use legitimate sender - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', mailResponse); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket, '250'); - console.log('Server response:', rcptResponse); - - console.log('Good sender accepted - IP reputation allows legitimate senders'); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('IP Reputation - Multiple connections from same IP', async (tools) => { - const connections: net.Socket[] = []; - const totalConnections = 3; - const connectionResults: Promise[] = []; - - // Create multiple connections rapidly - for (let i = 0; i < totalConnections; i++) { - const connectionPromise = (async () => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - connections.push(socket); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log(`Connection ${i + 1} response:`, greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket); - console.log(`Connection ${i + 1} response:`, ehloResponse); - - if (ehloResponse.includes('250')) { - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } else if (ehloResponse.includes('421') || ehloResponse.includes('550')) { - // Connection rejected due to rate limiting or reputation - console.log(`Connection ${i + 1} rejected - IP reputation/rate limiting active`); - } - } catch (err: any) { - console.error(`Connection ${i + 1} error:`, err.message); - } finally { - socket.destroy(); - } - })(); - - connectionResults.push(connectionPromise); - - // Small delay between connections - if (i < totalConnections - 1) { - await tools.delayFor(100); - } - } - - // Wait for all connections to complete - await Promise.all(connectionResults); - console.log('All connections completed'); - expect(true).toEqual(true); -}); - -tap.test('IP Reputation - Suspicious patterns in email', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - const greeting = await waitForResponse(socket, '220'); - console.log('Server response:', greeting); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - const ehloResponse = await waitForResponse(socket, '250'); - console.log('Server response:', ehloResponse); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - const mailResponse = await waitForResponse(socket, '250'); - console.log('Server response:', mailResponse); - - // Multiple recipients (spam pattern) - socket.write('RCPT TO:\r\n'); - const rcpt1Response = await waitForResponse(socket, '250'); - console.log('Server response:', rcpt1Response); - - socket.write('RCPT TO:\r\n'); - const rcpt2Response = await waitForResponse(socket, '250'); - console.log('Server response:', rcpt2Response); - - socket.write('RCPT TO:\r\n'); - const rcpt3Response = await waitForResponse(socket); - console.log('Server response:', rcpt3Response); - - if (rcpt3Response.includes('250')) { - // Send DATA - socket.write('DATA\r\n'); - const dataResponse = await waitForResponse(socket, '354'); - console.log('Server response:', dataResponse); - - // Email with spam-like content - const email = [ - `From: sender@example.com`, - `To: recipient1@example.com, recipient2@example.com, recipient3@example.com`, - `Subject: URGENT!!! You've won $1,000,000!!!`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'CLICK HERE NOW!!! Limited time offer!!!', - 'Visit http://suspicious-link.com/win-money', - 'Act NOW before it\'s too late!!!', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const emailResponse = await waitForResponse(socket); - console.log('Server response:', emailResponse); - - const result = emailResponse.includes('250') ? 'accepted' : 'rejected'; - console.log(`Suspicious content email ${result}`); - expect(true).toEqual(true); - } else if (rcpt3Response.includes('452') || rcpt3Response.includes('550')) { - console.log('Multiple recipients limited - reputation control active'); - expect(true).toEqual(true); - } - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221'); - } finally { - socket.destroy(); - } -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_security/test.sec-07.content-scanning.ts b/test/suite/smtpserver_security/test.sec-07.content-scanning.ts deleted file mode 100644 index d6a31cf..0000000 --- a/test/suite/smtpserver_security/test.sec-07.content-scanning.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -// Helper to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Look for any complete response - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('Content Scanning - Suspicious content patterns', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Email with suspicious content - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Content Scanning Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This email contains suspicious content that should trigger content scanning:', - 'VIRUS_TEST_STRING', - 'SUSPICIOUS_ATTACHMENT_PATTERN', - 'MALWARE_SIGNATURE_TEST', - 'Click here for FREE MONEY!!!', - 'Visit http://phishing-site.com/steal-data', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const dataResponse = await waitForResponse(socket); - - const accepted = dataResponse.startsWith('250'); - const rejected = dataResponse.startsWith('550'); - - console.log(`Suspicious content: accepted=${accepted}, rejected=${rejected}`); - - if (rejected) { - console.log('Content scanning active - suspicious content detected'); - } else { - console.log('Content scanning operational - email processed'); - } - - expect(accepted || rejected).toEqual(true); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('Content Scanning - Malware patterns', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Email with malware-like patterns - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Important Security Update`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - 'Content-Type: multipart/mixed; boundary="malware-boundary"', - '', - '--malware-boundary', - 'Content-Type: text/plain', - '', - 'Please run the attached file to update your security software.', - '', - '--malware-boundary', - 'Content-Type: application/x-msdownload; name="update.exe"', - 'Content-Transfer-Encoding: base64', - 'Content-Disposition: attachment; filename="update.exe"', - '', - 'TVqQAAMAAAAEAAAA//8AALgAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - 'AAAA4AAAAA4fug4AtAnNIbgBTM0hVGhpcyBwcm9ncmFtIGNhbm5vdCBiZSBydW4gaW4gRE9TIG1v', - '', - '--malware-boundary--', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const dataResponse = await waitForResponse(socket); - - const accepted = dataResponse.startsWith('250'); - const rejected = dataResponse.startsWith('550'); - - console.log(`Malware pattern email: ${accepted ? 'accepted' : 'rejected'}`); - - if (rejected) { - console.log('Content scanning active - malware patterns detected'); - } else { - console.log('Content scanning operational - email processed'); - } - - expect(accepted || rejected).toEqual(true); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('Content Scanning - Spam keywords', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Email with spam keywords - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: URGENT!!! Act NOW!!! Limited Time OFFER!!!`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'CONGRATULATIONS!!! You have WON!!!', - 'FREE FREE FREE!!!', - 'VIAGRA CIALIS CHEAP MEDS!!!', - 'MAKE $$$ FAST!!!', - 'WORK FROM HOME!!!', - 'NO CREDIT CHECK!!!', - 'GUARANTEED WINNER!!!', - 'CLICK HERE NOW!!!', - 'This is NOT SPAM!!!', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const dataResponse = await waitForResponse(socket); - - const accepted = dataResponse.startsWith('250'); - const rejected = dataResponse.startsWith('550'); - - console.log(`Spam keyword email: ${accepted ? 'accepted' : 'rejected (spam detected)'}`); - - if (rejected) { - console.log('Content scanning active - spam keywords detected'); - } else { - console.log('Content scanning operational - email processed'); - } - - expect(accepted || rejected).toEqual(true); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('Content Scanning - Clean legitimate email', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Clean legitimate email - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Meeting Tomorrow`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'Hi,', - '', - 'Just wanted to confirm our meeting for tomorrow at 2 PM.', - 'Please let me know if you need to reschedule.', - '', - 'Best regards,', - 'John', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const dataResponse = await waitForResponse(socket, '250'); - - console.log('Clean email accepted - content scanning allows legitimate emails'); - expect(dataResponse.startsWith('250')).toEqual(true); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('Content Scanning - Large attachment', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Email with large attachment pattern - const largeData = 'A'.repeat(10000); // 10KB of data - - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Large Attachment Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - 'Content-Type: multipart/mixed; boundary="boundary123"', - '', - '--boundary123', - 'Content-Type: text/plain', - '', - 'Please find the attached file.', - '', - '--boundary123', - 'Content-Type: application/octet-stream; name="largefile.dat"', - 'Content-Transfer-Encoding: base64', - 'Content-Disposition: attachment; filename="largefile.dat"', - '', - Buffer.from(largeData).toString('base64'), - '', - '--boundary123--', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const dataResponse = await waitForResponse(socket); - - const accepted = dataResponse.startsWith('250'); - const rejected = dataResponse.startsWith('550') || dataResponse.startsWith('552'); - - console.log(`Large attachment: ${accepted ? 'accepted' : 'rejected (size or content issue)'}`); - - if (rejected) { - console.log('Content scanning active - large attachment blocked'); - } else { - console.log('Content scanning operational - email processed'); - } - - expect(accepted || rejected).toEqual(true); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_security/test.sec-08.rate-limiting.ts b/test/suite/smtpserver_security/test.sec-08.rate-limiting.ts deleted file mode 100644 index a736eaa..0000000 --- a/test/suite/smtpserver_security/test.sec-08.rate-limiting.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 30025; -const TEST_TIMEOUT = 30000; - -let testServer: ITestServer; - -tap.test('setup - start SMTP server for rate limiting tests', async () => { - testServer = await startTestServer({ - port: TEST_PORT, - hostname: 'localhost' - }); - expect(testServer).toBeInstanceOf(Object); -}); - -tap.test('Rate Limiting - should limit rapid consecutive connections', async (tools) => { - const done = tools.defer(); - - try { - const connections: net.Socket[] = []; - let rateLimitTriggered = false; - let successfulConnections = 0; - const maxAttempts = 10; - - for (let i = 0; i < maxAttempts; i++) { - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - connections.push(socket); - - // Try EHLO - socket.write('EHLO testhost\r\n'); - - const response = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^[0-9]{3} /m) || data.match(/^[0-9]{3}-.*\r\n[0-9]{3} /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - if (response.includes('421') || response.toLowerCase().includes('rate') || response.toLowerCase().includes('limit')) { - rateLimitTriggered = true; - console.log(`Rate limit triggered at connection ${i + 1}`); - break; - } - - if (response.includes('250')) { - successfulConnections++; - } - - // Small delay between connections - await new Promise(resolve => setTimeout(resolve, 100)); - - } catch (error) { - const errorMsg = error instanceof Error ? error.message.toLowerCase() : ''; - if (errorMsg.includes('rate') || errorMsg.includes('limit') || errorMsg.includes('too many')) { - rateLimitTriggered = true; - console.log(`Rate limit error at connection ${i + 1}: ${errorMsg}`); - break; - } - // Connection refused might also indicate rate limiting - if (errorMsg.includes('econnrefused')) { - rateLimitTriggered = true; - console.log(`Connection refused at attempt ${i + 1} - possible rate limiting`); - break; - } - } - } - - // Clean up connections - for (const socket of connections) { - try { - if (!socket.destroyed) { - socket.write('QUIT\r\n'); - socket.end(); - } - } catch (e) { - // Ignore cleanup errors - } - } - - // Rate limiting is working if either: - // 1. We got explicit rate limit responses - // 2. We couldn't make all connections (some were refused/limited) - const rateLimitWorking = rateLimitTriggered || successfulConnections < maxAttempts; - - console.log(`Rate limiting test results: - - Successful connections: ${successfulConnections}/${maxAttempts} - - Rate limit triggered: ${rateLimitTriggered} - - Rate limiting effective: ${rateLimitWorking}`); - - // Note: We consider the test passed if rate limiting is either working OR not configured - // Many SMTP servers don't have rate limiting, which is also valid - expect(true).toEqual(true); - - } finally { - done.resolve(); - } -}); - -tap.test('Rate Limiting - should allow connections after rate limit period', async (tools) => { - const done = tools.defer(); - - try { - // First, try to trigger rate limiting - const connections: net.Socket[] = []; - let rateLimitTriggered = false; - - // Make rapid connections - for (let i = 0; i < 5; i++) { - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - connections.push(socket); - - socket.write('EHLO testhost\r\n'); - - const response = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^[0-9]{3} /m) || data.match(/^[0-9]{3}-.*\r\n[0-9]{3} /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - if (response.includes('421') || response.toLowerCase().includes('rate')) { - rateLimitTriggered = true; - break; - } - } catch (error) { - // Rate limit might cause connection errors - rateLimitTriggered = true; - break; - } - } - - // Clean up initial connections - for (const socket of connections) { - try { - if (!socket.destroyed) { - socket.end(); - } - } catch (e) { - // Ignore - } - } - - if (rateLimitTriggered) { - console.log('Rate limit was triggered, waiting before retry...'); - - // Wait a bit for rate limit to potentially reset - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Try a new connection - try { - const retrySocket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - retrySocket.once('connect', () => resolve()); - retrySocket.once('error', reject); - }); - - retrySocket.write('EHLO testhost\r\n'); - - const retryResponse = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^[0-9]{3} /m) || data.match(/^[0-9]{3}-.*\r\n[0-9]{3} /ms))) { - retrySocket.removeListener('data', handler); - resolve(data); - } - }; - retrySocket.on('data', handler); - }); - - console.log('Retry connection response:', retryResponse.trim()); - - // Clean up - retrySocket.write('QUIT\r\n'); - retrySocket.end(); - - // If we got a normal response, rate limiting reset worked - expect(retryResponse).toInclude('250'); - } catch (error) { - console.log('Retry connection failed:', error); - // Some servers might have longer rate limit periods - expect(true).toEqual(true); - } - } else { - console.log('Rate limiting not triggered or not configured'); - expect(true).toEqual(true); - } - - } finally { - done.resolve(); - } -}); - -tap.test('Rate Limiting - should limit rapid MAIL FROM commands', async (tools) => { - const done = tools.defer(); - - try { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: TEST_TIMEOUT - }); - - await new Promise((resolve, reject) => { - socket.once('connect', () => resolve()); - socket.once('error', reject); - }); - - // Get banner - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - - // Send EHLO - socket.write('EHLO testhost\r\n'); - await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - let commandRateLimitTriggered = false; - let successfulCommands = 0; - - // Try rapid MAIL FROM commands - for (let i = 0; i < 10; i++) { - socket.write(`MAIL FROM:\r\n`); - - const response = await new Promise((resolve) => { - let data = ''; - const handler = (chunk: Buffer) => { - data += chunk.toString(); - if (data.includes('\r\n')) { - socket.removeListener('data', handler); - resolve(data); - } - }; - socket.on('data', handler); - }); - - if (response.includes('421') || response.toLowerCase().includes('rate') || response.toLowerCase().includes('limit')) { - commandRateLimitTriggered = true; - console.log(`Command rate limit triggered at command ${i + 1}`); - break; - } - - if (response.includes('250')) { - successfulCommands++; - // Need to reset after each MAIL FROM - socket.write('RSET\r\n'); - await new Promise((resolve) => { - socket.once('data', (chunk) => resolve(chunk.toString())); - }); - } - } - - console.log(`Command rate limiting results: - - Successful commands: ${successfulCommands}/10 - - Rate limit triggered: ${commandRateLimitTriggered}`); - - // Clean up - socket.write('QUIT\r\n'); - socket.end(); - - // Test passes regardless - rate limiting is optional - expect(true).toEqual(true); - - } finally { - done.resolve(); - } -}); - -tap.test('cleanup - stop SMTP server', async () => { - await stopTestServer(testServer); - expect(true).toEqual(true); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_security/test.sec-09.tls-certificate-validation.ts b/test/suite/smtpserver_security/test.sec-09.tls-certificate-validation.ts deleted file mode 100644 index 18b789b..0000000 --- a/test/suite/smtpserver_security/test.sec-09.tls-certificate-validation.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import * as tls from 'tls'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('TLS Certificate Validation - STARTTLS certificate check', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - const supportsStarttls = dataBuffer.toLowerCase().includes('starttls'); - console.log('STARTTLS supported:', supportsStarttls); - - if (supportsStarttls) { - step = 'starttls'; - socket.write('STARTTLS\r\n'); - dataBuffer = ''; - } else { - console.log('STARTTLS not supported, testing plain connection'); - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } else if (step === 'starttls' && dataBuffer.includes('220')) { - console.log('Ready to start TLS'); - - // Upgrade to TLS - const tlsOptions = { - socket: socket, - rejectUnauthorized: false, // For self-signed certificates in testing - requestCert: true - }; - - const tlsSocket = tls.connect(tlsOptions); - - tlsSocket.on('secureConnect', () => { - console.log('TLS connection established'); - - // Get certificate information - const cert = tlsSocket.getPeerCertificate(); - console.log('Certificate present:', !!cert); - - if (cert && Object.keys(cert).length > 0) { - console.log('Certificate subject:', cert.subject); - console.log('Certificate issuer:', cert.issuer); - console.log('Certificate valid from:', cert.valid_from); - console.log('Certificate valid to:', cert.valid_to); - - // Check certificate validity - const now = new Date(); - const validFrom = new Date(cert.valid_from); - const validTo = new Date(cert.valid_to); - const isValid = now >= validFrom && now <= validTo; - - console.log('Certificate currently valid:', isValid); - expect(true).toEqual(true); // Certificate present - } - - // Test EHLO over TLS - tlsSocket.write('EHLO testclient\r\n'); - }); - - tlsSocket.on('data', (data) => { - const response = data.toString(); - console.log('TLS response:', response); - - if (response.includes('250')) { - console.log('EHLO over TLS successful'); - expect(true).toEqual(true); - - tlsSocket.write('QUIT\r\n'); - tlsSocket.end(); - done.resolve(); - } - }); - - tlsSocket.on('error', (err) => { - console.error('TLS error:', err); - done.reject(err); - }); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('TLS Certificate Validation - Direct TLS connection', async (tools) => { - const done = tools.defer(); - - // Try connecting with TLS directly (implicit TLS) - const tlsOptions = { - host: 'localhost', - port: TEST_PORT, - rejectUnauthorized: false, - timeout: 30000 - }; - - const socket = tls.connect(tlsOptions); - - socket.on('secureConnect', () => { - console.log('Direct TLS connection established'); - - const cert = socket.getPeerCertificate(); - if (cert && Object.keys(cert).length > 0) { - console.log('Certificate found on direct TLS connection'); - expect(true).toEqual(true); - } - - socket.end(); - done.resolve(); - }); - - socket.on('error', (err) => { - // Direct TLS might not be supported, try plain connection - console.log('Direct TLS not supported, this is expected for STARTTLS servers'); - expect(true).toEqual(true); - done.resolve(); - }); - - socket.on('timeout', () => { - console.log('Direct TLS connection timeout'); - socket.destroy(); - done.resolve(); - }); - - await done.promise; -}); - -tap.test('TLS Certificate Validation - Certificate verification with strict mode', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - if (dataBuffer.toLowerCase().includes('starttls')) { - step = 'starttls'; - socket.write('STARTTLS\r\n'); - dataBuffer = ''; - } else { - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } else if (step === 'starttls' && dataBuffer.includes('220')) { - // Try with strict certificate verification - const tlsOptions = { - socket: socket, - rejectUnauthorized: true, // Strict mode - servername: 'localhost' // For SNI - }; - - const tlsSocket = tls.connect(tlsOptions); - - tlsSocket.on('secureConnect', () => { - console.log('TLS connection with strict verification successful'); - const authorized = tlsSocket.authorized; - console.log('Certificate authorized:', authorized); - - if (!authorized) { - console.log('Authorization error:', tlsSocket.authorizationError); - } - - expect(true).toEqual(true); // Connection established - tlsSocket.write('QUIT\r\n'); - tlsSocket.end(); - done.resolve(); - }); - - tlsSocket.on('error', (err) => { - console.log('Certificate verification error (expected for self-signed):', err.message); - expect(true).toEqual(true); // Error is expected for self-signed certificates - socket.end(); - done.resolve(); - }); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('TLS Certificate Validation - Cipher suite information', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - if (dataBuffer.toLowerCase().includes('starttls')) { - step = 'starttls'; - socket.write('STARTTLS\r\n'); - dataBuffer = ''; - } else { - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - } else if (step === 'starttls' && dataBuffer.includes('220')) { - const tlsOptions = { - socket: socket, - rejectUnauthorized: false - }; - - const tlsSocket = tls.connect(tlsOptions); - - tlsSocket.on('secureConnect', () => { - console.log('TLS connection established'); - - // Get cipher information - const cipher = tlsSocket.getCipher(); - if (cipher) { - console.log('Cipher name:', cipher.name); - console.log('Cipher version:', cipher.version); - console.log('Cipher standardName:', cipher.standardName); - } - - // Get protocol version - const protocol = tlsSocket.getProtocol(); - console.log('TLS Protocol:', protocol); - - // Verify modern TLS version - expect(['TLSv1.2', 'TLSv1.3']).toContain(protocol); - - tlsSocket.write('QUIT\r\n'); - tlsSocket.end(); - done.resolve(); - }); - - tlsSocket.on('error', (err) => { - console.error('TLS error:', err); - done.reject(err); - }); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_security/test.sec-10.header-injection-prevention.ts b/test/suite/smtpserver_security/test.sec-10.header-injection-prevention.ts deleted file mode 100644 index 62f205e..0000000 --- a/test/suite/smtpserver_security/test.sec-10.header-injection-prevention.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('Header Injection Prevention - CRLF injection in headers', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - // Attempt header injection with CRLF sequences - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: Test\r\nBcc: hidden@attacker.com`, // CRLF injection attempt - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `X-Custom: normal\r\nX-Injected: malicious`, // Another injection attempt - '', - 'This email tests header injection prevention.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - dataBuffer = ''; - } else if (dataBuffer.includes('250 ') || dataBuffer.includes('550 ')) { - const accepted = dataBuffer.includes('250'); - const rejected = dataBuffer.includes('550'); - - console.log(`Header injection attempt: ${accepted ? 'accepted' : 'rejected'}`); - - if (rejected) { - console.log('Header injection prevention active - malicious headers detected'); - } - - expect(accepted || rejected).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Header Injection Prevention - Command injection in MAIL FROM', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - // Attempt command injection in MAIL FROM - socket.write('MAIL FROM: SIZE=1000\r\nRCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'mail') { - // Server should reject or handle this properly - const properResponse = dataBuffer.includes('250') || - dataBuffer.includes('501') || - dataBuffer.includes('500'); - - console.log('Command injection attempt handled'); - expect(properResponse).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Header Injection Prevention - HTML/Script injection in body', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - // Email with HTML/Script content - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: HTML Injection Test`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `Content-Type: text/html`, - '', - '', - '

Test Email

', - '', - '', - 'Injected-Header: malicious-value', // Attempted header injection in body - '', - '.', - '' - ].join('\r\n'); - - socket.write(email); - dataBuffer = ''; - } else if (dataBuffer.includes('250 ') || dataBuffer.includes('550 ')) { - const accepted = dataBuffer.includes('250'); - console.log(`HTML/Script content: ${accepted ? 'accepted (may be sanitized)' : 'rejected'}`); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Header Injection Prevention - Null byte injection', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - // Attempt null byte injection - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail') { - // Should be rejected or sanitized - const handled = dataBuffer.includes('250') || - dataBuffer.includes('501') || - dataBuffer.includes('550'); - - console.log('Null byte injection attempt handled'); - expect(handled).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('Header Injection Prevention - Unicode and encoding attacks', async (tools) => { - const done = tools.defer(); - - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - let dataBuffer = ''; - let step = 'greeting'; - - socket.on('data', (data) => { - dataBuffer += data.toString(); - console.log('Server response:', data.toString()); - - if (step === 'greeting' && dataBuffer.includes('220 ')) { - step = 'ehlo'; - socket.write('EHLO testclient\r\n'); - dataBuffer = ''; - } else if (step === 'ehlo' && dataBuffer.includes('250')) { - step = 'mail'; - socket.write('MAIL FROM:\r\n'); - dataBuffer = ''; - } else if (step === 'mail' && dataBuffer.includes('250')) { - step = 'rcpt'; - socket.write('RCPT TO:\r\n'); - dataBuffer = ''; - } else if (step === 'rcpt' && dataBuffer.includes('250')) { - step = 'data'; - socket.write('DATA\r\n'); - dataBuffer = ''; - } else if (step === 'data' && dataBuffer.includes('354')) { - // Unicode tricks and encoding attacks - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: =?UTF-8?B?${Buffer.from('Test\r\nBcc: hidden@attacker.com').toString('base64')}?=`, // Encoded injection - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `X-Test: \u000D\u000AX-Injected: true`, // Unicode CRLF - '', - 'Testing unicode and encoding attacks.', - '\x00\x0D\x0AExtra-Header: injected', // Null byte + CRLF - '.', - '' - ].join('\r\n'); - - socket.write(email); - dataBuffer = ''; - } else if (dataBuffer.includes('250 ') || dataBuffer.includes('550 ')) { - const result = dataBuffer.includes('250') ? 'accepted' : 'rejected'; - console.log(`Unicode/encoding attack: ${result}`); - expect(true).toEqual(true); - - socket.write('QUIT\r\n'); - socket.end(); - done.resolve(); - } - }); - - socket.on('error', (err) => { - console.error('Socket error:', err); - done.reject(err); - }); - - await done.promise; -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/suite/smtpserver_security/test.sec-11.bounce-management.ts b/test/suite/smtpserver_security/test.sec-11.bounce-management.ts deleted file mode 100644 index e2a2cf7..0000000 --- a/test/suite/smtpserver_security/test.sec-11.bounce-management.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../../../ts/plugins.js'; -import * as net from 'net'; -import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' -import type { ITestServer } from '../../helpers/server.loader.js'; - -const TEST_PORT = 2525; -let testServer: ITestServer; - -// Helper to wait for SMTP response -const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { - return new Promise((resolve, reject) => { - let buffer = ''; - const timer = setTimeout(() => { - socket.removeListener('data', handler); - reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); - }, timeout); - - const handler = (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\r\n'); - - for (const line of lines) { - if (expectedCode) { - if (line.startsWith(expectedCode + ' ')) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } else { - // Look for any complete response - if (line.match(/^\d{3} /)) { - clearTimeout(timer); - socket.removeListener('data', handler); - resolve(buffer); - return; - } - } - } - }; - - socket.on('data', handler); - }); -}; - -tap.test('setup - start test server', async (toolsArg) => { - testServer = await startTestServer({ port: TEST_PORT }); - await toolsArg.delayFor(1000); -}); - -tap.test('Bounce Management - Invalid recipient domain', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send to non-existent domain - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket); - - if (rcptResponse.startsWith('550') || rcptResponse.startsWith('551') || rcptResponse.startsWith('553')) { - console.log('Bounce management active - invalid recipient properly rejected'); - expect(true).toEqual(true); - } else if (rcptResponse.startsWith('250')) { - // Server accepted, may generate bounce later - console.log('Invalid recipient accepted - bounce may be generated later'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - const email = [ - `From: sender@example.com`, - `To: nonexistent@invalid-domain-that-does-not-exist.com`, - `Subject: Bounce Management Test`, - `Return-Path: `, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This email is designed to test bounce management functionality.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const dataResponse = await waitForResponse(socket, '250'); - - console.log('Email accepted for processing - bounce will be generated'); - expect(dataResponse.startsWith('250')).toEqual(true); - } - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('Bounce Management - Empty return path (null sender)', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Empty return path (null sender) - used for bounce messages - socket.write('MAIL FROM:<>\r\n'); - const mailResponse = await waitForResponse(socket); - - if (mailResponse.startsWith('250')) { - console.log('Null sender accepted (for bounce messages)'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - const dataCommandResponse = await waitForResponse(socket); - - if (dataCommandResponse.startsWith('354')) { - // Bounce message format - const email = [ - `From: MAILER-DAEMON@example.com`, - `To: recipient@example.com`, - `Subject: Mail delivery failed: returning message to sender`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `Auto-Submitted: auto-replied`, - '', - 'This message was created automatically by mail delivery software.', - '', - 'A message that you sent could not be delivered to one or more recipients.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const dataResponse = await waitForResponse(socket, '250'); - - console.log('Bounce message with null sender accepted'); - expect(dataResponse.startsWith('250')).toEqual(true); - } else if (dataCommandResponse.startsWith('503')) { - // Server rejects DATA for null sender - console.log('Server rejects DATA command for null sender (strict policy)'); - expect(dataCommandResponse.startsWith('503')).toEqual(true); - } - } else { - console.log('Null sender rejected'); - expect(true).toEqual(true); - } - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('Bounce Management - DSN headers', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - // Email with DSN request headers - const email = [ - `From: sender@example.com`, - `To: recipient@example.com`, - `Subject: DSN Test`, - `Return-Path: `, - `Disposition-Notification-To: sender@example.com`, - `Return-Receipt-To: sender@example.com`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This email requests delivery status notifications.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const dataResponse = await waitForResponse(socket, '250'); - - console.log('Email with DSN headers accepted'); - expect(dataResponse.startsWith('250')).toEqual(true); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('Bounce Management - Bounce loop prevention', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Null sender (bounce message) - socket.write('MAIL FROM:<>\r\n'); - await waitForResponse(socket, '250'); - - // To another mailer-daemon (potential loop) - socket.write('RCPT TO:\r\n'); - const rcptResponse = await waitForResponse(socket); - - if (rcptResponse.startsWith('550') || rcptResponse.startsWith('553')) { - console.log('Bounce loop prevented - mailer-daemon recipient rejected'); - expect(true).toEqual(true); - } else if (rcptResponse.startsWith('250')) { - console.log('Mailer-daemon recipient accepted - check for loop prevention'); - - // Send DATA - socket.write('DATA\r\n'); - const dataCommandResponse = await waitForResponse(socket); - - if (dataCommandResponse.startsWith('354')) { - const email = [ - `From: MAILER-DAEMON@example.com`, - `To: mailer-daemon@another-server.com`, - `Subject: Delivery Status Notification (Failure)`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - `Auto-Submitted: auto-replied`, - `X-Loop: example.com`, - '', - 'This is a bounce of a bounce - potential loop.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const dataResponse = await waitForResponse(socket); - - const result = dataResponse.startsWith('250') ? 'accepted' : 'rejected'; - console.log(`Bounce loop test: ${result}`); - expect(true).toEqual(true); - } else if (dataCommandResponse.startsWith('503')) { - // Server rejects DATA for null sender - console.log('Bounce loop prevented at DATA stage (null sender rejection)'); - expect(dataCommandResponse.startsWith('503')).toEqual(true); - } - } - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('Bounce Management - Valid email (control test)', async (tools) => { - const socket = net.createConnection({ - host: 'localhost', - port: TEST_PORT, - timeout: 30000 - }); - - try { - // Wait for greeting - await waitForResponse(socket, '220'); - - // Send EHLO - socket.write('EHLO testclient\r\n'); - await waitForResponse(socket, '250'); - - // Send MAIL FROM - socket.write('MAIL FROM:\r\n'); - await waitForResponse(socket, '250'); - - // Send RCPT TO - socket.write('RCPT TO:\r\n'); - await waitForResponse(socket, '250'); - - // Send DATA - socket.write('DATA\r\n'); - await waitForResponse(socket, '354'); - - const email = [ - `From: sender@example.com`, - `To: valid@example.com`, - `Subject: Valid Email Test`, - `Return-Path: `, - `Date: ${new Date().toUTCString()}`, - `Message-ID: `, - '', - 'This is a valid email that should not trigger bounce.', - '.', - '' - ].join('\r\n'); - - socket.write(email); - const dataResponse = await waitForResponse(socket, '250'); - - console.log('Valid email accepted - no bounce expected'); - expect(dataResponse.startsWith('250')).toEqual(true); - - socket.write('QUIT\r\n'); - await waitForResponse(socket, '221').catch(() => {}); - } finally { - socket.destroy(); - } -}); - -tap.test('cleanup - stop test server', async () => { - await stopTestServer(testServer); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.base.ts b/test/test.base.ts deleted file mode 100644 index 8e4007a..0000000 --- a/test/test.base.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../ts/plugins.js'; -import * as paths from '../ts/paths.js'; -import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.js'; -import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.js'; - -/** - * Basic test to check if our integrated classes work correctly - */ -tap.test('verify that SenderReputationMonitor and IPWarmupManager are functioning', async () => { - // Create instances of both classes - const reputationMonitor = SenderReputationMonitor.getInstance({ - enabled: true, - domains: ['example.com'] - }); - - const ipWarmupManager = IPWarmupManager.getInstance({ - enabled: true, - ipAddresses: ['192.168.1.1', '192.168.1.2'], - targetDomains: ['example.com'] - }); - - // Test SenderReputationMonitor - reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 }); - reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 }); - - const reputationData = reputationMonitor.getReputationData('example.com'); - expect(reputationData).toBeTruthy(); - - const summary = reputationMonitor.getReputationSummary(); - expect(summary.length).toBeGreaterThan(0); - - // Add and remove domains - reputationMonitor.addDomain('test.com'); - reputationMonitor.removeDomain('test.com'); - - // Test IPWarmupManager - ipWarmupManager.setActiveAllocationPolicy('balanced'); - - const bestIP = ipWarmupManager.getBestIPForSending({ - from: 'test@example.com', - to: ['recipient@test.com'], - domain: 'example.com' - }); - - if (bestIP) { - ipWarmupManager.recordSend(bestIP); - const canSendMore = ipWarmupManager.canSendMoreToday(bestIP); - expect(typeof canSendMore).toEqual('boolean'); - } - - const stageCount = ipWarmupManager.getStageCount(); - expect(stageCount).toBeGreaterThan(0); -}); - -// Final clean-up test -tap.test('clean up after tests', async () => { - // No-op - just to make sure everything is cleaned up properly -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.bouncemanager.ts b/test/test.bouncemanager.ts deleted file mode 100644 index b236b17..0000000 --- a/test/test.bouncemanager.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { BounceManager, BounceType, BounceCategory } from '../ts/mail/core/classes.bouncemanager.js'; -import { Email } from '../ts/mail/core/classes.email.js'; - -/** - * Test the BounceManager class - */ -tap.test('BounceManager - should be instantiable', async () => { - const bounceManager = new BounceManager(); - expect(bounceManager).toBeTruthy(); -}); - -tap.test('BounceManager - should process basic bounce categories', async () => { - const bounceManager = new BounceManager(); - - // Test hard bounce detection - const hardBounce = await bounceManager.processBounce({ - recipient: 'invalid@example.com', - sender: 'sender@example.com', - smtpResponse: 'user unknown', - domain: 'example.com' - }); - - expect(hardBounce.bounceCategory).toEqual(BounceCategory.HARD); - - // Test soft bounce detection - const softBounce = await bounceManager.processBounce({ - recipient: 'valid@example.com', - sender: 'sender@example.com', - smtpResponse: 'server unavailable', - domain: 'example.com' - }); - - expect(softBounce.bounceCategory).toEqual(BounceCategory.SOFT); - - // Test auto-response detection - const autoResponse = await bounceManager.processBounce({ - recipient: 'away@example.com', - sender: 'sender@example.com', - smtpResponse: 'auto-reply: out of office', - domain: 'example.com' - }); - - expect(autoResponse.bounceCategory).toEqual(BounceCategory.AUTO_RESPONSE); -}); - -tap.test('BounceManager - should add and check suppression list entries', async () => { - const bounceManager = new BounceManager(); - - // Add to suppression list permanently - bounceManager.addToSuppressionList('permanent@example.com', 'Test hard bounce', undefined); - - // Add to suppression list temporarily (5 seconds) - const expireTime = Date.now() + 5000; - bounceManager.addToSuppressionList('temporary@example.com', 'Test soft bounce', expireTime); - - // Check suppression status - expect(bounceManager.isEmailSuppressed('permanent@example.com')).toEqual(true); - expect(bounceManager.isEmailSuppressed('temporary@example.com')).toEqual(true); - expect(bounceManager.isEmailSuppressed('notsuppressed@example.com')).toEqual(false); - - // Get suppression info - const info = bounceManager.getSuppressionInfo('permanent@example.com'); - expect(info).toBeTruthy(); - expect(info.reason).toEqual('Test hard bounce'); - expect(info.expiresAt).toBeUndefined(); - - // Verify temporary suppression info - const tempInfo = bounceManager.getSuppressionInfo('temporary@example.com'); - expect(tempInfo).toBeTruthy(); - expect(tempInfo.reason).toEqual('Test soft bounce'); - expect(tempInfo.expiresAt).toEqual(expireTime); - - // Wait for expiration (6 seconds) - await new Promise(resolve => setTimeout(resolve, 6000)); - - // Verify permanent suppression is still active - expect(bounceManager.isEmailSuppressed('permanent@example.com')).toEqual(true); - - // Verify temporary suppression has expired - expect(bounceManager.isEmailSuppressed('temporary@example.com')).toEqual(false); -}); - -tap.test('BounceManager - should process SMTP failures correctly', async () => { - const bounceManager = new BounceManager(); - - const result = await bounceManager.processSmtpFailure( - 'recipient@example.com', - '550 5.1.1 User unknown', - { - sender: 'sender@example.com', - statusCode: '550' - } - ); - - expect(result.bounceType).toEqual(BounceType.INVALID_RECIPIENT); - expect(result.bounceCategory).toEqual(BounceCategory.HARD); - - // Check that the email was added to the suppression list - expect(bounceManager.isEmailSuppressed('recipient@example.com')).toEqual(true); -}); - -tap.test('BounceManager - should process bounce emails correctly', async () => { - const bounceManager = new BounceManager(); - - // Create a mock bounce email - const bounceEmail = new Email({ - from: 'mailer-daemon@example.com', - subject: 'Mail delivery failed: returning message to sender', - text: ` - This message was created automatically by mail delivery software. - - A message that you sent could not be delivered to one or more of its recipients. - The following address(es) failed: - - recipient@example.com - mailbox is full - - ------ This is a copy of the message, including all the headers. ------ - - Original-Recipient: rfc822;recipient@example.com - Final-Recipient: rfc822;recipient@example.com - Status: 5.2.2 - diagnostic-code: smtp; 552 5.2.2 Mailbox full - `, - to: 'sender@example.com' // Bounce emails are typically sent back to the original sender - }); - - const result = await bounceManager.processBounceEmail(bounceEmail); - - expect(result).toBeTruthy(); - expect(result.bounceType).toEqual(BounceType.MAILBOX_FULL); - expect(result.bounceCategory).toEqual(BounceCategory.HARD); - expect(result.recipient).toEqual('recipient@example.com'); -}); - -tap.test('BounceManager - should handle retries for soft bounces', async () => { - const bounceManager = new BounceManager({ - retryStrategy: { - maxRetries: 2, - initialDelay: 100, // 100ms for test - maxDelay: 1000, - backoffFactor: 2 - } - }); - - // First attempt - const result1 = await bounceManager.processBounce({ - recipient: 'retry@example.com', - sender: 'sender@example.com', - bounceType: BounceType.SERVER_UNAVAILABLE, - bounceCategory: BounceCategory.SOFT, - domain: 'example.com' - }); - - // Email should be suppressed temporarily - expect(bounceManager.isEmailSuppressed('retry@example.com')).toEqual(true); - expect(result1.retryCount).toEqual(1); - expect(result1.nextRetryTime).toBeGreaterThan(Date.now()); - - // Second attempt - const result2 = await bounceManager.processBounce({ - recipient: 'retry@example.com', - sender: 'sender@example.com', - bounceType: BounceType.SERVER_UNAVAILABLE, - bounceCategory: BounceCategory.SOFT, - domain: 'example.com', - retryCount: 1 - }); - - expect(result2.retryCount).toEqual(2); - - // Third attempt (should convert to hard bounce) - const result3 = await bounceManager.processBounce({ - recipient: 'retry@example.com', - sender: 'sender@example.com', - bounceType: BounceType.SERVER_UNAVAILABLE, - bounceCategory: BounceCategory.SOFT, - domain: 'example.com', - retryCount: 2 - }); - - // Should now be a hard bounce after max retries - expect(result3.bounceCategory).toEqual(BounceCategory.HARD); - - // Email should be suppressed permanently - expect(bounceManager.isEmailSuppressed('retry@example.com')).toEqual(true); - const info = bounceManager.getSuppressionInfo('retry@example.com'); - expect(info.expiresAt).toBeUndefined(); // Permanent -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.contentscanner.ts b/test/test.contentscanner.ts index f4e30df..8c5ce21 100644 --- a/test/test.contentscanner.ts +++ b/test/test.contentscanner.ts @@ -1,6 +1,6 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; import { ContentScanner, ThreatCategory } from '../ts/security/classes.contentscanner.js'; -import { Email } from '../ts/mail/core/classes.email.js'; +import { Email } from '@push.rocks/smartmta'; // Test instantiation tap.test('ContentScanner - should be instantiable', async () => { diff --git a/test/test.dcrouter.email.ts b/test/test.dcrouter.email.ts index 1465663..8bb057a 100644 --- a/test/test.dcrouter.email.ts +++ b/test/test.dcrouter.email.ts @@ -2,69 +2,63 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as plugins from '../ts/plugins.js'; import * as path from 'path'; import * as fs from 'fs'; -import { - DcRouter, - type IDcRouterOptions, - type IEmailConfig, - type EmailProcessingMode, - type IDomainRule -} from '../ts/classes.dcrouter.js'; +import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js'; +import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta'; tap.test('DcRouter class - Custom email port configuration', async () => { // Define custom port mapping - const customPortMapping = { + const customPortMapping: Record = { 25: 11025, // Custom SMTP port mapping 587: 11587, // Custom submission port mapping 465: 11465, // Custom SMTPS port mapping 2525: 12525 // Additional custom port }; - - // Create a custom email configuration - const emailConfig: IEmailConfig = { - ports: [25, 587, 465, 2525], // Added a non-standard port + + // Create a custom email configuration using smartmta interfaces + const emailConfig: IUnifiedEmailServerOptions = { + ports: [25, 587, 465, 2525], hostname: 'mail.example.com', maxMessageSize: 50 * 1024 * 1024, // 50MB - - defaultMode: 'forward' as EmailProcessingMode, - defaultServer: 'fallback-mail.example.com', - defaultPort: 25, - defaultTls: true, - - domainRules: [ + domains: [ { - pattern: '*@example.com', - mode: 'forward' as EmailProcessingMode, - target: { - server: 'mail1.example.com', - port: 25, - useTls: true + domain: 'example.com', + dnsMode: 'external-dns', + }, + { + domain: 'example.org', + dnsMode: 'external-dns', + } + ], + routes: [ + { + name: 'forward-example-com', + match: { + recipients: '*@example.com', + }, + action: { + type: 'forward', + forward: { + host: 'mail1.example.com', + port: 25, + } } }, { - pattern: '*@example.org', - mode: 'mta' as EmailProcessingMode, - mtaOptions: { - domain: 'example.org', - allowLocalDelivery: true + name: 'deliver-example-org', + match: { + recipients: '*@example.org', + }, + action: { + type: 'deliver', + process: { + dkim: true, + } } } ] }; - // Create custom email storage path - const customEmailsPath = path.join(process.cwd(), 'email'); - - // Ensure directory exists and is empty - if (fs.existsSync(customEmailsPath)) { - try { - fs.rmSync(customEmailsPath, { recursive: true }); - } catch (e) { - console.warn('Could not remove test directory:', e); - } - } - fs.mkdirSync(customEmailsPath, { recursive: true }); - // Create DcRouter options with custom email port configuration const options: IDcRouterOptions = { emailConfig, @@ -76,7 +70,6 @@ tap.test('DcRouter class - Custom email port configuration', async () => { routeName: 'custom-smtp-route' } }, - receivedEmailsPath: customEmailsPath }, tls: { contactEmail: 'test@example.com' @@ -85,118 +78,82 @@ tap.test('DcRouter class - Custom email port configuration', async () => { // Create DcRouter instance const router = new DcRouter(options); - + // Verify the options are correctly set expect(router.options.emailPortConfig).toBeTruthy(); - expect(router.options.emailPortConfig.portMapping).toEqual(customPortMapping); - expect(router.options.emailPortConfig.receivedEmailsPath).toEqual(customEmailsPath); - + expect(router.options.emailPortConfig!.portMapping).toEqual(customPortMapping); + // Test the generateEmailRoutes method - if (typeof router['generateEmailRoutes'] === 'function') { - const routes = router['generateEmailRoutes'](emailConfig); - + if (typeof (router as any)['generateEmailRoutes'] === 'function') { + const routes = (router as any)['generateEmailRoutes'](emailConfig); + // Verify that all ports are configured - expect(routes.length).toBeGreaterThan(0); // At least some routes are configured - + expect(routes.length).toBeGreaterThan(0); + // Check the custom port configuration - const customPortRoute = routes.find(r => { + const customPortRoute = routes.find((r: any) => { const ports = r.match.ports; return ports === 2525 || (Array.isArray(ports) && (ports as number[]).includes(2525)); }); expect(customPortRoute).toBeTruthy(); expect(customPortRoute?.name).toEqual('custom-smtp-route'); - expect(customPortRoute?.action.target.port).toEqual(12525); - + expect(customPortRoute?.action.targets[0].port).toEqual(12525); + // Check standard port mappings - const smtpRoute = routes.find(r => { + const smtpRoute = routes.find((r: any) => { const ports = r.match.ports; return ports === 25 || (Array.isArray(ports) && (ports as number[]).includes(25)); }); - expect(smtpRoute?.action.target.port).toEqual(11025); - - const submissionRoute = routes.find(r => { + expect(smtpRoute?.action.targets[0].port).toEqual(11025); + + const submissionRoute = routes.find((r: any) => { const ports = r.match.ports; return ports === 587 || (Array.isArray(ports) && (ports as number[]).includes(587)); }); - expect(submissionRoute?.action.target.port).toEqual(11587); - } - - // Clean up - try { - fs.rmSync(customEmailsPath, { recursive: true }); - } catch (e) { - console.warn('Could not remove test directory in cleanup:', e); + expect(submissionRoute?.action.targets[0].port).toEqual(11587); } }); -tap.test('DcRouter class - Custom email storage path', async () => { - // Create custom email storage path - const customEmailsPath = path.join(process.cwd(), 'email'); - - // Ensure directory exists and is empty - if (fs.existsSync(customEmailsPath)) { - try { - fs.rmSync(customEmailsPath, { recursive: true }); - } catch (e) { - console.warn('Could not remove test directory:', e); - } - } - fs.mkdirSync(customEmailsPath, { recursive: true }); - +tap.test('DcRouter class - Email config with domains and routes', async () => { // Create a basic email configuration - // Use high port (2525) to avoid needing root privileges - const emailConfig: IEmailConfig = { + const emailConfig: IUnifiedEmailServerOptions = { ports: [2525], hostname: 'mail.example.com', - domains: [], // Required: domain configurations - routes: [] // Required: email routing rules + domains: [], + routes: [] }; - - // Create DcRouter options with custom email storage path + + // Create DcRouter options const options: IDcRouterOptions = { emailConfig, - emailPortConfig: { - receivedEmailsPath: customEmailsPath - }, tls: { contactEmail: 'test@example.com' + }, + cacheConfig: { + enabled: false, } }; - + // Create DcRouter instance const router = new DcRouter(options); - + // Start the router to initialize email services await router.start(); - - // Verify that the custom email storage path was configured - expect(router.options.emailPortConfig?.receivedEmailsPath).toEqual(customEmailsPath); - - // Verify the directory exists - expect(fs.existsSync(customEmailsPath)).toEqual(true); - + // Verify unified email server was initialized expect(router.emailServer).toBeTruthy(); - + // Stop the router await router.stop(); - - // Clean up - try { - fs.rmSync(customEmailsPath, { recursive: true }); - } catch (e) { - console.warn('Could not remove test directory in cleanup:', e); - } }); // Final clean-up test tap.test('clean up after tests', async () => { - // No-op - just to make sure everything is cleaned up properly + // No-op }); tap.test('stop', async () => { await tap.stopForcefully(); }); -// Export a function to run all tests -export default tap.start(); \ No newline at end of file +export default tap.start(); diff --git a/test/test.deliverability.ts b/test/test.deliverability.ts deleted file mode 100644 index b54aca5..0000000 --- a/test/test.deliverability.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../ts/plugins.js'; -import * as paths from '../ts/paths.js'; - -// Import the components we want to test -import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.js'; -import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.js'; - -// Ensure test directories exist -paths.ensureDirectories(); - -// Test SenderReputationMonitor functionality -tap.test('SenderReputationMonitor should track sending events', async () => { - // Initialize monitor with test domain - const monitor = SenderReputationMonitor.getInstance({ - enabled: true, - domains: ['test-domain.com'] - }); - - // Record some events - monitor.recordSendEvent('test-domain.com', { type: 'sent', count: 100 }); - monitor.recordSendEvent('test-domain.com', { type: 'delivered', count: 95 }); - - // Get domain metrics - const metrics = monitor.getReputationData('test-domain.com'); - - // Verify metrics were recorded - if (metrics) { - expect(metrics.volume.sent).toEqual(100); - expect(metrics.volume.delivered).toEqual(95); - } -}); - -// Test IPWarmupManager functionality -tap.test('IPWarmupManager should handle IP allocation policies', async () => { - // Initialize warmup manager - const manager = IPWarmupManager.getInstance({ - enabled: true, - ipAddresses: ['192.168.1.1', '192.168.1.2'], - targetDomains: ['test-domain.com'] - }); - - // Set allocation policy - manager.setActiveAllocationPolicy('balanced'); - - // Verify allocation methods work - const canSend = manager.canSendMoreToday('192.168.1.1'); - expect(typeof canSend).toEqual('boolean'); -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.dns-manager-creation.ts b/test/test.dns-manager-creation.ts deleted file mode 100644 index 72f8d5a..0000000 --- a/test/test.dns-manager-creation.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../ts/plugins.js'; -import * as paths from '../ts/paths.js'; -import { DnsManager } from '../ts/mail/routing/classes.dns.manager.js'; -import { DKIMCreator } from '../ts/mail/security/classes.dkimcreator.js'; -import { StorageManager } from '../ts/storage/classes.storagemanager.js'; -import type { IEmailDomainConfig } from '../ts/mail/routing/interfaces.js'; - -// Mock DcRouter with DNS server -class MockDcRouter { - public storageManager: StorageManager; - public dnsServer: any; - public options: any; - private dnsHandlers: Map = new Map(); - - constructor(testDir: string, dnsDomain?: string) { - this.storageManager = new StorageManager({ fsPath: testDir }); - this.options = { dnsDomain }; - - // Mock DNS server - this.dnsServer = { - registerHandler: (name: string, types: string[], handler: () => any) => { - const key = `${name}:${types.join(',')}`; - this.dnsHandlers.set(key, handler); - } - }; - } - - getDnsHandler(name: string, type: string): any { - const key = `${name}:${type}`; - return this.dnsHandlers.get(key); - } -} - -tap.test('DnsManager - Create Internal DNS Records', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-dns-manager-creation'); - const mockRouter = new MockDcRouter(testDir, 'ns.test.com') as any; - const dnsManager = new DnsManager(mockRouter); - - const domainConfigs: IEmailDomainConfig[] = [ - { - domain: 'test.example.com', - dnsMode: 'internal-dns', - dns: { - internal: { - mxPriority: 15, - ttl: 7200 - } - }, - dkim: { - selector: 'test2024', - keySize: 2048 - } - } - ]; - - // Create DNS records - await dnsManager.ensureDnsRecords(domainConfigs); - - // Verify MX record was registered - const mxHandler = mockRouter.getDnsHandler('test.example.com', 'MX'); - expect(mxHandler).toBeTruthy(); - const mxRecord = mxHandler(); - expect(mxRecord.type).toEqual('MX'); - expect(mxRecord.data.priority).toEqual(15); - expect(mxRecord.data.exchange).toEqual('test.example.com'); - expect(mxRecord.ttl).toEqual(7200); - - // Verify SPF record was registered - const txtHandler = mockRouter.getDnsHandler('test.example.com', 'TXT'); - expect(txtHandler).toBeTruthy(); - const spfRecord = txtHandler(); - expect(spfRecord.type).toEqual('TXT'); - expect(spfRecord.data).toEqual('v=spf1 a mx ~all'); - - // Verify DMARC record was registered - const dmarcHandler = mockRouter.getDnsHandler('_dmarc.test.example.com', 'TXT'); - expect(dmarcHandler).toBeTruthy(); - const dmarcRecord = dmarcHandler(); - expect(dmarcRecord.type).toEqual('TXT'); - expect(dmarcRecord.data).toContain('v=DMARC1'); - expect(dmarcRecord.data).toContain('p=none'); - - // Verify records were stored in StorageManager - const mxStored = await mockRouter.storageManager.getJSON('/email/dns/test.example.com/mx'); - expect(mxStored).toBeTruthy(); - expect(mxStored.priority).toEqual(15); - - const spfStored = await mockRouter.storageManager.getJSON('/email/dns/test.example.com/spf'); - expect(spfStored).toBeTruthy(); - expect(spfStored.data).toEqual('v=spf1 a mx ~all'); - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -tap.test('DnsManager - Create DKIM Records', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-dns-manager-dkim'); - const keysDir = plugins.path.join(testDir, 'keys'); - await plugins.fs.promises.mkdir(keysDir, { recursive: true }); - - const mockRouter = new MockDcRouter(testDir, 'ns.test.com') as any; - const dnsManager = new DnsManager(mockRouter); - const dkimCreator = new DKIMCreator(keysDir, mockRouter.storageManager); - - const domainConfigs: IEmailDomainConfig[] = [ - { - domain: 'dkim.example.com', - dnsMode: 'internal-dns', - dkim: { - selector: 'mail2024', - keySize: 2048 - } - } - ]; - - // Generate DKIM keys first - await dkimCreator.handleDKIMKeysForDomain('dkim.example.com'); - - // Create DNS records including DKIM - await dnsManager.ensureDnsRecords(domainConfigs, dkimCreator); - - // Verify DKIM record was registered - const dkimHandler = mockRouter.getDnsHandler('mail2024._domainkey.dkim.example.com', 'TXT'); - expect(dkimHandler).toBeTruthy(); - const dkimRecord = dkimHandler(); - expect(dkimRecord.type).toEqual('TXT'); - expect(dkimRecord.data).toContain('v=DKIM1'); - expect(dkimRecord.data).toContain('k=rsa'); - expect(dkimRecord.data).toContain('p='); - - // Verify DKIM record was stored - const dkimStored = await mockRouter.storageManager.getJSON('/email/dns/dkim.example.com/dkim'); - expect(dkimStored).toBeTruthy(); - expect(dkimStored.name).toEqual('mail2024._domainkey.dkim.example.com'); - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.dns-mode-switching.ts b/test/test.dns-mode-switching.ts deleted file mode 100644 index 656ced7..0000000 --- a/test/test.dns-mode-switching.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../ts/plugins.js'; -import * as paths from '../ts/paths.js'; -import { StorageManager } from '../ts/storage/classes.storagemanager.js'; -import { DnsManager } from '../ts/mail/routing/classes.dns.manager.js'; -import { DomainRegistry } from '../ts/mail/routing/classes.domain.registry.js'; -import { DKIMCreator } from '../ts/mail/security/classes.dkimcreator.js'; -import type { IEmailDomainConfig } from '../ts/mail/routing/interfaces.js'; - -// Mock DcRouter for testing -class MockDcRouter { - public storageManager: StorageManager; - public options: any; - - constructor(testDir: string, dnsDomain?: string) { - this.storageManager = new StorageManager({ fsPath: testDir }); - this.options = { - dnsDomain - }; - } -} - -tap.test('DNS Mode Switching - Forward to Internal', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-dns-mode-switch-1'); - const keysDir = plugins.path.join(testDir, 'keys'); - await plugins.fs.promises.mkdir(keysDir, { recursive: true }); - - const mockRouter = new MockDcRouter(testDir, 'ns.test.com') as any; - const dkimCreator = new DKIMCreator(keysDir, mockRouter.storageManager); - - // Phase 1: Start with forward mode - let config: IEmailDomainConfig = { - domain: 'switchtest1.com', - dnsMode: 'forward', - dns: { - forward: { - skipDnsValidation: true - } - } - }; - - let registry = new DomainRegistry([config]); - let domainConfig = registry.getDomainConfig('switchtest1.com'); - - expect(domainConfig?.dnsMode).toEqual('forward'); - - // DKIM keys should still be generated for consistency - await dkimCreator.handleDKIMKeysForDomain('switchtest1.com'); - const keys = await dkimCreator.readDKIMKeys('switchtest1.com'); - expect(keys.privateKey).toBeTruthy(); - - // Phase 2: Switch to internal-dns mode - config = { - domain: 'switchtest1.com', - dnsMode: 'internal-dns', - dns: { - internal: { - mxPriority: 20, - ttl: 7200 - } - } - }; - - registry = new DomainRegistry([config]); - domainConfig = registry.getDomainConfig('switchtest1.com'); - - expect(domainConfig?.dnsMode).toEqual('internal-dns'); - expect(domainConfig?.dns?.internal?.mxPriority).toEqual(20); - - // DKIM keys should persist across mode switches - const keysAfterSwitch = await dkimCreator.readDKIMKeys('switchtest1.com'); - expect(keysAfterSwitch.privateKey).toEqual(keys.privateKey); - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -tap.test('DNS Mode Switching - External to Forward', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-dns-mode-switch-2'); - const keysDir = plugins.path.join(testDir, 'keys'); - await plugins.fs.promises.mkdir(keysDir, { recursive: true }); - - const mockRouter = new MockDcRouter(testDir) as any; - const dkimCreator = new DKIMCreator(keysDir, mockRouter.storageManager); - - // Phase 1: Start with external-dns mode - let config: IEmailDomainConfig = { - domain: 'switchtest2.com', - dnsMode: 'external-dns', - dns: { - external: { - requiredRecords: ['MX', 'SPF', 'DKIM'] - } - }, - dkim: { - selector: 'custom2024', - keySize: 4096 - } - }; - - let registry = new DomainRegistry([config]); - let domainConfig = registry.getDomainConfig('switchtest2.com'); - - expect(domainConfig?.dnsMode).toEqual('external-dns'); - expect(domainConfig?.dkim?.selector).toEqual('custom2024'); - expect(domainConfig?.dkim?.keySize).toEqual(4096); - - // Generate DKIM keys (always uses default selector initially) - await dkimCreator.handleDKIMKeysForDomain('switchtest2.com'); - // For custom selector, we would need to implement key rotation - const dnsRecord = await dkimCreator.getDNSRecordForDomain('switchtest2.com'); - expect(dnsRecord.name).toContain('mta._domainkey'); - - // Phase 2: Switch to forward mode - config = { - domain: 'switchtest2.com', - dnsMode: 'forward', - dns: { - forward: { - targetDomain: 'mail.forward.com' - } - } - }; - - registry = new DomainRegistry([config]); - domainConfig = registry.getDomainConfig('switchtest2.com'); - - expect(domainConfig?.dnsMode).toEqual('forward'); - expect(domainConfig?.dns?.forward?.targetDomain).toEqual('mail.forward.com'); - - // DKIM configuration should revert to defaults - expect(domainConfig?.dkim?.selector).toEqual('default'); - expect(domainConfig?.dkim?.keySize).toEqual(2048); - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -tap.test('DNS Mode Switching - Multiple Domains Different Modes', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-dns-mode-switch-3'); - const mockRouter = new MockDcRouter(testDir, 'ns.multi.com') as any; - - // Configure multiple domains with different modes - const domains: IEmailDomainConfig[] = [ - { - domain: 'forward.multi.com', - dnsMode: 'forward' - }, - { - domain: 'internal.multi.com', - dnsMode: 'internal-dns', - dns: { - internal: { - mxPriority: 5 - } - } - }, - { - domain: 'external.multi.com', - dnsMode: 'external-dns', - rateLimits: { - inbound: { - messagesPerMinute: 50 - } - } - } - ]; - - const registry = new DomainRegistry(domains); - - // Verify each domain has correct mode - expect(registry.getDomainConfig('forward.multi.com')?.dnsMode).toEqual('forward'); - expect(registry.getDomainConfig('internal.multi.com')?.dnsMode).toEqual('internal-dns'); - expect(registry.getDomainConfig('external.multi.com')?.dnsMode).toEqual('external-dns'); - - // Verify mode-specific configurations - expect(registry.getDomainConfig('internal.multi.com')?.dns?.internal?.mxPriority).toEqual(5); - expect(registry.getDomainConfig('external.multi.com')?.rateLimits?.inbound?.messagesPerMinute).toEqual(50); - - // Get domains by mode - const forwardDomains = registry.getDomainsByMode('forward'); - const internalDomains = registry.getDomainsByMode('internal-dns'); - const externalDomains = registry.getDomainsByMode('external-dns'); - - expect(forwardDomains.length).toEqual(1); - expect(forwardDomains[0].domain).toEqual('forward.multi.com'); - - expect(internalDomains.length).toEqual(1); - expect(internalDomains[0].domain).toEqual('internal.multi.com'); - - expect(externalDomains.length).toEqual(1); - expect(externalDomains[0].domain).toEqual('external.multi.com'); - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -tap.test('DNS Mode Switching - Configuration Persistence', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-dns-mode-switch-4'); - const storage = new StorageManager({ fsPath: testDir }); - - // Save domain configuration - const config: IEmailDomainConfig = { - domain: 'persist.test.com', - dnsMode: 'internal-dns', - dns: { - internal: { - mxPriority: 15, - ttl: 1800 - } - }, - dkim: { - selector: 'persist2024', - rotateKeys: true, - rotationInterval: 30 - }, - rateLimits: { - outbound: { - messagesPerHour: 1000 - } - } - }; - - // Save to storage - await storage.setJSON('/email/domains/persist.test.com', config); - - // Simulate restart - load from storage - const loadedConfig = await storage.getJSON('/email/domains/persist.test.com'); - - expect(loadedConfig).toBeTruthy(); - expect(loadedConfig?.dnsMode).toEqual('internal-dns'); - expect(loadedConfig?.dns?.internal?.mxPriority).toEqual(15); - expect(loadedConfig?.dkim?.selector).toEqual('persist2024'); - expect(loadedConfig?.dkim?.rotateKeys).toEqual(true); - expect(loadedConfig?.rateLimits?.outbound?.messagesPerHour).toEqual(1000); - - // Update DNS mode - if (loadedConfig) { - loadedConfig.dnsMode = 'forward'; - loadedConfig.dns = { - forward: { - skipDnsValidation: false - } - }; - await storage.setJSON('/email/domains/persist.test.com', loadedConfig); - } - - // Load updated config - const updatedConfig = await storage.getJSON('/email/domains/persist.test.com'); - expect(updatedConfig?.dnsMode).toEqual('forward'); - expect(updatedConfig?.dns?.forward?.skipDnsValidation).toEqual(false); - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.dns-validation.ts b/test/test.dns-validation.ts deleted file mode 100644 index 7de35eb..0000000 --- a/test/test.dns-validation.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../ts/plugins.js'; -import * as paths from '../ts/paths.js'; -import { DnsManager } from '../ts/mail/routing/classes.dns.manager.js'; -import { DomainRegistry } from '../ts/mail/routing/classes.domain.registry.js'; -import { StorageManager } from '../ts/storage/classes.storagemanager.js'; -import { DKIMCreator } from '../ts/mail/security/classes.dkimcreator.js'; -import type { IEmailDomainConfig } from '../ts/mail/routing/interfaces.js'; - -// Mock DcRouter for testing -class MockDcRouter { - public storageManager: StorageManager; - public options: any; - - constructor(testDir: string, dnsNsDomains?: string[], dnsScopes?: string[]) { - this.storageManager = new StorageManager({ fsPath: testDir }); - this.options = { - dnsNsDomains, - dnsScopes - }; - } -} - -// Mock DNS resolver for testing -class MockDnsManager extends DnsManager { - private mockNsRecords: Map = new Map(); - private mockTxtRecords: Map = new Map(); - private mockMxRecords: Map = new Map(); - - setNsRecords(domain: string, records: string[]) { - this.mockNsRecords.set(domain, records); - } - - setTxtRecords(domain: string, records: string[][]) { - this.mockTxtRecords.set(domain, records); - } - - setMxRecords(domain: string, records: any[]) { - this.mockMxRecords.set(domain, records); - } - - protected async resolveNs(domain: string): Promise { - return this.mockNsRecords.get(domain) || []; - } - - protected async resolveTxt(domain: string): Promise { - return this.mockTxtRecords.get(domain) || []; - } - - protected async resolveMx(domain: string): Promise { - return this.mockMxRecords.get(domain) || []; - } -} - -tap.test('DNS Validator - Forward Mode', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-dns-forward'); - const mockRouter = new MockDcRouter(testDir) as any; - const validator = new DnsManager(mockRouter); - - const config: IEmailDomainConfig = { - domain: 'forward.example.com', - dnsMode: 'forward', - dns: { - forward: { - skipDnsValidation: true - } - } - }; - - const result = await validator.validateDomain(config); - - expect(result.valid).toEqual(true); - expect(result.errors.length).toEqual(0); - expect(result.warnings.length).toBeGreaterThan(0); // Should have warning about forward mode - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -tap.test('DNS Validator - Internal DNS Mode', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-dns-internal'); - // Configure with dnsNsDomains array and dnsScopes that include the test domain - const mockRouter = new MockDcRouter( - testDir, - ['ns.myservice.com', 'ns2.myservice.com'], // dnsNsDomains - ['mail.example.com', 'mail2.example.com'] // dnsScopes - must include all internal-dns domains - ) as any; - const validator = new MockDnsManager(mockRouter); - - // Setup NS delegation - validator.setNsRecords('mail.example.com', ['ns.myservice.com']); - - const config: IEmailDomainConfig = { - domain: 'mail.example.com', - dnsMode: 'internal-dns', - dns: { - internal: { - mxPriority: 10, - ttl: 3600 - } - } - }; - - const result = await validator.validateDomain(config); - - expect(result.valid).toEqual(true); - expect(result.errors.length).toEqual(0); - - // Test without NS delegation (domain is in scopes, but NS not yet delegated) - validator.setNsRecords('mail2.example.com', ['other.nameserver.com']); - - const config2: IEmailDomainConfig = { - domain: 'mail2.example.com', - dnsMode: 'internal-dns' - }; - - const result2 = await validator.validateDomain(config2); - - // Should have warnings but still be valid (warnings don't make it invalid) - expect(result2.valid).toEqual(true); - expect(result2.warnings.length).toBeGreaterThan(0); - expect(result2.requiredChanges.length).toBeGreaterThan(0); - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -tap.test('DNS Validator - External DNS Mode', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-dns-external'); - const mockRouter = new MockDcRouter(testDir) as any; - const validator = new MockDnsManager(mockRouter); - - // Setup mock DNS records - validator.setMxRecords('example.com', [ - { priority: 10, exchange: 'mail.example.com' } - ]); - validator.setTxtRecords('example.com', [ - ['v=spf1 mx ~all'] - ]); - validator.setTxtRecords('default._domainkey.example.com', [ - ['v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...'] - ]); - validator.setTxtRecords('_dmarc.example.com', [ - ['v=DMARC1; p=none; rua=mailto:dmarc@example.com'] - ]); - - const config: IEmailDomainConfig = { - domain: 'example.com', - dnsMode: 'external-dns', - dns: { - external: { - requiredRecords: ['MX', 'SPF', 'DKIM', 'DMARC'] - } - } - }; - - const result = await validator.validateDomain(config); - - // External DNS validation checks if records exist and provides instructions - expect(result.valid).toEqual(true); - expect(result.errors.length).toEqual(0); - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -tap.test('DKIM Key Generation', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-dkim-generation'); - const storage = new StorageManager({ fsPath: testDir }); - - // Ensure keys directory exists - const keysDir = plugins.path.join(testDir, 'keys'); - await plugins.fs.promises.mkdir(keysDir, { recursive: true }); - - const dkimCreator = new DKIMCreator(keysDir, storage); - - // Generate DKIM keys - await dkimCreator.handleDKIMKeysForDomain('test.example.com'); - - // Verify keys were created - const keys = await dkimCreator.readDKIMKeys('test.example.com'); - expect(keys.privateKey).toBeTruthy(); - expect(keys.publicKey).toBeTruthy(); - expect(keys.privateKey).toContain('BEGIN RSA PRIVATE KEY'); - expect(keys.publicKey).toContain('BEGIN PUBLIC KEY'); - - // Get DNS record - const dnsRecord = await dkimCreator.getDNSRecordForDomain('test.example.com'); - expect(dnsRecord.name).toEqual('mta._domainkey.test.example.com'); - expect(dnsRecord.type).toEqual('TXT'); - expect(dnsRecord.value).toContain('v=DKIM1'); - expect(dnsRecord.value).toContain('k=rsa'); - expect(dnsRecord.value).toContain('p='); - - // Test key rotation - const needsRotation = await dkimCreator.needsRotation('test.example.com', 'default', 0); // 0 days = always rotate - expect(needsRotation).toEqual(true); - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -tap.test('Domain Registry', async () => { - // Test domain configurations - const domains: IEmailDomainConfig[] = [ - { - domain: 'simple.example.com', - dnsMode: 'internal-dns' - }, - { - domain: 'configured.example.com', - dnsMode: 'external-dns', - dkim: { - selector: 'custom', - keySize: 4096 - }, - rateLimits: { - outbound: { - messagesPerMinute: 100 - } - } - } - ]; - - const defaults = { - dnsMode: 'internal-dns' as const, - dkim: { - selector: 'default', - keySize: 2048 - } - }; - - const registry = new DomainRegistry(domains, defaults); - - // Test simple domain (uses defaults) - const simpleConfig = registry.getDomainConfig('simple.example.com'); - expect(simpleConfig).toBeTruthy(); - expect(simpleConfig?.dnsMode).toEqual('internal-dns'); - expect(simpleConfig?.dkim?.selector).toEqual('default'); - expect(simpleConfig?.dkim?.keySize).toEqual(2048); - - // Test configured domain - const configuredConfig = registry.getDomainConfig('configured.example.com'); - expect(configuredConfig).toBeTruthy(); - expect(configuredConfig?.dnsMode).toEqual('external-dns'); - expect(configuredConfig?.dkim?.selector).toEqual('custom'); - expect(configuredConfig?.dkim?.keySize).toEqual(4096); - expect(configuredConfig?.rateLimits?.outbound?.messagesPerMinute).toEqual(100); - - // Test non-existent domain - const nonExistent = registry.getDomainConfig('nonexistent.example.com'); - expect(nonExistent).toEqual(undefined); // Returns undefined, not null - - // Test getting all domains - const allDomains = registry.getAllDomains(); - expect(allDomains.length).toEqual(2); - expect(allDomains).toContain('simple.example.com'); - expect(allDomains).toContain('configured.example.com'); -}); - -tap.test('DNS Record Generation', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-dns-records'); - const storage = new StorageManager({ fsPath: testDir }); - - // Ensure keys directory exists - const keysDir = plugins.path.join(testDir, 'keys'); - await plugins.fs.promises.mkdir(keysDir, { recursive: true }); - - const dkimCreator = new DKIMCreator(keysDir, storage); - - // Generate DKIM keys first - await dkimCreator.handleDKIMKeysForDomain('records.example.com'); - - // Test DNS record for domain - const dkimRecord = await dkimCreator.getDNSRecordForDomain('records.example.com'); - - // Check DKIM record - expect(dkimRecord).toBeTruthy(); - expect(dkimRecord.name).toContain('_domainkey.records.example.com'); - expect(dkimRecord.value).toContain('v=DKIM1'); - - // Note: The DnsManager doesn't have a generateDnsRecords method exposed - // DNS records are handled internally or by the DNS server component - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.email-socket-handler.ts b/test/test.email-socket-handler.ts deleted file mode 100644 index 56c9d3e..0000000 --- a/test/test.email-socket-handler.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { DcRouter } from '../ts/classes.dcrouter.js'; -import * as plugins from '../ts/plugins.js'; - -let dcRouter: DcRouter; - -tap.test('should use traditional port forwarding when useSocketHandler is false', async () => { - dcRouter = new DcRouter({ - emailConfig: { - ports: [2525, 2587, 2465], - hostname: 'mail.test.local', - domains: ['test.local'], - routes: [], - useSocketHandler: false // Traditional mode - }, - smartProxyConfig: { - routes: [] - }, - cacheConfig: { enabled: false } - }); - - await dcRouter.start(); - - // Check that email server is created and listening on ports - const emailServer = (dcRouter as any).emailServer; - expect(emailServer).toBeDefined(); - - // Check SmartProxy routes are forward type - const smartProxy = (dcRouter as any).smartProxy; - const routes = smartProxy?.options?.routes || []; - const emailRoutes = routes.filter((route: any) => - route.name?.includes('-route') - ); - - emailRoutes.forEach((route: any) => { - expect(route.action.type).toEqual('forward'); - expect(route.action.target).toBeDefined(); - expect(route.action.target.host).toEqual('localhost'); - }); - - await dcRouter.stop(); -}); - -tap.test('should use socket-handler mode when useSocketHandler is true', async () => { - dcRouter = new DcRouter({ - emailConfig: { - ports: [2525, 2587, 2465], - hostname: 'mail.test.local', - domains: ['test.local'], - routes: [], - useSocketHandler: true // Socket-handler mode - }, - smartProxyConfig: { - routes: [] - }, - cacheConfig: { enabled: false } - }); - - await dcRouter.start(); - - // Check that email server is created - const emailServer = (dcRouter as any).emailServer; - expect(emailServer).toBeDefined(); - - // Check SmartProxy routes are socket-handler type - const smartProxy = (dcRouter as any).smartProxy; - const routes = smartProxy?.options?.routes || []; - const emailRoutes = routes.filter((route: any) => - route.name?.includes('-route') - ); - - emailRoutes.forEach((route: any) => { - expect(route.action.type).toEqual('socket-handler'); - expect(route.action.socketHandler).toBeDefined(); - expect(typeof route.action.socketHandler).toEqual('function'); - }); - - await dcRouter.stop(); -}); - -tap.test('should generate correct email routes for each port', async () => { - const emailConfig = { - ports: [2525, 2587, 2465], - hostname: 'mail.test.local', - domains: ['test.local'], - routes: [], - useSocketHandler: true - }; - - dcRouter = new DcRouter({ emailConfig, cacheConfig: { enabled: false } }); - - // Access the private method to generate routes - const emailRoutes = (dcRouter as any).generateEmailRoutes(emailConfig); - - expect(emailRoutes.length).toEqual(3); - - // Check route for port 2525 (non-standard ports use generic naming) - const port2525Route = emailRoutes.find((r: any) => r.name === 'email-port-2525-route'); - expect(port2525Route).toBeDefined(); - expect(port2525Route.match.ports).toContain(2525); - expect(port2525Route.action.type).toEqual('socket-handler'); - - // Check route for port 2587 - const port2587Route = emailRoutes.find((r: any) => r.name === 'email-port-2587-route'); - expect(port2587Route).toBeDefined(); - expect(port2587Route.match.ports).toContain(2587); - expect(port2587Route.action.type).toEqual('socket-handler'); - - // Check route for port 2465 - const port2465Route = emailRoutes.find((r: any) => r.name === 'email-port-2465-route'); - expect(port2465Route).toBeDefined(); - expect(port2465Route.match.ports).toContain(2465); - expect(port2465Route.action.type).toEqual('socket-handler'); -}); - -tap.test('email socket handler should handle different ports correctly', async () => { - dcRouter = new DcRouter({ - emailConfig: { - ports: [2525, 2587, 2465], - hostname: 'mail.test.local', - domains: ['test.local'], - routes: [], - useSocketHandler: true - }, - cacheConfig: { enabled: false } - }); - - await dcRouter.start(); - - // Test port 2525 handler (plain SMTP) - const port2525Handler = (dcRouter as any).createMailSocketHandler(2525); - expect(port2525Handler).toBeDefined(); - expect(typeof port2525Handler).toEqual('function'); - - // Test port 2465 handler (SMTPS - should wrap in TLS) - const port2465Handler = (dcRouter as any).createMailSocketHandler(2465); - expect(port2465Handler).toBeDefined(); - expect(typeof port2465Handler).toEqual('function'); - - await dcRouter.stop(); -}); - -tap.test('email server handleSocket method should work', async () => { - dcRouter = new DcRouter({ - emailConfig: { - ports: [2525], - hostname: 'mail.test.local', - domains: ['test.local'], - routes: [], - useSocketHandler: true - }, - cacheConfig: { enabled: false } - }); - - await dcRouter.start(); - - const emailServer = (dcRouter as any).emailServer; - expect(emailServer).toBeDefined(); - expect(emailServer.handleSocket).toBeDefined(); - expect(typeof emailServer.handleSocket).toEqual('function'); - - // Create a mock socket - const mockSocket = new plugins.net.Socket(); - let socketDestroyed = false; - - mockSocket.destroy = () => { - socketDestroyed = true; - }; - - // Test handleSocket - try { - await emailServer.handleSocket(mockSocket, 2525); - // It will fail because we don't have a real socket, but it should handle it gracefully - } catch (error) { - // Expected to error with mock socket - } - - await dcRouter.stop(); -}); - -tap.test('should not create SMTP servers when useSocketHandler is true', async () => { - dcRouter = new DcRouter({ - emailConfig: { - ports: [2525, 2587, 2465], - hostname: 'mail.test.local', - domains: ['test.local'], - routes: [], - useSocketHandler: true - }, - cacheConfig: { enabled: false } - }); - - await dcRouter.start(); - - // The email server should not have any SMTP server instances - const emailServer = (dcRouter as any).emailServer; - expect(emailServer).toBeDefined(); - - // The servers array should be empty (no port binding) - expect(emailServer.servers).toBeDefined(); - expect(emailServer.servers.length).toEqual(0); - - await dcRouter.stop(); -}); - -tap.test('TLS handling should differ between ports', async () => { - // Use standard ports 25 and 465 to test TLS behavior - // This test doesn't start the server, just checks route generation - const emailConfig = { - ports: [25, 465], - hostname: 'mail.test.local', - domains: ['test.local'], - routes: [], - useSocketHandler: false // Use traditional mode to check TLS config - }; - - dcRouter = new DcRouter({ emailConfig, cacheConfig: { enabled: false } }); - - const emailRoutes = (dcRouter as any).generateEmailRoutes(emailConfig); - - // Port 25 should use passthrough - const smtpRoute = emailRoutes.find((r: any) => r.match.ports[0] === 25); - expect(smtpRoute.action.tls.mode).toEqual('passthrough'); - - // Port 465 should use terminate - const smtpsRoute = emailRoutes.find((r: any) => r.match.ports[0] === 465); - expect(smtpsRoute.action.tls.mode).toEqual('terminate'); - expect(smtpsRoute.action.tls.certificate).toEqual('auto'); -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.email.integration.ts b/test/test.email.integration.ts deleted file mode 100644 index 31775e0..0000000 --- a/test/test.email.integration.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { type IEmailRoute } from '../ts/mail/routing/interfaces.js'; -import { EmailRouter } from '../ts/mail/routing/classes.email.router.js'; -import { Email } from '../ts/mail/core/classes.email.js'; - -tap.test('Email Integration - Route-based forwarding scenario', async () => { - // Define routes with match/action pattern - const routes: IEmailRoute[] = [ - { - name: 'office-relay', - priority: 100, - match: { - clientIp: '192.168.0.0/16' - }, - action: { - type: 'forward', - forward: { - host: 'internal.mail.example.com', - port: 25 - } - } - }, - { - name: 'company-mail', - priority: 50, - match: { - recipients: '*@mycompany.com' - }, - action: { - type: 'process', - process: { - scan: true, - dkim: true, - queue: 'normal' - } - } - }, - { - name: 'admin-priority', - priority: 90, - match: { - recipients: 'admin@mycompany.com' - }, - action: { - type: 'process', - process: { - scan: true, - dkim: true, - queue: 'priority' - } - } - }, - { - name: 'spam-reject', - priority: 80, - match: { - senders: '*@spammer.com' - }, - action: { - type: 'reject', - reject: { - code: 550, - message: 'Sender blocked' - } - } - }, - { - name: 'default-reject', - priority: 1, - match: { - recipients: '*' - }, - action: { - type: 'reject', - reject: { - code: 550, - message: 'Relay denied' - } - } - } - ]; - - // Create email router with routes - const emailRouter = new EmailRouter(routes); - - // Test route priority sorting - const sortedRoutes = emailRouter.getRoutes(); - expect(sortedRoutes[0].name).toEqual('office-relay'); // Highest priority (100) - expect(sortedRoutes[1].name).toEqual('admin-priority'); // Priority 90 - expect(sortedRoutes[2].name).toEqual('spam-reject'); // Priority 80 - expect(sortedRoutes[sortedRoutes.length - 1].name).toEqual('default-reject'); // Lowest priority (1) - - // Test route evaluation with different scenarios - const testCases = [ - { - description: 'Office relay scenario (IP-based)', - email: new Email({ - from: 'user@external.com', - to: 'anyone@anywhere.com', - subject: 'Test from office', - text: 'Test message' - }), - session: { - id: 'test-1', - remoteAddress: '192.168.1.100' - }, - expectedRoute: 'office-relay' - }, - { - description: 'Admin priority mail', - email: new Email({ - from: 'user@external.com', - to: 'admin@mycompany.com', - subject: 'Important admin message', - text: 'Admin message content' - }), - session: { - id: 'test-2', - remoteAddress: '10.0.0.1' - }, - expectedRoute: 'admin-priority' - }, - { - description: 'Company mail processing', - email: new Email({ - from: 'partner@partner.com', - to: 'sales@mycompany.com', - subject: 'Business proposal', - text: 'Business content' - }), - session: { - id: 'test-3', - remoteAddress: '203.0.113.1' - }, - expectedRoute: 'company-mail' - }, - { - description: 'Spam rejection', - email: new Email({ - from: 'bad@spammer.com', - to: 'victim@mycompany.com', - subject: 'Spam message', - text: 'Spam content' - }), - session: { - id: 'test-4', - remoteAddress: '203.0.113.2' - }, - expectedRoute: 'spam-reject' - }, - { - description: 'Default rejection', - email: new Email({ - from: 'unknown@unknown.com', - to: 'random@random.com', - subject: 'Random message', - text: 'Random content' - }), - session: { - id: 'test-5', - remoteAddress: '203.0.113.3' - }, - expectedRoute: 'default-reject' - } - ]; - - for (const testCase of testCases) { - const context = { - email: testCase.email, - session: testCase.session as any - }; - - const matchedRoute = await emailRouter.evaluateRoutes(context); - expect(matchedRoute).not.toEqual(null); - expect(matchedRoute?.name).toEqual(testCase.expectedRoute); - - console.log(`✓ ${testCase.description}: Matched route '${matchedRoute?.name}'`); - } -}); - -tap.test('Email Integration - CIDR IP matching', async () => { - const routes: IEmailRoute[] = [ - { - name: 'internal-network', - match: { clientIp: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'] }, - action: { type: 'deliver' } - }, - { - name: 'specific-subnet', - priority: 10, - match: { clientIp: '192.168.1.0/24' }, - action: { type: 'forward', forward: { host: 'subnet-mail.com', port: 25 } } - } - ]; - - const emailRouter = new EmailRouter(routes); - - const testIps = [ - { ip: '192.168.1.100', expectedRoute: 'specific-subnet' }, // More specific match - { ip: '192.168.2.100', expectedRoute: 'internal-network' }, // General internal - { ip: '10.5.10.20', expectedRoute: 'internal-network' }, - { ip: '172.16.5.10', expectedRoute: 'internal-network' } - ]; - - for (const testCase of testIps) { - const context = { - email: new Email({ from: 'test@test.com', to: 'user@test.com', subject: 'Test', text: 'Test' }), - session: { id: 'test', remoteAddress: testCase.ip } as any - }; - - const route = await emailRouter.evaluateRoutes(context); - expect(route?.name).toEqual(testCase.expectedRoute); - console.log(`✓ IP ${testCase.ip}: Matched route '${route?.name}'`); - } -}); - -tap.test('Email Integration - Authentication-based routing', async () => { - const routes: IEmailRoute[] = [ - { - name: 'authenticated-relay', - priority: 100, - match: { authenticated: true }, - action: { - type: 'forward', - forward: { host: 'relay.example.com', port: 587 } - } - }, - { - name: 'unauthenticated-local', - match: { - authenticated: false, - recipients: '*@localserver.com' - }, - action: { type: 'deliver' } - }, - { - name: 'unauthenticated-reject', - match: { authenticated: false }, - action: { - type: 'reject', - reject: { code: 550, message: 'Authentication required' } - } - } - ]; - - const emailRouter = new EmailRouter(routes); - - // Test authenticated user - const authContext = { - email: new Email({ from: 'user@anywhere.com', to: 'dest@anywhere.com', subject: 'Test', text: 'Test' }), - session: { - id: 'auth-test', - remoteAddress: '203.0.113.1', - authenticated: true, - authenticatedUser: 'user@anywhere.com' - } as any - }; - - const authRoute = await emailRouter.evaluateRoutes(authContext); - expect(authRoute?.name).toEqual('authenticated-relay'); - - // Test unauthenticated local delivery - const localContext = { - email: new Email({ from: 'external@external.com', to: 'user@localserver.com', subject: 'Test', text: 'Test' }), - session: { - id: 'local-test', - remoteAddress: '203.0.113.2', - authenticated: false - } as any - }; - - const localRoute = await emailRouter.evaluateRoutes(localContext); - expect(localRoute?.name).toEqual('unauthenticated-local'); - - // Test unauthenticated rejection - const rejectContext = { - email: new Email({ from: 'external@external.com', to: 'user@external.com', subject: 'Test', text: 'Test' }), - session: { - id: 'reject-test', - remoteAddress: '203.0.113.3', - authenticated: false - } as any - }; - - const rejectRoute = await emailRouter.evaluateRoutes(rejectContext); - expect(rejectRoute?.name).toEqual('unauthenticated-reject'); - - console.log('✓ Authentication-based routing works correctly'); -}); - -tap.test('Email Integration - Pattern caching performance', async () => { - const routes: IEmailRoute[] = [ - { - name: 'complex-pattern', - match: { - recipients: ['*@domain1.com', '*@domain2.com', 'admin@*.domain3.com'], - senders: 'partner-*@*.partner.net' - }, - action: { type: 'forward', forward: { host: 'partner-relay.com', port: 25 } } - } - ]; - - const emailRouter = new EmailRouter(routes); - - const email = new Email({ - from: 'partner-sales@us.partner.net', - to: 'admin@sales.domain3.com', - subject: 'Test', - text: 'Test' - }); - - const context = { - email, - session: { id: 'perf-test', remoteAddress: '10.0.0.1' } as any - }; - - // First evaluation - should populate cache - const start1 = Date.now(); - const route1 = await emailRouter.evaluateRoutes(context); - const time1 = Date.now() - start1; - - // Second evaluation - should use cache - const start2 = Date.now(); - const route2 = await emailRouter.evaluateRoutes(context); - const time2 = Date.now() - start2; - - expect(route1?.name).toEqual('complex-pattern'); - expect(route2?.name).toEqual('complex-pattern'); - - // Cache should make second evaluation faster (though this is timing-dependent) - console.log(`✓ Pattern caching: First evaluation: ${time1}ms, Second: ${time2}ms`); -}); - -tap.test('Email Integration - Route update functionality', async () => { - const initialRoutes: IEmailRoute[] = [ - { - name: 'test-route', - match: { recipients: '*@test.com' }, - action: { type: 'deliver' } - } - ]; - - const emailRouter = new EmailRouter(initialRoutes); - - // Test initial configuration - expect(emailRouter.getRoutes().length).toEqual(1); - expect(emailRouter.getRoutes()[0].name).toEqual('test-route'); - - // Update routes - const newRoutes: IEmailRoute[] = [ - { - name: 'updated-route', - match: { recipients: '*@updated.com' }, - action: { type: 'forward', forward: { host: 'new-server.com', port: 25 } } - }, - { - name: 'additional-route', - match: { recipients: '*@additional.com' }, - action: { type: 'reject', reject: { code: 550, message: 'Blocked' } } - } - ]; - - emailRouter.updateRoutes(newRoutes); - - // Verify routes were updated - expect(emailRouter.getRoutes().length).toEqual(2); - expect(emailRouter.getRoutes()[0].name).toEqual('updated-route'); - expect(emailRouter.getRoutes()[1].name).toEqual('additional-route'); - - console.log('✓ Route update functionality works correctly'); -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.email.router.ts b/test/test.email.router.ts deleted file mode 100644 index e1d652e..0000000 --- a/test/test.email.router.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import { EmailRouter, type IEmailRoute, type IEmailContext } from '../ts/mail/routing/index.js'; -import { Email } from '../ts/mail/core/classes.email.js'; - -tap.test('EmailRouter - should create and manage routes', async () => { - const router = new EmailRouter([]); - - // Test initial state - expect(router.getRoutes()).toEqual([]); - - // Add some test routes - const routes: IEmailRoute[] = [ - { - name: 'forward-example', - priority: 10, - match: { - recipients: '*@example.com' - }, - action: { - type: 'forward', - forward: { - host: 'mail.example.com', - port: 25 - } - } - }, - { - name: 'reject-spam', - priority: 20, - match: { - senders: '*@spammer.com' - }, - action: { - type: 'reject', - reject: { - code: 550, - message: 'Spam not allowed' - } - } - } - ]; - - router.updateRoutes(routes); - expect(router.getRoutes().length).toEqual(2); -}); - -tap.test('EmailRouter - should evaluate routes based on priority', async () => { - const router = new EmailRouter([]); - - const routes: IEmailRoute[] = [ - { - name: 'low-priority', - priority: 5, - match: { - recipients: '*@test.com' - }, - action: { - type: 'deliver' - } - }, - { - name: 'high-priority', - priority: 10, - match: { - recipients: 'admin@test.com' - }, - action: { - type: 'process', - process: { - scan: true - } - } - } - ]; - - router.updateRoutes(routes); - - // Create test context - const email = new Email({ - from: 'sender@example.com', - to: 'admin@test.com', - subject: 'Test email', - text: 'Test email content' - }); - - const context: IEmailContext = { - email, - session: { - id: 'test-session', - remoteAddress: '192.168.1.1', - matchedRoute: null - } as any - }; - - const route = await router.evaluateRoutes(context); - expect(route).not.toEqual(null); - expect(route?.name).toEqual('high-priority'); -}); - -tap.test('EmailRouter - should match recipient patterns', async () => { - const router = new EmailRouter([]); - - const routes: IEmailRoute[] = [ - { - name: 'exact-match', - match: { - recipients: 'admin@example.com' - }, - action: { - type: 'forward', - forward: { - host: 'admin-server.com', - port: 25 - } - } - }, - { - name: 'wildcard-match', - match: { - recipients: '*@example.com' - }, - action: { - type: 'deliver' - } - } - ]; - - router.updateRoutes(routes); - - // Test exact match - const email1 = new Email({ - from: 'sender@test.com', - to: 'admin@example.com', - subject: 'Admin email', - text: 'Admin email content' - }); - - const context1: IEmailContext = { - email: email1, - session: { id: 'test1', remoteAddress: '10.0.0.1' } as any - }; - - const route1 = await router.evaluateRoutes(context1); - expect(route1?.name).toEqual('exact-match'); - - // Test wildcard match - const email2 = new Email({ - from: 'sender@test.com', - to: 'user@example.com', - subject: 'User email', - text: 'User email content' - }); - - const context2: IEmailContext = { - email: email2, - session: { id: 'test2', remoteAddress: '10.0.0.2' } as any - }; - - const route2 = await router.evaluateRoutes(context2); - expect(route2?.name).toEqual('wildcard-match'); -}); - -tap.test('EmailRouter - should match IP ranges with CIDR notation', async () => { - const router = new EmailRouter([]); - - const routes: IEmailRoute[] = [ - { - name: 'internal-network', - match: { - clientIp: '10.0.0.0/24' - }, - action: { - type: 'deliver' - } - }, - { - name: 'external-network', - match: { - clientIp: ['192.168.1.0/24', '172.16.0.0/16'] - }, - action: { - type: 'process', - process: { - scan: true - } - } - } - ]; - - router.updateRoutes(routes); - - // Test internal network match - const email = new Email({ - from: 'internal@company.com', - to: 'user@company.com', - subject: 'Internal email', - text: 'Internal email content' - }); - - const context1: IEmailContext = { - email, - session: { id: 'test1', remoteAddress: '10.0.0.15' } as any - }; - - const route1 = await router.evaluateRoutes(context1); - expect(route1?.name).toEqual('internal-network'); - - // Test external network match - const context2: IEmailContext = { - email, - session: { id: 'test2', remoteAddress: '192.168.1.100' } as any - }; - - const route2 = await router.evaluateRoutes(context2); - expect(route2?.name).toEqual('external-network'); -}); - -tap.test('EmailRouter - should handle authentication matching', async () => { - const router = new EmailRouter([]); - - const routes: IEmailRoute[] = [ - { - name: 'authenticated-users', - match: { - authenticated: true - }, - action: { - type: 'deliver' - } - }, - { - name: 'unauthenticated-users', - match: { - authenticated: false - }, - action: { - type: 'reject', - reject: { - code: 550, - message: 'Authentication required' - } - } - } - ]; - - router.updateRoutes(routes); - - const email = new Email({ - from: 'user@example.com', - to: 'recipient@test.com', - subject: 'Test', - text: 'Test content' - }); - - // Test authenticated session - const context1: IEmailContext = { - email, - session: { - id: 'test1', - remoteAddress: '10.0.0.1', - authenticated: true, - authenticatedUser: 'user@example.com' - } as any - }; - - const route1 = await router.evaluateRoutes(context1); - expect(route1?.name).toEqual('authenticated-users'); - - // Test unauthenticated session - const context2: IEmailContext = { - email, - session: { - id: 'test2', - remoteAddress: '10.0.0.2', - authenticated: false - } as any - }; - - const route2 = await router.evaluateRoutes(context2); - expect(route2?.name).toEqual('unauthenticated-users'); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.emailauth.ts b/test/test.emailauth.ts deleted file mode 100644 index dc015cd..0000000 --- a/test/test.emailauth.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mail/security/classes.spfverifier.js'; -import { DmarcVerifier, DmarcPolicy, DmarcAlignment } from '../ts/mail/security/classes.dmarcverifier.js'; -import { Email } from '../ts/mail/core/classes.email.js'; - -/** - * Test email authentication systems: SPF and DMARC - */ - -// SPF Verifier Tests -tap.test('SPF Verifier - should parse SPF record', async () => { - const spfVerifier = new SpfVerifier(); - - // Test valid SPF record parsing - const record = 'v=spf1 a mx ip4:192.168.0.1/24 include:example.org ~all'; - const parsedRecord = spfVerifier.parseSpfRecord(record); - - expect(parsedRecord).toBeTruthy(); - expect(parsedRecord.version).toEqual('spf1'); - expect(parsedRecord.mechanisms.length).toEqual(5); - - // Check specific mechanisms - expect(parsedRecord.mechanisms[0].type).toEqual(SpfMechanismType.A); - expect(parsedRecord.mechanisms[0].qualifier).toEqual(SpfQualifier.PASS); - - expect(parsedRecord.mechanisms[1].type).toEqual(SpfMechanismType.MX); - expect(parsedRecord.mechanisms[1].qualifier).toEqual(SpfQualifier.PASS); - - expect(parsedRecord.mechanisms[2].type).toEqual(SpfMechanismType.IP4); - expect(parsedRecord.mechanisms[2].value).toEqual('192.168.0.1/24'); - - expect(parsedRecord.mechanisms[3].type).toEqual(SpfMechanismType.INCLUDE); - expect(parsedRecord.mechanisms[3].value).toEqual('example.org'); - - expect(parsedRecord.mechanisms[4].type).toEqual(SpfMechanismType.ALL); - expect(parsedRecord.mechanisms[4].qualifier).toEqual(SpfQualifier.SOFTFAIL); - - // Test invalid record - const invalidRecord = 'not-a-spf-record'; - const invalidParsed = spfVerifier.parseSpfRecord(invalidRecord); - expect(invalidParsed).toBeNull(); -}); - -// DMARC Verifier Tests -tap.test('DMARC Verifier - should parse DMARC record', async () => { - const dmarcVerifier = new DmarcVerifier(); - - // Test valid DMARC record parsing - const record = 'v=DMARC1; p=reject; sp=quarantine; pct=50; adkim=s; aspf=r; rua=mailto:dmarc@example.com'; - const parsedRecord = dmarcVerifier.parseDmarcRecord(record); - - expect(parsedRecord).toBeTruthy(); - expect(parsedRecord.version).toEqual('DMARC1'); - expect(parsedRecord.policy).toEqual(DmarcPolicy.REJECT); - expect(parsedRecord.subdomainPolicy).toEqual(DmarcPolicy.QUARANTINE); - expect(parsedRecord.pct).toEqual(50); - expect(parsedRecord.adkim).toEqual(DmarcAlignment.STRICT); - expect(parsedRecord.aspf).toEqual(DmarcAlignment.RELAXED); - expect(parsedRecord.reportUriAggregate).toContain('dmarc@example.com'); - - // Test invalid record - const invalidRecord = 'not-a-dmarc-record'; - const invalidParsed = dmarcVerifier.parseDmarcRecord(invalidRecord); - expect(invalidParsed).toBeNull(); -}); - -tap.test('DMARC Verifier - should verify DMARC alignment', async () => { - const dmarcVerifier = new DmarcVerifier(); - - // Test email domains with DMARC alignment - const email = new Email({ - from: 'sender@example.com', - to: 'recipient@example.net', - subject: 'Test DMARC alignment', - text: 'This is a test email' - }); - - // Test when both SPF and DKIM pass with alignment - const dmarcResult = await dmarcVerifier.verify( - email, - { domain: 'example.com', result: true }, // SPF - aligned and passed - { domain: 'example.com', result: true } // DKIM - aligned and passed - ); - - expect(dmarcResult).toBeTruthy(); - expect(dmarcResult.spfPassed).toEqual(true); - expect(dmarcResult.dkimPassed).toEqual(true); - expect(dmarcResult.spfDomainAligned).toEqual(true); - expect(dmarcResult.dkimDomainAligned).toEqual(true); - expect(dmarcResult.action).toEqual('pass'); - - // Test when neither SPF nor DKIM is aligned - const dmarcResult2 = await dmarcVerifier.verify( - email, - { domain: 'differentdomain.com', result: true }, // SPF - passed but not aligned - { domain: 'anotherdomain.com', result: true } // DKIM - passed but not aligned - ); - - // Without a DNS manager, no DMARC record will be found - - expect(dmarcResult2).toBeTruthy(); - expect(dmarcResult2.spfPassed).toEqual(true); - expect(dmarcResult2.dkimPassed).toEqual(true); - expect(dmarcResult2.spfDomainAligned).toEqual(false); - expect(dmarcResult2.dkimDomainAligned).toEqual(false); - - // Without a DMARC record, the default action is 'pass' - expect(dmarcResult2.hasDmarc).toEqual(false); - expect(dmarcResult2.policyEvaluated).toEqual(DmarcPolicy.NONE); - expect(dmarcResult2.actualPolicy).toEqual(DmarcPolicy.NONE); - expect(dmarcResult2.action).toEqual('pass'); -}); - -tap.test('DMARC Verifier - should apply policy correctly', async () => { - const dmarcVerifier = new DmarcVerifier(); - - // Create test email - const email = new Email({ - from: 'sender@example.com', - to: 'recipient@example.net', - subject: 'Test DMARC policy application', - text: 'This is a test email' - }); - - // Test pass action - const passResult: any = { - hasDmarc: true, - spfDomainAligned: true, - dkimDomainAligned: true, - spfPassed: true, - dkimPassed: true, - policyEvaluated: DmarcPolicy.NONE, - actualPolicy: DmarcPolicy.NONE, - appliedPercentage: 100, - action: 'pass', - details: 'DMARC passed' - }; - - const passApplied = dmarcVerifier.applyPolicy(email, passResult); - expect(passApplied).toEqual(true); - expect(email.mightBeSpam).toEqual(false); - expect(email.headers['X-DMARC-Result']).toEqual('DMARC passed'); - - // Test quarantine action - const quarantineResult: any = { - hasDmarc: true, - spfDomainAligned: false, - dkimDomainAligned: false, - spfPassed: false, - dkimPassed: false, - policyEvaluated: DmarcPolicy.QUARANTINE, - actualPolicy: DmarcPolicy.QUARANTINE, - appliedPercentage: 100, - action: 'quarantine', - details: 'DMARC failed, policy=quarantine' - }; - - // Reset email spam flag - email.mightBeSpam = false; - email.headers = {}; - - const quarantineApplied = dmarcVerifier.applyPolicy(email, quarantineResult); - expect(quarantineApplied).toEqual(true); - expect(email.mightBeSpam).toEqual(true); - expect(email.headers['X-Spam-Flag']).toEqual('YES'); - expect(email.headers['X-DMARC-Result']).toEqual('DMARC failed, policy=quarantine'); - - // Test reject action - const rejectResult: any = { - hasDmarc: true, - spfDomainAligned: false, - dkimDomainAligned: false, - spfPassed: false, - dkimPassed: false, - policyEvaluated: DmarcPolicy.REJECT, - actualPolicy: DmarcPolicy.REJECT, - appliedPercentage: 100, - action: 'reject', - details: 'DMARC failed, policy=reject' - }; - - // Reset email spam flag - email.mightBeSpam = false; - email.headers = {}; - - const rejectApplied = dmarcVerifier.applyPolicy(email, rejectResult); - expect(rejectApplied).toEqual(false); - expect(email.mightBeSpam).toEqual(true); -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.errors.ts b/test/test.errors.ts index d5795c1..0cebd83 100644 --- a/test/test.errors.ts +++ b/test/test.errors.ts @@ -1,7 +1,7 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as errors from '../ts/errors/index.js'; import { - PlatformError, + PlatformError, ValidationError, NetworkError, ResourceError, @@ -12,19 +12,6 @@ import { ErrorCategory, ErrorRecoverability } from '../ts/errors/error.codes.js'; -import { - EmailServiceError, - EmailTemplateError, - EmailValidationError, - EmailSendError, - EmailReceiveError -} from '../ts/errors/email.errors.js'; -import { - MtaConnectionError, - MtaAuthenticationError, - MtaDeliveryError, - MtaConfigurationError -} from '../ts/errors/mta.errors.js'; import { ErrorHandler } from '../ts/errors/error-handler.js'; @@ -75,126 +62,6 @@ tap.test('Base error classes should set properties correctly', async () => { expect(resourceError.category).toEqual(ErrorCategory.RESOURCE); }); -// Test 7: Error withRetry() method -tap.test('PlatformError withRetry creates new instance with retry info', async () => { - const originalError = new EmailSendError('Send failed', { - data: { someData: true } - }); - - const retryError = originalError.withRetry(3, 1, 1000); - - // Verify it's a new instance - expect(retryError === originalError).toEqual(false); - expect(retryError).toBeInstanceOf(EmailSendError); - - // Verify original data is preserved - expect(retryError.context?.data?.someData).toEqual(true); - - // Verify retry info is added - expect(retryError.context?.retry?.maxRetries).toEqual(3); - expect(retryError.context?.retry?.currentRetry).toEqual(1); - expect(retryError.context?.retry?.retryDelay).toEqual(1000); - expect(retryError.context?.retry?.nextRetryAt).toBeTypeofNumber(); -}); - -// Test email error classes -tap.test('Email error classes should be properly constructed', async () => { - try { - // Test EmailServiceError - const emailServiceError = new EmailServiceError('Email service error', { - component: 'EmailService', - operation: 'sendEmail' - }); - expect(emailServiceError.code).toEqual('EMAIL_SERVICE_ERROR'); - expect(emailServiceError.name).toEqual('EmailServiceError'); - - // Test EmailTemplateError - const templateError = new EmailTemplateError('Template not found: welcome_email', { - data: { templateId: 'welcome_email' } - }); - expect(templateError.code).toEqual('EMAIL_TEMPLATE_ERROR'); - expect(templateError.context.data?.templateId).toEqual('welcome_email'); - - // Test EmailSendError with permanent flag - const permanentError = EmailSendError.permanent( - 'Invalid recipient: user@example.com', - { data: { details: 'DNS not found', recipient: 'user@example.com' } } - ); - expect(permanentError.code).toEqual('EMAIL_SEND_ERROR'); - expect(permanentError.isPermanent()).toEqual(true); - expect(permanentError.context.data?.permanent).toEqual(true); - - // Test EmailSendError with temporary flag and retry - const tempError = EmailSendError.temporary( - 'Server busy', - 3, - 0, - 1000, - { data: { server: 'smtp.example.com' } } - ); - expect(tempError.isPermanent()).toEqual(false); - expect(tempError.context.data?.permanent).toEqual(false); - expect(tempError.context.retry?.maxRetries).toEqual(3); - expect(tempError.shouldRetry()).toEqual(true); - } catch (error) { - console.error('Test failed with error:', error); - throw error; - } -}); - -// Test MTA error classes -tap.test('MTA error classes should be properly constructed', async () => { - try { - // Test MtaConnectionError - const dnsError = MtaConnectionError.dnsError('mail.example.com', new Error('DNS lookup failed')); - expect(dnsError.code).toEqual('MTA_CONNECTION_ERROR'); - expect(dnsError.category).toEqual(ErrorCategory.CONNECTIVITY); - expect(dnsError.context.data?.hostname).toEqual('mail.example.com'); - - // Test MtaTimeoutError via MtaConnectionError.timeout - const timeoutError = MtaConnectionError.timeout('mail.example.com', 25, 30000); - expect(timeoutError.code).toEqual('MTA_CONNECTION_ERROR'); - expect(timeoutError.context.data?.timeout).toEqual(30000); - - // Test MtaAuthenticationError - const authError = MtaAuthenticationError.invalidCredentials('mail.example.com', 'user@example.com'); - expect(authError.code).toEqual('MTA_AUTHENTICATION_ERROR'); - expect(authError.category).toEqual(ErrorCategory.AUTHENTICATION); - expect(authError.context.data?.username).toEqual('user@example.com'); - - // Test MtaDeliveryError - const permDeliveryError = MtaDeliveryError.permanent( - 'User unknown', - 'nonexistent@example.com', - '550', - '550 5.1.1 User unknown', - {} - ); - expect(permDeliveryError.code).toEqual('MTA_DELIVERY_ERROR'); - expect(permDeliveryError.isPermanent()).toEqual(true); - expect(permDeliveryError.getRecipientAddress()).toEqual('nonexistent@example.com'); - expect(permDeliveryError.getStatusCode()).toEqual('550'); - - // Test temporary delivery error with retry - const tempDeliveryError = MtaDeliveryError.temporary( - 'Mailbox temporarily unavailable', - 'user@example.com', - '450', - '450 4.2.1 Mailbox temporarily unavailable', - 3, - 1, - 5000 - ); - expect(tempDeliveryError.isPermanent()).toEqual(false); - expect(tempDeliveryError.shouldRetry()).toEqual(true); - expect(tempDeliveryError.context.retry?.currentRetry).toEqual(1); - expect(tempDeliveryError.context.retry?.maxRetries).toEqual(3); - } catch (error) { - console.error('MTA test failed with error:', error); - throw error; - } -}); - // Test error handler utility tap.test('ErrorHandler should properly handle and format errors', async () => { // Configure error handler @@ -291,7 +158,6 @@ tap.test('ErrorHandler should properly handle and format errors', async () => { // Test retry utilities tap.test('Error retry utilities should work correctly', async () => { let attempts = 0; - const start = Date.now(); try { await errors.retry( @@ -383,7 +249,7 @@ tap.test('Error handling can be combined with retry for robust operations', asyn // Reset and test failure case flaky.reset(); - + try { await errors.retry( () => flaky(5, 'never reached'), @@ -405,4 +271,4 @@ tap.test('stop', async () => { await tap.stopForcefully(); }); -export default tap.start(); \ No newline at end of file +export default tap.start(); diff --git a/test/test.integration.storage.ts b/test/test.integration.storage.ts deleted file mode 100644 index 3d7f25a..0000000 --- a/test/test.integration.storage.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../ts/plugins.js'; -import * as paths from '../ts/paths.js'; -import { StorageManager } from '../ts/storage/classes.storagemanager.js'; -import { DKIMCreator } from '../ts/mail/security/classes.dkimcreator.js'; -import { BounceManager } from '../ts/mail/core/classes.bouncemanager.js'; -import { EmailRouter } from '../ts/mail/routing/classes.email.router.js'; -import type { IEmailRoute } from '../ts/mail/routing/interfaces.js'; - -tap.test('Storage Persistence Across Restarts', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-integration-persistence'); - - // Phase 1: Create storage and write data - { - const storage = new StorageManager({ fsPath: testDir }); - - // Write some test data - await storage.set('/test/key1', 'value1'); - await storage.setJSON('/test/json', { data: 'test', count: 42 }); - await storage.set('/other/key2', 'value2'); - } - - // Phase 2: Create new instance and verify data persists - { - const storage = new StorageManager({ fsPath: testDir }); - - // Verify data persists - const value1 = await storage.get('/test/key1'); - expect(value1).toEqual('value1'); - - const jsonData = await storage.getJSON('/test/json'); - expect(jsonData).toEqual({ data: 'test', count: 42 }); - - const value2 = await storage.get('/other/key2'); - expect(value2).toEqual('value2'); - - // Test list operation - const testKeys = await storage.list('/test'); - expect(testKeys.length).toEqual(2); - expect(testKeys).toContain('/test/key1'); - expect(testKeys).toContain('/test/json'); - } - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -tap.test('DKIM Storage Integration', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-integration-dkim'); - const keysDir = plugins.path.join(testDir, 'keys'); - - // Ensure the keys directory exists before running the test - await plugins.fs.promises.mkdir(keysDir, { recursive: true }); - - // Phase 1: Generate DKIM keys with storage - { - const storage = new StorageManager({ fsPath: testDir }); - const dkimCreator = new DKIMCreator(keysDir, storage); - - await dkimCreator.handleDKIMKeysForDomain('storage.example.com'); - - // Verify keys exist - const keys = await dkimCreator.readDKIMKeys('storage.example.com'); - expect(keys.privateKey).toBeTruthy(); - expect(keys.publicKey).toBeTruthy(); - } - - // Phase 2: New instance should find keys in storage - { - const storage = new StorageManager({ fsPath: testDir }); - const dkimCreator = new DKIMCreator(keysDir, storage); - - // Keys should be loaded from storage - const keys = await dkimCreator.readDKIMKeys('storage.example.com'); - expect(keys.privateKey).toBeTruthy(); - expect(keys.publicKey).toBeTruthy(); - expect(keys.privateKey).toContain('BEGIN RSA PRIVATE KEY'); - } - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -tap.test('Bounce Manager Storage Integration', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-integration-bounce'); - - // Phase 1: Add to suppression list with storage - { - const storage = new StorageManager({ fsPath: testDir }); - const bounceManager = new BounceManager({ - storageManager: storage - }); - - // Wait for constructor's async loadSuppressionList to complete - await new Promise(resolve => setTimeout(resolve, 200)); - - // Add emails to suppression list - bounceManager.addToSuppressionList('bounce1@example.com', 'Hard bounce: invalid_recipient'); - bounceManager.addToSuppressionList('bounce2@example.com', 'Soft bounce: temporary', Date.now() + 3600000); - - // Verify suppression - expect(bounceManager.isEmailSuppressed('bounce1@example.com')).toEqual(true); - expect(bounceManager.isEmailSuppressed('bounce2@example.com')).toEqual(true); - - // Wait for async save to complete (addToSuppressionList saves asynchronously) - await new Promise(resolve => setTimeout(resolve, 500)); - } - - // Phase 2: New instance should load suppression list from storage - { - const storage = new StorageManager({ fsPath: testDir }); - const bounceManager = new BounceManager({ - storageManager: storage - }); - - // Wait for async load to complete - await new Promise(resolve => setTimeout(resolve, 500)); - - // Verify persistence - expect(bounceManager.isEmailSuppressed('bounce1@example.com')).toEqual(true); - expect(bounceManager.isEmailSuppressed('bounce2@example.com')).toEqual(true); - expect(bounceManager.isEmailSuppressed('notbounced@example.com')).toEqual(false); - - // Check suppression info - const info1 = bounceManager.getSuppressionInfo('bounce1@example.com'); - expect(info1).toBeTruthy(); - expect(info1?.reason).toContain('Hard bounce'); - expect(info1?.expiresAt).toBeUndefined(); // Permanent - - const info2 = bounceManager.getSuppressionInfo('bounce2@example.com'); - expect(info2).toBeTruthy(); - expect(info2?.reason).toContain('Soft bounce'); - expect(info2?.expiresAt).toBeGreaterThan(Date.now()); - } - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -tap.test('Email Router Storage Integration', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-integration-router'); - - const testRoutes: IEmailRoute[] = [ - { - name: 'test-route-1', - match: { recipients: '*@test.com' }, - action: { type: 'forward', forward: { host: 'test.server.com', port: 25 } }, - priority: 100 - }, - { - name: 'test-route-2', - match: { senders: '*@internal.com' }, - action: { type: 'process', process: { scan: true, dkim: true } }, - priority: 50 - } - ]; - - // Phase 1: Save routes with storage - { - const storage = new StorageManager({ fsPath: testDir }); - const router = new EmailRouter([], { - storageManager: storage, - persistChanges: true - }); - - // Add routes - await router.addRoute(testRoutes[0]); - await router.addRoute(testRoutes[1]); - - // Verify routes - const routes = router.getRoutes(); - expect(routes.length).toEqual(2); - expect(routes[0].name).toEqual('test-route-1'); // Higher priority first - expect(routes[1].name).toEqual('test-route-2'); - } - - // Phase 2: New instance should load routes from storage - { - const storage = new StorageManager({ fsPath: testDir }); - const router = new EmailRouter([], { - storageManager: storage, - persistChanges: true - }); - - // Wait for async load - await new Promise(resolve => setTimeout(resolve, 100)); - - // Manually load routes (since constructor load is fire-and-forget) - await router.loadRoutes({ replace: true }); - - // Verify persistence - const routes = router.getRoutes(); - expect(routes.length).toEqual(2); - expect(routes[0].name).toEqual('test-route-1'); - expect(routes[0].priority).toEqual(100); - expect(routes[1].name).toEqual('test-route-2'); - expect(routes[1].priority).toEqual(50); - - // Test route retrieval - const route1 = router.getRoute('test-route-1'); - expect(route1).toBeTruthy(); - expect(route1?.match.recipients).toEqual('*@test.com'); - } - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -tap.test('Storage Backend Switching', async () => { - const testDir = plugins.path.join(paths.dataDir, '.test-integration-switching'); - const testData = { key: 'value', nested: { data: true } }; - - // Phase 1: Start with memory storage - const memoryStore = new Map(); - { - const storage = new StorageManager(); // Memory backend - await storage.setJSON('/switch/test', testData); - - // Verify it's in memory - expect(storage.getBackend()).toEqual('memory'); - } - - // Phase 2: Switch to custom backend - { - const storage = new StorageManager({ - readFunction: async (key) => memoryStore.get(key) || null, - writeFunction: async (key, value) => { memoryStore.set(key, value); } - }); - - // Write data - await storage.setJSON('/switch/test', testData); - - // Verify backend - expect(storage.getBackend()).toEqual('custom'); - expect(memoryStore.has('/switch/test')).toEqual(true); - } - - // Phase 3: Switch to filesystem - { - const storage = new StorageManager({ fsPath: testDir }); - - // Migrate data from custom backend - const dataStr = memoryStore.get('/switch/test'); - if (dataStr) { - await storage.set('/switch/test', dataStr); - } - - // Verify data migrated - const data = await storage.getJSON('/switch/test'); - expect(data).toEqual(testData); - expect(storage.getBackend()).toEqual('filesystem'); // fsPath is now properly reported as filesystem - } - - // Clean up - await plugins.fs.promises.rm(testDir, { recursive: true, force: true }).catch(() => {}); -}); - -tap.test('Data Migration Between Backends', async () => { - const testDir1 = plugins.path.join(paths.dataDir, '.test-migration-source'); - const testDir2 = plugins.path.join(paths.dataDir, '.test-migration-dest'); - - // Create test data structure - const testData = { - '/config/app': JSON.stringify({ name: 'test-app', version: '1.0.0' }), - '/config/database': JSON.stringify({ host: 'localhost', port: 5432 }), - '/data/users/1': JSON.stringify({ id: 1, name: 'User One' }), - '/data/users/2': JSON.stringify({ id: 2, name: 'User Two' }), - '/logs/app.log': 'Log entry 1\nLog entry 2\nLog entry 3' - }; - - // Phase 1: Populate source storage - { - const source = new StorageManager({ fsPath: testDir1 }); - - for (const [key, value] of Object.entries(testData)) { - await source.set(key, value); - } - - // Verify data written - const keys = await source.list('/'); - expect(keys.length).toBeGreaterThanOrEqual(5); - } - - // Phase 2: Migrate to destination - { - const source = new StorageManager({ fsPath: testDir1 }); - const dest = new StorageManager({ fsPath: testDir2 }); - - // List all keys from source - const allKeys = await source.list('/'); - - // Migrate each key - for (const key of allKeys) { - const value = await source.get(key); - if (value !== null) { - await dest.set(key, value); - } - } - - // Verify migration - for (const [key, expectedValue] of Object.entries(testData)) { - const value = await dest.get(key); - expect(value).toEqual(expectedValue); - } - - // Verify structure preserved - const configKeys = await dest.list('/config'); - expect(configKeys.length).toEqual(2); - - const userKeys = await dest.list('/data/users'); - expect(userKeys.length).toEqual(2); - } - - // Clean up - await plugins.fs.promises.rm(testDir1, { recursive: true, force: true }).catch(() => {}); - await plugins.fs.promises.rm(testDir2, { recursive: true, force: true }).catch(() => {}); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.integration.ts b/test/test.integration.ts deleted file mode 100644 index 79f6085..0000000 --- a/test/test.integration.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../ts/plugins.js'; -// SzPlatformService doesn't exist in codebase - using DcRouter instead for integration tests -import DcRouter from '../ts/classes.dcrouter.js'; -import { BounceManager } from '../ts/mail/core/classes.bouncemanager.js'; -import { smtpClientMod } from '../ts/mail/delivery/index.js'; -import { SmtpServer } from '../ts/mail/delivery/smtpserver/smtp-server.js'; - -// Test the new integration architecture -tap.test('should be able to create an SMTP server', async (tools) => { - // Create an SMTP server - const smtpServer = new SmtpServer({ - options: { - port: 10025, - hostname: 'test.example.com', - key: '', - cert: '' - } - }); - - // Verify it was created properly - expect(smtpServer).toBeTruthy(); - expect(smtpServer.options.port).toEqual(10025); - expect(smtpServer.options.hostname).toEqual('test.example.com'); -}); - - -tap.test('DcRouter should support email configuration', async (tools) => { - // Create a DcRouter with email config - const dcRouter = new DcRouter({ - emailConfig: { - useEmail: true, - domainRules: [{ - // name: 'test-rule', // not part of IDomainRule - match: { - senderPattern: '.*@test.com', - }, - actions: [] - }] - } - }); - - // Verify it was created properly - expect(dcRouter).toBeTruthy(); -}); - -tap.test('SMTP client should be able to connect to SMTP server', async (tools) => { - // Create an SMTP client - const options = { - host: 'smtp.test.com', - port: 587, - secure: false, - auth: { - user: 'test@example.com', - pass: 'testpass' - }, - connectionTimeout: 5000, - socketTimeout: 5000 - }; - - const smtpClient = smtpClientMod.createSmtpClient(options); - - // Verify it was created properly - expect(smtpClient).toBeTruthy(); - // Since options are not exposed, just verify the client was created - expect(typeof smtpClient.sendMail).toEqual('function'); - expect(typeof smtpClient.getPoolStatus).toEqual('function'); -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -// Export for tapbundle execution -export default tap.start(); \ No newline at end of file diff --git a/test/test.ipwarmupmanager.ts b/test/test.ipwarmupmanager.ts deleted file mode 100644 index 0e51362..0000000 --- a/test/test.ipwarmupmanager.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../ts/plugins.js'; -import * as paths from '../ts/paths.js'; -import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.js'; - -// Cleanup any temporary test data -const cleanupTestData = () => { - const warmupDataPath = plugins.path.join(paths.dataDir, 'warmup'); - if (plugins.fs.existsSync(warmupDataPath)) { - // Remove the directory recursively using fs instead of smartfile - plugins.fs.rmSync(warmupDataPath, { recursive: true, force: true }); - } -}; - -// Helper to reset the singleton instance between tests -const resetSingleton = () => { - // @ts-ignore - accessing private static field for testing - IPWarmupManager.instance = null; -}; - -// Before running any tests -tap.test('setup', async () => { - cleanupTestData(); -}); - -// Test initialization of IPWarmupManager -tap.test('should initialize IPWarmupManager with default settings', async () => { - resetSingleton(); - const ipWarmupManager = IPWarmupManager.getInstance(); - - expect(ipWarmupManager).toBeTruthy(); - expect(typeof ipWarmupManager.getBestIPForSending).toEqual('function'); - expect(typeof ipWarmupManager.canSendMoreToday).toEqual('function'); - expect(typeof ipWarmupManager.getStageCount).toEqual('function'); - expect(typeof ipWarmupManager.setActiveAllocationPolicy).toEqual('function'); -}); - -// Test initialization with custom settings -tap.test('should initialize IPWarmupManager with custom settings', async () => { - resetSingleton(); - const ipWarmupManager = IPWarmupManager.getInstance({ - enabled: true, - ipAddresses: ['192.168.1.1', '192.168.1.2'], - targetDomains: ['example.com', 'test.com'], - fallbackPercentage: 75 - }); - - // Test setting allocation policy - ipWarmupManager.setActiveAllocationPolicy('roundRobin'); - - // Get best IP for sending - const bestIP = ipWarmupManager.getBestIPForSending({ - from: 'test@example.com', - to: ['recipient@test.com'], - domain: 'example.com' - }); - - // Check if we can send more today - const canSendMore = ipWarmupManager.canSendMoreToday('192.168.1.1'); - - // Check stage count - const stageCount = ipWarmupManager.getStageCount(); - expect(typeof stageCount).toEqual('number'); -}); - -// Test IP allocation policies -tap.test('should allocate IPs using balanced policy', async () => { - resetSingleton(); - const ipWarmupManager = IPWarmupManager.getInstance({ - enabled: true, - ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'], - targetDomains: ['example.com', 'test.com'] - // Remove allocationPolicy which is not in the interface - }); - - ipWarmupManager.setActiveAllocationPolicy('balanced'); - - // Use getBestIPForSending multiple times and check if all IPs are used - const usedIPs = new Set(); - for (let i = 0; i < 30; i++) { - const ip = ipWarmupManager.getBestIPForSending({ - from: 'test@example.com', - to: ['recipient@test.com'], - domain: 'example.com' - }); - if (ip) usedIPs.add(ip); - } - - // We should use at least 2 different IPs with balanced policy - expect(usedIPs.size >= 2).toEqual(true); -}); - -// Test round robin allocation policy -tap.test('should allocate IPs using round robin policy', async () => { - resetSingleton(); - const ipWarmupManager = IPWarmupManager.getInstance({ - enabled: true, - ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'], - targetDomains: ['example.com', 'test.com'] - // Remove allocationPolicy which is not in the interface - }); - - ipWarmupManager.setActiveAllocationPolicy('roundRobin'); - - // First few IPs should rotate through the available IPs - const firstIP = ipWarmupManager.getBestIPForSending({ - from: 'test@example.com', - to: ['recipient@test.com'], - domain: 'example.com' - }); - - const secondIP = ipWarmupManager.getBestIPForSending({ - from: 'test@example.com', - to: ['recipient@test.com'], - domain: 'example.com' - }); - - const thirdIP = ipWarmupManager.getBestIPForSending({ - from: 'test@example.com', - to: ['recipient@test.com'], - domain: 'example.com' - }); - - // Round robin should give us different IPs for consecutive calls - expect(firstIP !== secondIP).toEqual(true); - - // With 3 IPs, the fourth call should cycle back to one of the IPs - const fourthIP = ipWarmupManager.getBestIPForSending({ - from: 'test@example.com', - to: ['recipient@test.com'], - domain: 'example.com' - }); - - // Check that the fourth IP is one of the 3 valid IPs - expect(['192.168.1.1', '192.168.1.2', '192.168.1.3'].includes(fourthIP)).toEqual(true); -}); - -// Test dedicated domain allocation policy -tap.test('should allocate IPs using dedicated domain policy', async () => { - resetSingleton(); - const ipWarmupManager = IPWarmupManager.getInstance({ - enabled: true, - ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'], - targetDomains: ['example.com', 'test.com', 'other.com'] - // Remove allocationPolicy which is not in the interface - }); - - ipWarmupManager.setActiveAllocationPolicy('dedicated'); - - // Instead of mapDomainToIP which doesn't exist, we'll simulate domain mapping - // by making dedicated calls per domain - we can't call the internal method directly - - // Each domain should get its dedicated IP - const exampleIP = ipWarmupManager.getBestIPForSending({ - from: 'test@example.com', - to: ['recipient@gmail.com'], - domain: 'example.com' - }); - - const testIP = ipWarmupManager.getBestIPForSending({ - from: 'test@test.com', - to: ['recipient@gmail.com'], - domain: 'test.com' - }); - - const otherIP = ipWarmupManager.getBestIPForSending({ - from: 'test@other.com', - to: ['recipient@gmail.com'], - domain: 'other.com' - }); - - // Since we're not actually mapping domains to IPs, we can only test if they return valid IPs - // The original assertions have been modified since we can't guarantee which IP will be returned - expect(exampleIP).toBeTruthy(); - expect(testIP).toBeTruthy(); - expect(otherIP).toBeTruthy(); -}); - -// Test daily sending limits -tap.test('should enforce daily sending limits', async () => { - resetSingleton(); - const ipWarmupManager = IPWarmupManager.getInstance({ - enabled: true, - ipAddresses: ['192.168.1.1'], - targetDomains: ['example.com'] - // Remove allocationPolicy which is not in the interface - }); - - // Override the warmup stage for testing - // @ts-ignore - accessing private method for testing - ipWarmupManager.warmupStatuses.set('192.168.1.1', { - ipAddress: '192.168.1.1', - isActive: true, - currentStage: 1, - startDate: new Date(), - currentStageStartDate: new Date(), - targetCompletionDate: new Date(), - currentDailyAllocation: 5, - sentInCurrentStage: 0, - totalSent: 0, - dailyStats: [], - metrics: { - openRate: 0, - bounceRate: 0, - complaintRate: 0 - } - }); - - // Set a very low daily limit for testing - // @ts-ignore - accessing private method for testing - ipWarmupManager.config.stages = [ - { stage: 1, maxDailyVolume: 5, durationDays: 5, targetMetrics: { maxBounceRate: 8, minOpenRate: 15 } } - ]; - - // First pass: should be able to get an IP - const ip = ipWarmupManager.getBestIPForSending({ - from: 'test@example.com', - to: ['recipient@test.com'], - domain: 'example.com' - }); - - expect(ip === '192.168.1.1').toEqual(true); - - // Record 5 sends to reach the daily limit - for (let i = 0; i < 5; i++) { - ipWarmupManager.recordSend('192.168.1.1'); - } - - // Check if we can send more today - const canSendMore = ipWarmupManager.canSendMoreToday('192.168.1.1'); - expect(canSendMore).toEqual(false); - - // After reaching limit, getBestIPForSending should return null - // since there are no available IPs - const sixthIP = ipWarmupManager.getBestIPForSending({ - from: 'test@example.com', - to: ['recipient@test.com'], - domain: 'example.com' - }); - - expect(sixthIP === null).toEqual(true); -}); - -// Test recording sends -tap.test('should record send events correctly', async () => { - resetSingleton(); - const ipWarmupManager = IPWarmupManager.getInstance({ - enabled: true, - ipAddresses: ['192.168.1.1', '192.168.1.2'], - targetDomains: ['example.com'], - }); - - // Set allocation policy - ipWarmupManager.setActiveAllocationPolicy('balanced'); - - // Get an IP for sending - const ip = ipWarmupManager.getBestIPForSending({ - from: 'test@example.com', - to: ['recipient@test.com'], - domain: 'example.com' - }); - - // If we got an IP, record some sends - if (ip) { - // Record a few sends - for (let i = 0; i < 5; i++) { - ipWarmupManager.recordSend(ip); - } - - // Check if we can still send more - const canSendMore = ipWarmupManager.canSendMoreToday(ip); - expect(typeof canSendMore).toEqual('boolean'); - } -}); - -// Test that DedicatedDomainPolicy assigns IPs correctly -tap.test('should assign IPs using dedicated domain policy', async () => { - resetSingleton(); - const ipWarmupManager = IPWarmupManager.getInstance({ - enabled: true, - ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'], - targetDomains: ['example.com', 'test.com', 'other.com'] - }); - - // Set allocation policy to dedicated domains - ipWarmupManager.setActiveAllocationPolicy('dedicated'); - - // Check allocation by querying for different domains - const ip1 = ipWarmupManager.getBestIPForSending({ - from: 'test@example.com', - to: ['recipient@test.com'], - domain: 'example.com' - }); - - const ip2 = ipWarmupManager.getBestIPForSending({ - from: 'test@test.com', - to: ['recipient@test.com'], - domain: 'test.com' - }); - - // If we got IPs, they should be consistently assigned - if (ip1 && ip2) { - // Requesting the same domain again should return the same IP - const ip1again = ipWarmupManager.getBestIPForSending({ - from: 'another@example.com', - to: ['recipient@test.com'], - domain: 'example.com' - }); - - expect(ip1again === ip1).toEqual(true); - } -}); - -// After all tests, clean up -tap.test('cleanup', async () => { - cleanupTestData(); -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.minimal.ts b/test/test.minimal.ts deleted file mode 100644 index bdebfb5..0000000 --- a/test/test.minimal.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../ts/plugins.js'; -import * as paths from '../ts/paths.js'; -import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.js'; -import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.js'; - -/** - * Basic test to check if our integrated classes work correctly - */ -tap.test('verify that SenderReputationMonitor and IPWarmupManager are functioning', async (tools) => { - // Create instances of both classes - const reputationMonitor = SenderReputationMonitor.getInstance({ - enabled: true, - domains: ['example.com'] - }); - - const ipWarmupManager = IPWarmupManager.getInstance({ - enabled: true, - ipAddresses: ['192.168.1.1', '192.168.1.2'], - targetDomains: ['example.com'] - }); - - // Test SenderReputationMonitor - reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 }); - reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 }); - - const reputationData = reputationMonitor.getReputationData('example.com'); - const summary = reputationMonitor.getReputationSummary(); - - // Basic checks - expect(reputationData).toBeTruthy(); - expect(summary.length).toBeGreaterThan(0); - - // Add and remove domains - reputationMonitor.addDomain('test.com'); - reputationMonitor.removeDomain('test.com'); - - // Test IPWarmupManager - ipWarmupManager.setActiveAllocationPolicy('balanced'); - - const bestIP = ipWarmupManager.getBestIPForSending({ - from: 'test@example.com', - to: ['recipient@test.com'], - domain: 'example.com' - }); - - if (bestIP) { - ipWarmupManager.recordSend(bestIP); - const canSendMore = ipWarmupManager.canSendMoreToday(bestIP); - expect(canSendMore !== undefined).toEqual(true); - } - - const stageCount = ipWarmupManager.getStageCount(); - expect(stageCount).toBeGreaterThan(0); -}); - -// Final clean-up test -tap.test('clean up after tests', async () => { - // No-op - just to make sure everything is cleaned up properly -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.rate-limiting-integration.ts b/test/test.rate-limiting-integration.ts deleted file mode 100644 index b86a00e..0000000 --- a/test/test.rate-limiting-integration.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as plugins from './helpers/server.loader.js'; -import type { ITestServer } from './helpers/server.loader.js'; -import { createTestSmtpClient, sendTestEmail } from './helpers/smtp.client.js'; -import { SmtpClient } from '../ts/mail/delivery/smtpclient/smtp-client.js'; - -const TEST_PORT = 2525; - -// Store the test server reference for cleanup -let testServer: ITestServer | null = null; - -// Test email configuration with rate limits -const testEmailConfig = { - ports: [TEST_PORT], - hostname: 'localhost', - domains: [ - { - domain: 'test.local', - dnsMode: 'forward' as const, - rateLimits: { - inbound: { - messagesPerMinute: 3, // Very low limit for testing - recipientsPerMessage: 2, - connectionsPerIp: 5 - } - } - } - ], - routes: [ - { - name: 'test-route', - match: { recipients: '*@test.local' }, - action: { type: 'process' as const, process: { scan: false, queue: 'normal' } } - } - ], - rateLimits: { - global: { - maxMessagesPerMinute: 10, - maxConnectionsPerIP: 10, - maxErrorsPerIP: 3, - maxAuthFailuresPerIP: 2, - blockDuration: 5000 // 5 seconds for testing - } - } -}; - -tap.test('prepare server with rate limiting', async () => { - testServer = await plugins.startTestServer({ - port: TEST_PORT, - hostname: 'localhost' - }); - // Give server time to start - await new Promise(resolve => setTimeout(resolve, 1000)); -}); - -tap.test('should enforce connection rate limits', async () => { - const clients: SmtpClient[] = []; - let successCount = 0; - let failCount = 0; - - try { - // Try to create many connections quickly - for (let i = 0; i < 12; i++) { - const client = createTestSmtpClient(); - clients.push(client); - - // Connection should fail after limit is exceeded - const verified = await client.verify().catch(() => false); - - if (verified) { - successCount++; - } else { - failCount++; - } - } - - // With global limit of 10 connections per IP, we expect most to succeed - // Rate limiting behavior may vary based on implementation timing - // At minimum, verify that connections are being made - expect(successCount).toBeGreaterThan(0); - console.log(`Connection test: ${successCount} succeeded, ${failCount} failed`); - } finally { - // Clean up connections - for (const client of clients) { - await client.close().catch(() => {}); - } - } -}); - -tap.test('should enforce message rate limits per domain', async () => { - const client = createTestSmtpClient(); - let acceptedCount = 0; - let rejectedCount = 0; - - try { - // Send messages rapidly to test domain-specific rate limit - for (let i = 0; i < 5; i++) { - const result = await sendTestEmail(client, { - from: `sender${i}@example.com`, - to: 'recipient@test.local', - subject: `Test ${i}`, - text: 'Test message' - }).catch(err => err); - - if (result && result.accepted && result.accepted.length > 0) { - acceptedCount++; - } else if (result && result.code) { - rejectedCount++; - } else { - // Count successful sends that don't have explicit accepted array - acceptedCount++; - } - } - - // Verify that messages were processed - rate limiting may or may not kick in - // depending on timing and server implementation - console.log(`Message rate test: ${acceptedCount} accepted, ${rejectedCount} rejected`); - expect(acceptedCount + rejectedCount).toBeGreaterThan(0); - } finally { - await client.close(); - } -}); - -tap.test('should enforce recipient limits', async () => { - const client = createTestSmtpClient(); - - try { - // Try to send to many recipients (domain limit is 2 per message) - const result = await sendTestEmail(client, { - from: 'sender@example.com', - to: ['user1@test.local', 'user2@test.local', 'user3@test.local'], - subject: 'Test with multiple recipients', - text: 'Test message' - }).catch(err => err); - - // The server may either: - // 1. Reject with EENVELOPE if recipient limit is strictly enforced - // 2. Accept some/all recipients if limits are per-recipient rather than per-message - // 3. Accept the message if recipient limits aren't enforced at SMTP level - if (result && result.code === 'EENVELOPE') { - console.log('Recipient limit enforced: message rejected'); - expect(result.code).toEqual('EENVELOPE'); - } else if (result && result.accepted) { - console.log(`Recipient limit: ${result.accepted.length} of 3 recipients accepted`); - expect(result.accepted.length).toBeGreaterThan(0); - } else { - // Some other result (success or error) - console.log('Recipient test result:', result); - expect(result).toBeDefined(); - } - } finally { - await client.close(); - } -}); - -tap.test('should enforce error rate limits', async () => { - // This test verifies that the server tracks error rates - // The actual enforcement depends on server implementation - // For now, we just verify the configuration is accepted - console.log('Error rate limit configured: maxErrorsPerIP = 3'); - console.log('Error rate limiting is configured in the server'); - - // The server should track errors per IP and block after threshold - // This is tested indirectly through the server configuration - expect(testEmailConfig.rateLimits.global.maxErrorsPerIP).toEqual(3); -}); - -tap.test('should enforce authentication failure limits', async () => { - // This test verifies that authentication failure limits are configured - // The actual enforcement depends on server implementation - console.log('Auth failure limit configured: maxAuthFailuresPerIP = 2'); - console.log('Authentication failure limiting is configured in the server'); - - // The server should track auth failures per IP and block after threshold - // This is tested indirectly through the server configuration - expect(testEmailConfig.rateLimits.global.maxAuthFailuresPerIP).toEqual(2); -}); - -tap.test('cleanup server', async () => { - if (testServer) { - await plugins.stopTestServer(testServer); - testServer = null; - } -}); - -export default tap.start(); diff --git a/test/test.ratelimiter.ts b/test/test.ratelimiter.ts deleted file mode 100644 index 1725d29..0000000 --- a/test/test.ratelimiter.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { RateLimiter } from '../ts/mail/delivery/classes.ratelimiter.js'; - -tap.test('RateLimiter - should be instantiable', async () => { - const limiter = new RateLimiter({ - maxPerPeriod: 10, - periodMs: 1000, - perKey: true - }); - - expect(limiter).toBeTruthy(); -}); - -tap.test('RateLimiter - should allow requests within rate limit', async () => { - const limiter = new RateLimiter({ - maxPerPeriod: 5, - periodMs: 1000, - perKey: true - }); - - // Should allow 5 requests - for (let i = 0; i < 5; i++) { - expect(limiter.isAllowed('test')).toEqual(true); - } - - // 6th request should be denied - expect(limiter.isAllowed('test')).toEqual(false); -}); - -tap.test('RateLimiter - should enforce per-key limits', async () => { - const limiter = new RateLimiter({ - maxPerPeriod: 3, - periodMs: 1000, - perKey: true - }); - - // Should allow 3 requests for key1 - for (let i = 0; i < 3; i++) { - expect(limiter.isAllowed('key1')).toEqual(true); - } - - // 4th request for key1 should be denied - expect(limiter.isAllowed('key1')).toEqual(false); - - // But key2 should still be allowed - expect(limiter.isAllowed('key2')).toEqual(true); -}); - -tap.test('RateLimiter - should refill tokens over time', async () => { - const limiter = new RateLimiter({ - maxPerPeriod: 2, - periodMs: 100, // Short period for testing - perKey: true - }); - - // Use all tokens - expect(limiter.isAllowed('test')).toEqual(true); - expect(limiter.isAllowed('test')).toEqual(true); - expect(limiter.isAllowed('test')).toEqual(false); - - // Wait for refill - await new Promise(resolve => setTimeout(resolve, 150)); - - // Should have tokens again - expect(limiter.isAllowed('test')).toEqual(true); -}); - -tap.test('RateLimiter - should support burst allowance', async () => { - const limiter = new RateLimiter({ - maxPerPeriod: 2, - periodMs: 100, - perKey: true, - burstTokens: 2, // Allow 2 extra tokens for bursts - initialTokens: 4 // Start with max + burst tokens - }); - - // Should allow 4 requests (2 regular + 2 burst) - for (let i = 0; i < 4; i++) { - expect(limiter.isAllowed('test')).toEqual(true); - } - - // 5th request should be denied - expect(limiter.isAllowed('test')).toEqual(false); - - // Wait for refill - await new Promise(resolve => setTimeout(resolve, 150)); - - // Should have 2 tokens again (rate-limited to normal max, not burst) - expect(limiter.isAllowed('test')).toEqual(true); - expect(limiter.isAllowed('test')).toEqual(true); - - // 3rd request after refill should fail (only normal max is refilled, not burst) - expect(limiter.isAllowed('test')).toEqual(false); -}); - -tap.test('RateLimiter - should return correct stats', async () => { - const limiter = new RateLimiter({ - maxPerPeriod: 10, - periodMs: 1000, - perKey: true - }); - - // Make some requests - limiter.isAllowed('test'); - limiter.isAllowed('test'); - limiter.isAllowed('test'); - - // Get stats - const stats = limiter.getStats('test'); - - expect(stats.remaining).toEqual(7); - expect(stats.limit).toEqual(10); - expect(stats.allowed).toEqual(3); - expect(stats.denied).toEqual(0); -}); - -tap.test('RateLimiter - should reset limits', async () => { - const limiter = new RateLimiter({ - maxPerPeriod: 3, - periodMs: 1000, - perKey: true - }); - - // Use all tokens - expect(limiter.isAllowed('test')).toEqual(true); - expect(limiter.isAllowed('test')).toEqual(true); - expect(limiter.isAllowed('test')).toEqual(true); - expect(limiter.isAllowed('test')).toEqual(false); - - // Reset - limiter.reset('test'); - - // Should have tokens again - expect(limiter.isAllowed('test')).toEqual(true); -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.reputationmonitor.ts b/test/test.reputationmonitor.ts deleted file mode 100644 index bed03c2..0000000 --- a/test/test.reputationmonitor.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../ts/plugins.js'; -import * as paths from '../ts/paths.js'; -import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.js'; - -// Set NODE_ENV to test to prevent loading persisted data -process.env.NODE_ENV = 'test'; - -// Cleanup any temporary test data -const cleanupTestData = () => { - const reputationDataPath = plugins.path.join(paths.dataDir, 'reputation'); - if (plugins.fs.existsSync(reputationDataPath)) { - // Remove the directory recursively using fs instead of smartfile - plugins.fs.rmSync(reputationDataPath, { recursive: true, force: true }); - } -}; - -// Helper to reset the singleton instance between tests -const resetSingleton = () => { - // @ts-ignore - accessing private static field for testing - SenderReputationMonitor.instance = null; - - // Clean up any timeout to prevent race conditions - const activeSendReputationMonitors = Array.from(Object.values(global)) - .filter((item: any) => item && typeof item === 'object' && item._idleTimeout) - .filter((item: any) => - item._onTimeout && - item._onTimeout.toString && - item._onTimeout.toString().includes('updateAllDomainMetrics')); - - // Clear any active timeouts to prevent race conditions - activeSendReputationMonitors.forEach((timer: any) => { - clearTimeout(timer); - }); -}; - -// Before running any tests -tap.test('setup', async () => { - resetSingleton(); - cleanupTestData(); -}); - -// Test initialization of SenderReputationMonitor -tap.test('should initialize SenderReputationMonitor with default settings', async () => { - resetSingleton(); - const reputationMonitor = SenderReputationMonitor.getInstance(); - - expect(reputationMonitor).toBeTruthy(); - // Check if the object has the expected methods - expect(typeof reputationMonitor.recordSendEvent).toEqual('function'); - expect(typeof reputationMonitor.getReputationData).toEqual('function'); - expect(typeof reputationMonitor.getReputationSummary).toEqual('function'); -}); - -// Test initialization with custom settings -tap.test('should initialize SenderReputationMonitor with custom settings', async () => { - resetSingleton(); - const reputationMonitor = SenderReputationMonitor.getInstance({ - enabled: false, // Disable automatic updates to prevent race conditions - domains: ['example.com', 'test.com'], - updateFrequency: 12 * 60 * 60 * 1000, // 12 hours - alertThresholds: { - minReputationScore: 80, - maxComplaintRate: 0.05 - } - }); - - // Test adding domains - reputationMonitor.addDomain('newdomain.com'); - - // Test retrieving reputation data - const data = reputationMonitor.getReputationData('example.com'); - expect(data).toBeTruthy(); - expect(data.domain).toEqual('example.com'); -}); - -// Test recording and tracking send events -tap.test('should record send events and update metrics', async () => { - resetSingleton(); - const reputationMonitor = SenderReputationMonitor.getInstance({ - enabled: false, // Disable automatic updates to prevent race conditions - domains: ['example.com'] - }); - - // Record a series of events - reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 }); - reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 }); - reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: true, count: 3 }); - reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: false, count: 2 }); - reputationMonitor.recordSendEvent('example.com', { type: 'complaint', count: 1 }); - - // Check metrics - const metrics = reputationMonitor.getReputationData('example.com'); - - expect(metrics).toBeTruthy(); - expect(metrics.volume.sent).toEqual(100); - expect(metrics.volume.delivered).toEqual(95); - expect(metrics.volume.hardBounces).toEqual(3); - expect(metrics.volume.softBounces).toEqual(2); - expect(metrics.complaints.total).toEqual(1); -}); - -// Test reputation score calculation -tap.test('should calculate reputation scores correctly', async () => { - resetSingleton(); - const reputationMonitor = SenderReputationMonitor.getInstance({ - enabled: false, // Disable automatic updates to prevent race conditions - domains: ['high.com', 'medium.com', 'low.com'] - }); - - // Record events for different domains - reputationMonitor.recordSendEvent('high.com', { type: 'sent', count: 1000 }); - reputationMonitor.recordSendEvent('high.com', { type: 'delivered', count: 990 }); - reputationMonitor.recordSendEvent('high.com', { type: 'open', count: 500 }); - - reputationMonitor.recordSendEvent('medium.com', { type: 'sent', count: 1000 }); - reputationMonitor.recordSendEvent('medium.com', { type: 'delivered', count: 950 }); - reputationMonitor.recordSendEvent('medium.com', { type: 'open', count: 300 }); - - reputationMonitor.recordSendEvent('low.com', { type: 'sent', count: 1000 }); - reputationMonitor.recordSendEvent('low.com', { type: 'delivered', count: 850 }); - reputationMonitor.recordSendEvent('low.com', { type: 'open', count: 100 }); - - // Get reputation summary - const summary = reputationMonitor.getReputationSummary(); - expect(Array.isArray(summary)).toEqual(true); - expect(summary.length >= 3).toEqual(true); - - // Check that domains are included in the summary - const domains = summary.map(item => item.domain); - expect(domains.includes('high.com')).toEqual(true); - expect(domains.includes('medium.com')).toEqual(true); - expect(domains.includes('low.com')).toEqual(true); -}); - -// Test adding and removing domains -tap.test('should add and remove domains for monitoring', async () => { - resetSingleton(); - const reputationMonitor = SenderReputationMonitor.getInstance({ - enabled: false, // Disable automatic updates to prevent race conditions - domains: ['example.com'] - }); - - // Add a new domain - reputationMonitor.addDomain('newdomain.com'); - - // Record data for the new domain - reputationMonitor.recordSendEvent('newdomain.com', { type: 'sent', count: 50 }); - - // Check that data was recorded for the new domain - const metrics = reputationMonitor.getReputationData('newdomain.com'); - expect(metrics).toBeTruthy(); - expect(metrics.volume.sent).toEqual(50); - - // Remove a domain - reputationMonitor.removeDomain('newdomain.com'); - - // Check that data is no longer available - const removedMetrics = reputationMonitor.getReputationData('newdomain.com'); - expect(removedMetrics === null).toEqual(true); -}); - -// Test handling open and click events -tap.test('should track engagement metrics correctly', async () => { - resetSingleton(); - const reputationMonitor = SenderReputationMonitor.getInstance({ - enabled: false, // Disable automatic updates to prevent race conditions - domains: ['example.com'] - }); - - // Record basic sending metrics - reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 1000 }); - reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 950 }); - - // Record engagement events - reputationMonitor.recordSendEvent('example.com', { type: 'open', count: 500 }); - reputationMonitor.recordSendEvent('example.com', { type: 'click', count: 250 }); - - // Check engagement metrics - const metrics = reputationMonitor.getReputationData('example.com'); - expect(metrics).toBeTruthy(); - expect(metrics.engagement.opens).toEqual(500); - expect(metrics.engagement.clicks).toEqual(250); - expect(typeof metrics.engagement.openRate).toEqual('number'); - expect(typeof metrics.engagement.clickRate).toEqual('number'); -}); - -// Test historical data tracking -tap.test('should store historical reputation data', async () => { - resetSingleton(); - const reputationMonitor = SenderReputationMonitor.getInstance({ - enabled: false, // Disable automatic updates to prevent race conditions - domains: ['example.com'] - }); - - // Record events over multiple days - const today = new Date(); - const todayStr = today.toISOString().split('T')[0]; - - // Record data - reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 1000 }); - reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 950 }); - - // Get metrics data - const metrics = reputationMonitor.getReputationData('example.com'); - - // Check that historical data exists - expect(metrics.historical).toBeTruthy(); - expect(metrics.historical.reputationScores).toBeTruthy(); - - // Check that daily send volume is tracked - expect(metrics.volume.dailySendVolume).toBeTruthy(); - expect(metrics.volume.dailySendVolume[todayStr]).toEqual(1000); -}); - -// Test event recording for different event types -tap.test('should correctly handle different event types', async () => { - resetSingleton(); - const reputationMonitor = SenderReputationMonitor.getInstance({ - enabled: false, // Disable automatic updates to prevent race conditions - domains: ['example.com'] - }); - - // Record different types of events - reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 }); - reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 }); - reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: true, count: 3 }); - reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: false, count: 2 }); - reputationMonitor.recordSendEvent('example.com', { type: 'complaint', receivingDomain: 'gmail.com', count: 1 }); - reputationMonitor.recordSendEvent('example.com', { type: 'open', count: 50 }); - reputationMonitor.recordSendEvent('example.com', { type: 'click', count: 25 }); - - // Check metrics for different event types - const metrics = reputationMonitor.getReputationData('example.com'); - - // Check volume metrics - expect(metrics.volume.sent).toEqual(100); - expect(metrics.volume.delivered).toEqual(95); - expect(metrics.volume.hardBounces).toEqual(3); - expect(metrics.volume.softBounces).toEqual(2); - - // Check complaint metrics - expect(metrics.complaints.total).toEqual(1); - expect(metrics.complaints.topDomains[0].domain).toEqual('gmail.com'); - - // Check engagement metrics - expect(metrics.engagement.opens).toEqual(50); - expect(metrics.engagement.clicks).toEqual(25); -}); - -// After all tests, clean up -tap.test('cleanup', async () => { - resetSingleton(); - cleanupTestData(); -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - - -export default tap.start(); \ No newline at end of file diff --git a/test/test.smartmail.ts b/test/test.smartmail.ts deleted file mode 100644 index 9fdae67..0000000 --- a/test/test.smartmail.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../ts/plugins.js'; -import * as paths from '../ts/paths.js'; - -// Import the components we want to test -import { EmailValidator } from '../ts/mail/core/classes.emailvalidator.js'; -import { TemplateManager } from '../ts/mail/core/classes.templatemanager.js'; -import { Email } from '../ts/mail/core/classes.email.js'; - -// Ensure test directories exist -paths.ensureDirectories(); - -tap.test('EmailValidator - should validate email formats correctly', async (tools) => { - const validator = new EmailValidator(); - - // Test valid email formats - expect(validator.isValidFormat('user@example.com')).toEqual(true); - expect(validator.isValidFormat('firstname.lastname@example.com')).toEqual(true); - expect(validator.isValidFormat('user+tag@example.com')).toEqual(true); - - // Test invalid email formats - expect(validator.isValidFormat('user@')).toEqual(false); - expect(validator.isValidFormat('@example.com')).toEqual(false); - expect(validator.isValidFormat('user@example')).toEqual(false); - expect(validator.isValidFormat('user.example.com')).toEqual(false); -}); - -tap.test('EmailValidator - should perform comprehensive validation', async (tools) => { - const validator = new EmailValidator(); - - // Test basic validation (syntax-only) - const basicResult = await validator.validate('user@example.com', { checkSyntaxOnly: true }); - expect(basicResult.isValid).toEqual(true); - expect(basicResult.details.formatValid).toEqual(true); - - // We can't reliably test MX validation in all environments, but the function should run - const mxResult = await validator.validate('user@example.com', { checkMx: true }); - expect(typeof mxResult.isValid).toEqual('boolean'); - expect(typeof mxResult.hasMx).toEqual('boolean'); -}); - -tap.test('EmailValidator - should detect invalid emails', async (tools) => { - const validator = new EmailValidator(); - - const invalidResult = await validator.validate('invalid@@example.com', { checkSyntaxOnly: true }); - expect(invalidResult.isValid).toEqual(false); - expect(invalidResult.details.formatValid).toEqual(false); -}); - -tap.test('TemplateManager - should register and retrieve templates', async (tools) => { - const templateManager = new TemplateManager({ - from: 'test@example.com' - }); - - // Register a custom template - templateManager.registerTemplate({ - id: 'test-template', - name: 'Test Template', - description: 'A test template', - from: 'test@example.com', - subject: 'Test Subject: {{name}}', - bodyHtml: '

Hello, {{name}}!

', - bodyText: 'Hello, {{name}}!', - category: 'test' - }); - - // Get the template back - const template = templateManager.getTemplate('test-template'); - expect(template).toBeTruthy(); - expect(template.id).toEqual('test-template'); - expect(template.subject).toEqual('Test Subject: {{name}}'); - - // List templates - const templates = templateManager.listTemplates(); - expect(templates.length > 0).toEqual(true); - expect(templates.some(t => t.id === 'test-template')).toEqual(true); -}); - -tap.test('TemplateManager - should create email from template', async (tools) => { - const templateManager = new TemplateManager({ - from: 'test@example.com' - }); - - // Register a template - templateManager.registerTemplate({ - id: 'welcome-test', - name: 'Welcome Test', - description: 'A welcome test template', - from: 'welcome@example.com', - subject: 'Welcome, {{name}}!', - bodyHtml: '

Hello, {{name}}! Welcome to our service.

', - bodyText: 'Hello, {{name}}! Welcome to our service.', - category: 'test' - }); - - // Create email from template - const email = await templateManager.createEmail('welcome-test', { - name: 'John Doe' - }); - - expect(email).toBeTruthy(); - expect(email.from).toEqual('welcome@example.com'); - expect(email.getSubjectWithVariables()).toEqual('Welcome, John Doe!'); - expect(email.getHtmlWithVariables()?.indexOf('Hello, John Doe!') > -1).toEqual(true); -}); - -tap.test('Email - should handle template variables', async (tools) => { - // Create email with variables - const email = new Email({ - from: 'sender@example.com', - to: 'recipient@example.com', - subject: 'Hello {{name}}!', - text: 'Welcome, {{name}}! Your order #{{orderId}} has been processed.', - html: '

Welcome, {{name}}! Your order #{{orderId}} has been processed.

', - variables: { - name: 'John Doe', - orderId: '12345' - } - }); - - // Test variable substitution - expect(email.getSubjectWithVariables()).toEqual('Hello John Doe!'); - expect(email.getTextWithVariables()).toEqual('Welcome, John Doe! Your order #12345 has been processed.'); - expect(email.getHtmlWithVariables().indexOf('John Doe') > -1).toEqual(true); - - // Test with additional variables - const additionalVars = { - name: 'Jane Smith', // Override existing variable - status: 'shipped' // Add new variable - }; - - expect(email.getSubjectWithVariables(additionalVars)).toEqual('Hello Jane Smith!'); - - // Add a new variable - email.setVariable('trackingNumber', 'TRK123456'); - expect(email.getTextWithVariables().indexOf('12345') > -1).toEqual(true); - - // Update multiple variables at once - email.setVariables({ - orderId: '67890', - status: 'delivered' - }); - - expect(email.getTextWithVariables().indexOf('67890') > -1).toEqual(true); -}); - -tap.test('Email and Smartmail compatibility - should convert between formats', async (tools) => { - // Create a Smartmail instance - const smartmail = new plugins.smartmail.Smartmail({ - from: 'smartmail@example.com', - subject: 'Test Subject', - body: '

This is a test email.

', - creationObjectRef: { - orderId: '12345' - } - }); - - // Add recipient and attachment - smartmail.addRecipient('recipient@example.com'); - - // Use SmartFileFactory for creating SmartFile instances (smartfile v13+) - const smartFileFactory = plugins.smartfile.SmartFileFactory.nodeFs(); - const attachment = smartFileFactory.fromString( - 'test.txt', - 'This is a test attachment', - 'utf8', - ); - - smartmail.addAttachment(attachment); - - // Convert to Email - const resolvedSmartmail = await smartmail; - const email = Email.fromSmartmail(resolvedSmartmail); - - // Verify first conversion (Smartmail to Email) - expect(email.from).toEqual('smartmail@example.com'); - expect(email.to.indexOf('recipient@example.com') > -1).toEqual(true); - expect(email.subject).toEqual('Test Subject'); - expect(email.html?.indexOf('This is a test email') > -1).toEqual(true); - expect(email.attachments.length).toEqual(1); - - // Convert back to Smartmail - const convertedSmartmail = await email.toSmartmail(); - - // Verify second conversion (Email back to Smartmail) with simplified assertions - expect(convertedSmartmail.options.from).toEqual('smartmail@example.com'); - expect(Array.isArray(convertedSmartmail.options.to)).toEqual(true); - expect(convertedSmartmail.options.to.length).toEqual(1); - expect(convertedSmartmail.getSubject()).toEqual('Test Subject'); - expect(convertedSmartmail.getBody(true).indexOf('This is a test email') > -1).toEqual(true); - expect(convertedSmartmail.attachments.length).toEqual(1); -}); - -tap.test('Email - should validate email addresses', async (tools) => { - // Attempt to create an email with invalid addresses - let errorThrown = false; - - try { - const email = new Email({ - from: 'invalid-email', - to: 'recipient@example.com', - subject: 'Test', - text: 'Test' - }); - } catch (error) { - errorThrown = true; - expect(error.message.indexOf('Invalid sender email address') > -1).toEqual(true); - } - - expect(errorThrown).toEqual(true); - - // Attempt with invalid recipient - errorThrown = false; - - try { - const email = new Email({ - from: 'sender@example.com', - to: 'invalid-recipient', - subject: 'Test', - text: 'Test' - }); - } catch (error) { - errorThrown = true; - expect(error.message.indexOf('Invalid recipient email address') > -1).toEqual(true); - } - - expect(errorThrown).toEqual(true); - - // Valid email should not throw - let validEmail: Email; - try { - validEmail = new Email({ - from: 'sender@example.com', - to: 'recipient@example.com', - subject: 'Test', - text: 'Test' - }); - - expect(validEmail).toBeTruthy(); - expect(validEmail.from).toEqual('sender@example.com'); - } catch (error) { - expect(error === undefined).toEqual(true); // This should not happen - } -}); - -tap.test('stop', async () => { - tap.stopForcefully(); -}) - -export default tap.start(); \ No newline at end of file diff --git a/test/test.smtp.client.compatibility.ts b/test/test.smtp.client.compatibility.ts deleted file mode 100644 index b94dc7e..0000000 --- a/test/test.smtp.client.compatibility.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -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'; - -/** - * Compatibility tests for the legacy SMTP client facade - */ - -tap.test('verify backward compatibility - client creation', async () => { - // Create test configuration - const options: ISmtpClientOptions = { - host: 'smtp.example.com', - port: 587, - secure: false, - connectionTimeout: 10000, - domain: 'test.example.com' - }; - - // Create SMTP client instance using legacy constructor - const smtpClient = smtpClientMod.createSmtpClient(options); - - // Verify instance was created correctly - expect(smtpClient).toBeTruthy(); - expect(smtpClient.isConnected()).toBeFalsy(); // Should start disconnected -}); - -tap.test('verify backward compatibility - methods exist', async () => { - const options: ISmtpClientOptions = { - host: 'smtp.example.com', - port: 587, - secure: false - }; - - const smtpClient = smtpClientMod.createSmtpClient(options); - - // Verify all expected methods exist - expect(typeof smtpClient.sendMail === 'function').toBeTruthy(); - expect(typeof smtpClient.verify === 'function').toBeTruthy(); - expect(typeof smtpClient.isConnected === 'function').toBeTruthy(); - expect(typeof smtpClient.getPoolStatus === 'function').toBeTruthy(); - expect(typeof smtpClient.updateOptions === 'function').toBeTruthy(); - expect(typeof smtpClient.close === 'function').toBeTruthy(); - expect(typeof smtpClient.on === 'function').toBeTruthy(); - expect(typeof smtpClient.off === 'function').toBeTruthy(); - expect(typeof smtpClient.emit === 'function').toBeTruthy(); -}); - -tap.test('verify backward compatibility - options update', async () => { - const options: ISmtpClientOptions = { - host: 'smtp.example.com', - port: 587, - secure: false - }; - - const smtpClient = smtpClientMod.createSmtpClient(options); - - // Test option updates don't throw - expect(() => smtpClient.updateOptions({ - host: 'new-smtp.example.com', - port: 465, - secure: true - })).not.toThrow(); - - expect(() => smtpClient.updateOptions({ - debug: true, - connectionTimeout: 5000 - })).not.toThrow(); -}); - -tap.test('verify backward compatibility - connection failure handling', async () => { - const options: ISmtpClientOptions = { - host: 'nonexistent.invalid.domain', - port: 587, - secure: false, - connectionTimeout: 1000 // Short timeout for faster test - }; - - const smtpClient = smtpClientMod.createSmtpClient(options); - - // verify() should return false for invalid hosts - const isValid = await smtpClient.verify(); - expect(isValid).toBeFalsy(); - - // sendMail should fail gracefully for invalid hosts - const email = new Email({ - from: 'test@example.com', - to: 'recipient@example.com', - subject: 'Test Email', - text: 'This is a test email' - }); - - try { - const result = await smtpClient.sendMail(email); - expect(result.success).toBeFalsy(); - expect(result.error).toBeTruthy(); - } catch (error) { - // Connection errors are expected for invalid domains - expect(error).toBeTruthy(); - } -}); - -tap.test('verify backward compatibility - pool status', async () => { - const options: ISmtpClientOptions = { - host: 'smtp.example.com', - port: 587, - secure: false, - pool: true, - maxConnections: 5 - }; - - const smtpClient = smtpClientMod.createSmtpClient(options); - - // Get pool status - const status = smtpClient.getPoolStatus(); - expect(status).toBeTruthy(); - expect(typeof status.total === 'number').toBeTruthy(); - expect(typeof status.active === 'number').toBeTruthy(); - expect(typeof status.idle === 'number').toBeTruthy(); - expect(typeof status.pending === 'number').toBeTruthy(); - - // Initially should have no connections - expect(status.total).toEqual(0); - expect(status.active).toEqual(0); - expect(status.idle).toEqual(0); - expect(status.pending).toEqual(0); -}); - -tap.test('verify backward compatibility - event handling', async () => { - const options: ISmtpClientOptions = { - host: 'smtp.example.com', - port: 587, - secure: false - }; - - const smtpClient = smtpClientMod.createSmtpClient(options); - - // Test event listener methods don't throw - const testListener = () => {}; - - expect(() => smtpClient.on('test', testListener)).not.toThrow(); - expect(() => smtpClient.off('test', testListener)).not.toThrow(); - expect(() => smtpClient.emit('test')).not.toThrow(); -}); - -tap.test('clean up after compatibility tests', async () => { - // No-op - just to make sure everything is cleaned up properly -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.smtp.client.ts b/test/test.smtp.client.ts deleted file mode 100644 index 0f5e2d8..0000000 --- a/test/test.smtp.client.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../ts/plugins.js'; -import * as paths from '../ts/paths.js'; -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'; - -/** - * Tests for the SMTP client class - */ -tap.test('verify SMTP client initialization', async () => { - // Create test configuration - const options: ISmtpClientOptions = { - host: 'smtp.example.com', - port: 587, - secure: false, - connectionTimeout: 10000, - domain: 'test.example.com' - }; - - // Create SMTP client instance - const smtpClient = smtpClientMod.createSmtpClient(options); - - // Verify instance was created correctly - expect(smtpClient).toBeTruthy(); - expect(smtpClient.isConnected()).toBeFalsy(); // Should start disconnected -}); - -tap.test('test SMTP client configuration update', async () => { - // Create test configuration - const options: ISmtpClientOptions = { - host: 'smtp.example.com', - port: 587, - secure: false - }; - - // Create SMTP client instance - const smtpClient = smtpClientMod.createSmtpClient(options); - - // Update configuration - smtpClient.updateOptions({ - host: 'new-smtp.example.com', - port: 465, - secure: true - }); - - // Can't directly test private fields, but we can verify it doesn't throw - expect(() => smtpClient.updateOptions({ - tls: { - rejectUnauthorized: false - } - })).not.toThrow(); -}); - -// Mocked SMTP server for testing -class MockSmtpServer { - private responses: Map; - - constructor() { - this.responses = new Map(); - - // Default responses - this.responses.set('connect', '220 smtp.example.com ESMTP ready'); - this.responses.set('EHLO', '250-smtp.example.com\r\n250-PIPELINING\r\n250-SIZE 10240000\r\n250-STARTTLS\r\n250-AUTH PLAIN LOGIN\r\n250 HELP'); - this.responses.set('MAIL FROM', '250 OK'); - this.responses.set('RCPT TO', '250 OK'); - this.responses.set('DATA', '354 Start mail input; end with .'); - this.responses.set('data content', '250 OK: message accepted'); - this.responses.set('QUIT', '221 Bye'); - } - - public setResponse(command: string, response: string): void { - this.responses.set(command, response); - } - - public getResponse(command: string): string { - if (command.startsWith('MAIL FROM')) { - return this.responses.get('MAIL FROM') || '250 OK'; - } else if (command.startsWith('RCPT TO')) { - return this.responses.get('RCPT TO') || '250 OK'; - } else if (command.startsWith('EHLO') || command.startsWith('HELO')) { - return this.responses.get('EHLO') || '250 OK'; - } else if (command === 'DATA') { - return this.responses.get('DATA') || '354 Start mail input; end with .'; - } else if (command.includes('Content-Type')) { - return this.responses.get('data content') || '250 OK: message accepted'; - } else if (command === 'QUIT') { - return this.responses.get('QUIT') || '221 Bye'; - } - - return this.responses.get(command) || '250 OK'; - } -} - -/** - * This test validates the SMTP client public interface - */ -tap.test('verify SMTP client email delivery functionality with mock', async () => { - // Create a test email - const testEmail = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Test Email', - text: 'This is a test email' - }); - - // Create SMTP client options - const options: ISmtpClientOptions = { - host: 'smtp.example.com', - port: 587, - secure: false, - domain: 'test.example.com', - auth: { - user: 'testuser', - pass: 'testpass' - } - }; - - // Create SMTP client instance - const smtpClient = smtpClientMod.createSmtpClient(options); - - // Test public methods exist and have correct signatures - expect(typeof smtpClient.sendMail).toEqual('function'); - expect(typeof smtpClient.verify).toEqual('function'); - expect(typeof smtpClient.isConnected).toEqual('function'); - expect(typeof smtpClient.getPoolStatus).toEqual('function'); - expect(typeof smtpClient.updateOptions).toEqual('function'); - expect(typeof smtpClient.close).toEqual('function'); - - // Test connection status before any operation - expect(smtpClient.isConnected()).toBeFalsy(); - - // Test pool status - const poolStatus = smtpClient.getPoolStatus(); - expect(poolStatus).toBeTruthy(); - expect(typeof poolStatus.active).toEqual('number'); - expect(typeof poolStatus.idle).toEqual('number'); - expect(typeof poolStatus.total).toEqual('number'); - - // Since we can't connect to a real server, we'll skip the actual send test - // and just verify the client was created correctly - expect(smtpClient).toBeTruthy(); -}); - -tap.test('test SMTP client error handling with mock', async () => { - // Create SMTP client instance - const smtpClient = smtpClientMod.createSmtpClient({ - host: 'smtp.example.com', - port: 587, - secure: false - }); - - // Test with valid email (Email class might allow any string) - const testEmail = new Email({ - from: 'sender@example.com', - to: ['recipient@example.com'], - subject: 'Test Email', - text: 'This is a test email' - }); - - // Test event listener methods - const mockListener = () => {}; - smtpClient.on('test-event', mockListener); - smtpClient.off('test-event', mockListener); - - // Test update options - smtpClient.updateOptions({ - auth: { - user: 'newuser', - pass: 'newpass' - } - }); - - // Verify client is still functional - expect(smtpClient.isConnected()).toBeFalsy(); - - // Test close on a non-connected client - await smtpClient.close(); - expect(smtpClient.isConnected()).toBeFalsy(); -}); - -// Final clean-up test -tap.test('clean up after tests', async () => { - // No-op - just to make sure everything is cleaned up properly -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.smtp.server.ts b/test/test.smtp.server.ts deleted file mode 100644 index 75bae07..0000000 --- a/test/test.smtp.server.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as plugins from '../ts/plugins.js'; -import * as paths from '../ts/paths.js'; -import { createSmtpServer } from '../ts/mail/delivery/smtpserver/index.js'; -import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js'; -import { Email } from '../ts/mail/core/classes.email.js'; -import type { ISmtpServerOptions } from '../ts/mail/delivery/interfaces.js'; - -/** - * Tests for the SMTP server class - */ -tap.test('verify SMTP server initialization', async () => { - // Mock email server - const mockEmailServer = { - processEmailByMode: async () => new Email({ - from: 'test@example.com', - to: 'recipient@example.com', - subject: 'Test Email', - text: 'This is a test email' - }) - } as any; - - // Create test configuration - const options: ISmtpServerOptions = { - port: 2525, // Use a high port for testing - hostname: 'test.example.com', - key: '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAxzYIwlfnr7AK2v6E+c2oYD7nAIXIIvDuvVvZ8R9kyxXIzTXB\nj5D1AgntqKS3bFR1XT8hCVeXjuLKPBvXbhVjG15gXlXxpNiFi1ZcphJvs4zB/Vh7\nZv2ALt3anSIwsJ2rZA/R/GqdJPkHvYf/GMTDLw0YllR0YOevErnRIIM5S58Lj2nT\nCr5v5hK1Gl9mWwRkFQKkWVl2UXt/JX6C7Z6UyJXMZSnoG0Kw6GQje41K5r0Zdzrh\nrGfmb9wSDUn9sZGX6il+oMiYz7UgQkPEzGUZEJxKJwxy8ZgPdSgbvYq4WwPwbBUJ\nlpw0gt5i6HOS7CphRama+zAf5LvfSLoLXSP5JwIDAQABAoIBAQC8C5Ge6wS4LuH9\ntbZFPwjdGHXL+QT2fOFxPBrE7PkeY8UXD7G5Yei6iqqCxJh8nhLQ3DoayhZM69hO\nePOV1Z/LDERCnGel15WKQ1QJ1HZ+JQXnfQrE1Mi9QrXO5bVFtnXIr0mZ+AzwoUmn\nK5fYCvaL3xDZPDzOYL5kZG2hQKgbywGKZoQx16G0dSEhlAHbK9z6XmPRrbUKGzB8\nqV7QGbL7BUTQs5JW/8LpkYr5C0q5THtUVb9mHNR3jPf9WTPQ0D3lxcbLS4PQ8jQ/\nL/GcuHGmsXhe2Unw3w2wpuJKPeHKz4rBNIvaSjIZl9/dIKM88JYQTiIGKErxsC0e\nkczQMp6BAoGBAO0zUN8H7ynXGNNtK/tJo0lI3qg1ZKgr+0CU2L5eU8Bn1oJ1JkCI\nWD3p36NdECx5tGexm9U6MN+HzKYUjnQ6LKzbHQGLZqzF5IL5axXgCn8w4BM+6Ixm\ny8kQgsTKlKRMXIn8RZCmXNnc7v0FhBgpDxPmm7ZUuOPrInd8Ph4mEsePAoGBANb4\n3/izAHnLEp3/sTOZpfWBnDcvEHCG7/JAX0TDRW1FpXiTHpvDV1j3XU3EvLl7WRJ1\nB+B8h/Z6kQtUUxQ3I+zxuQIkQYI8qPu+xhQ8gb5AIO5CMX09+xKUgYjQtm7kYs7W\nL0LD9u3hkGsJk2wfVvMJKb3OSIHeTwRzFCzGX995AoGADkLB8eu/FKAIfwRPCHVE\nsfwMtqjkj2XJ9FeNcRQ5g/Tf8OGnCGEzBwXb05wJVrXUgXp4dBaqYTdAKj8uLEvd\nmi9t/LzR+33cGUdAQHItxcKbsMv00TyNRQUvZFZ7ZEY8aBkv5uZfvJHZ5iQ8C7+g\nHGXNfVGXGPutz/KN6X25CLECgYEAjVLK0MkXzLxCYJRDIhB1TpQVXjpxYUP2Vxls\nSSxfeYqkJPgNvYiHee33xQ8+TP1y9WzkWh+g2AbGmwTuKKL6CvQS9gKVvqqaFB7y\nKrkR13MTPJKvHHdQYKGQqQGgHKh0kGFCC0+PoVwtYs/XU1KpZCE16nNgXrOvTYNN\nHxESa+kCgYB7WOcawTp3WdKP8JbolxIfxax7Kd4QkZhY7dEb4JxBBYXXXpv/NHE9\npcJw4eKDyY+QE2AHPu3+fQYzXopaaTGRpB+ynEfYfD2hW+HnOWfWu/lFJbiwBn/S\nwRsYzSWiLtNplKNFRrsSoMWlh8GOTUpZ7FMLXWhE4rE9NskQBbYq8g==\n-----END RSA PRIVATE KEY-----', - cert: '-----BEGIN CERTIFICATE-----\nMIIDazCCAlOgAwIBAgIUcmAewXEYwtzbZmZAJ5inMogKSbowDQYJKoZIhvcNAQEL\nBQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM\nGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAyMjAwODM4MzRaFw0yNTAy\nMTkwODM4MzRaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw\nHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB\nAQUAA4IBDwAwggEKAoIBAQDHNgjCV+evsAra/oT5zahgPucAhcgi8O69W9nxH2TL\nFcjNNcGPkPUCCe2opLdsVHVdPyEJV5eO4so8G9duFWMbXmBeVfGk2IWLVlymEm+z\njMH9WHtm/YAu3dqdIjCwnatED9H8ap0k+Qd9h/8YxMMvDRiWVHRg568SudEggzlL\nnwuPadMKvm/mErUaX2ZbBGQVAqRZWXZRe38lfoLtnpTIlcxlKegbQrDoZCN7jUrm\nvRl3OuGsZ+Zv3BINSf2xkZfqKX6gyJjPtSBCQ8TMZRkQnEonDHLxmA91KBu9irhb\nA/BsFQmWnDSC3mLoc5LsKmFFqZr7MB/ku99IugtdI/knAgMBAAGjUzBRMB0GA1Ud\nDgQWBBQryyWLuN22OqU1r9HIt2tMLBk42DAfBgNVHSMEGDAWgBQryyWLuN22OqU1\nr9HIt2tMLBk42DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAe\nCeXQZlXJ2xLnDoOoKY3BpodErNmAwygGYxwDCU0xPbpUMPrQhLI80JlZmfy58gT/\n0ZbULS+srShfEsFnBLmzWLGXDvA/IKCQyTmCQwbPeELGXF6h4URMb+lQL7WL9tY0\nuUg2dA+7CtYokIrOkGqUitPK3yvVhxugkf51WIgKMACZDibOQSWrV5QO2vHOAaO9\nePzRGGl3+Ebmcs3+5w1fI6OLsIZH10lfEnC83C0lO8tIJlGsXMQkCjAcX22rT0rc\nAcxLm07H4EwMwgOAJUkuDjD3y4+KH91jKWF8bhaLZooFB8lccNnaCRiuZRnXlvmf\nM7uVlLGwlj5R9iHd+0dP\n-----END CERTIFICATE-----' - }; - - // Create SMTP server instance - const smtpServer = createSmtpServer(mockEmailServer, options); - - // Verify instance was created correctly - expect(smtpServer).toBeTruthy(); - - // Test that the listen method exists and is callable - expect(typeof smtpServer.listen === 'function').toBeTruthy(); - - // Test that the close method exists - expect(typeof smtpServer.close === 'function').toBeTruthy(); -}); - -tap.test('verify SMTP server listen method - skipping test that accesses private properties', async (tools) => { - tools.skip('Skipping test that accesses private properties'); - // Mock email server - const mockEmailServer = { - processEmailByMode: async () => new Email({ - from: 'test@example.com', - to: 'recipient@example.com', - subject: 'Test Email', - text: 'This is a test email' - }) - } as any; - - // Create test configuration without certificates (will use self-signed) - const options: ISmtpServerOptions = { - port: 2526, // Use a different port for this test - hostname: 'test.example.com', - connectionTimeout: 5000 // Short timeout for tests - }; - - // Create SMTP server instance - const smtpServer = createSmtpServer(mockEmailServer, options); - - // Test that server was created - expect(smtpServer).toBeTruthy(); - expect(smtpServer).toHaveProperty('server'); - - // Mock server methods to avoid actual networking - let listenCalled = false; - let closeCalled = false; - - if (smtpServer.server) { - const originalListen = smtpServer.server.listen; - const originalClose = smtpServer.server.close; - - smtpServer.server.listen = function(port, callback) { - listenCalled = true; - if (callback) callback(); - return this; - }; - - smtpServer.server.close = function(callback) { - closeCalled = true; - if (callback) callback(null); - return this; - }; - - try { - // Test listen method - await smtpServer.listen(); - expect(listenCalled).toBeTruthy(); - - // Test close method - await smtpServer.close(); - expect(closeCalled).toBeTruthy(); - } finally { - // Restore original methods - smtpServer.server.listen = originalListen; - smtpServer.server.close = originalClose; - } - } -}); - -tap.test('verify SMTP server error handling - skipping test that accesses private properties', async (tools) => { - tools.skip('Skipping test that accesses private properties'); - // Mock email server - const mockEmailServer = { - processEmailByMode: async () => new Email({ - from: 'test@example.com', - to: 'recipient@example.com', - subject: 'Test Email', - text: 'This is a test email' - }) - } as any; - - // Create test configuration without certificates - const options: ISmtpServerOptions = { - port: 2527, // Use port that should work - hostname: 'test.example.com' - }; - - // Create SMTP server instance - const smtpServer = createSmtpServer(mockEmailServer, options); - - // Test error handling by mocking the server's error event - if (smtpServer.server) { - const originalListen = smtpServer.server.listen; - const originalOn = smtpServer.server.on; - const originalOnce = smtpServer.server.once; - - let errorCallback: (err: Error) => void; - let listeningCallback: () => void; - - smtpServer.server.listen = function(port, callback) { - // Simulate error after a delay - setTimeout(() => { - if (errorCallback) { - errorCallback(new Error('EACCES: Permission denied')); - } - }, 10); - return this; - }; - - smtpServer.server.on = function(event: string, callback: any) { - if (event === 'error') { - errorCallback = callback; - } - return originalOn.call(this, event, callback); - }; - - smtpServer.server.once = function(event: string, callback: any) { - if (event === 'listening') { - listeningCallback = callback; - } - return originalOnce.call(this, event, callback); - }; - - try { - // This should fail with an error - await smtpServer.listen().catch(error => { - // Expect an error - expect(error).toBeTruthy(); - expect(error.message).toContain('EACCES'); - }); - } finally { - // Restore original methods - smtpServer.server.listen = originalListen; - smtpServer.server.on = originalOn as any; - smtpServer.server.once = originalOnce as any; - } - } -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/test/test.socket-handler-integration.ts b/test/test.socket-handler-integration.ts deleted file mode 100644 index eca5e38..0000000 --- a/test/test.socket-handler-integration.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { DcRouter } from '../ts/classes.dcrouter.js'; -import * as plugins from '../ts/plugins.js'; - -/** - * Integration tests for socket-handler functionality - * - * Note: These tests verify the actual startup and route configuration of DcRouter - * with socket-handler mode. Each test starts a full DcRouter instance. - * - * The unit tests (test.socket-handler-unit.ts) cover route generation logic - * without starting actual servers. - */ - -let dcRouter: DcRouter; - -tap.test('should start email server with socket-handlers and verify routes', async () => { - dcRouter = new DcRouter({ - emailConfig: { - ports: [10025, 10587, 10465], - hostname: 'mail.integration.test', - domains: ['integration.test'], - routes: [], - useSocketHandler: true - }, - smartProxyConfig: { - routes: [] - } - }); - - await dcRouter.start(); - - // Verify email service is running - const emailServer = (dcRouter as any).emailServer; - expect(emailServer).toBeDefined(); - - // Verify SmartProxy has routes for email - const smartProxy = (dcRouter as any).smartProxy; - - // Try different ways to access routes - // SmartProxy might store routes in different locations after initialization - const optionsRoutes = smartProxy?.options?.routes || []; - const routeManager = (smartProxy as any)?.routeManager; - const routeManagerRoutes = routeManager?.routes || routeManager?.getAllRoutes?.() || []; - - // Use whichever has routes - const routes = optionsRoutes.length > 0 ? optionsRoutes : routeManagerRoutes; - - // Count email routes - they should be named email-port-{port}-route for non-standard ports - const emailRoutes = routes.filter((route: any) => - route.name?.includes('email-port-') && route.name?.includes('-route') - ); - - // Verify we have 3 routes (one for each port) - expect(emailRoutes.length).toEqual(3); - - // All routes should be socket-handler type - emailRoutes.forEach((route: any) => { - expect(route.action.type).toEqual('socket-handler'); - expect(route.action.socketHandler).toBeDefined(); - expect(typeof route.action.socketHandler).toEqual('function'); - }); - - // Verify each port has a route - const routePorts = emailRoutes.map((r: any) => r.match.ports[0]).sort((a: number, b: number) => a - b); - expect(routePorts).toEqual([10025, 10465, 10587]); - - // Verify email server has NO internal listeners (socket-handler mode) - expect(emailServer.servers.length).toEqual(0); - - await dcRouter.stop(); -}); - -tap.test('should create mail socket handler for different ports', async () => { - // The dcRouter from the previous test should still be available - // but we need a fresh one to test handler creation - dcRouter = new DcRouter({ - emailConfig: { - ports: [11025, 11465], - hostname: 'mail.handler.test', - domains: ['handler.test'], - routes: [], - useSocketHandler: true - } - }); - - // Don't start the server - just test handler creation - const handler25 = (dcRouter as any).createMailSocketHandler(11025); - const handler465 = (dcRouter as any).createMailSocketHandler(11465); - - expect(handler25).toBeDefined(); - expect(handler465).toBeDefined(); - expect(typeof handler25).toEqual('function'); - expect(typeof handler465).toEqual('function'); - - // Handlers should be different functions - expect(handler25).not.toEqual(handler465); -}); - -tap.test('should handle socket handler errors gracefully', async () => { - dcRouter = new DcRouter({ - emailConfig: { - ports: [12025], - hostname: 'mail.error.test', - domains: ['error.test'], - routes: [], - useSocketHandler: true - } - }); - - // Test email socket handler error handling without starting the server - const emailHandler = (dcRouter as any).createMailSocketHandler(12025); - const errorSocket = new plugins.net.Socket(); - - let errorThrown = false; - try { - // This should handle the error gracefully - // The socket is not connected so it should fail gracefully - await emailHandler(errorSocket); - } catch (error) { - errorThrown = true; - } - - // Should not throw, should handle gracefully - expect(errorThrown).toBeFalsy(); -}); - -tap.test('stop', async () => { - // Ensure any remaining dcRouter is stopped - if (dcRouter) { - try { - await dcRouter.stop(); - } catch (e) { - // Ignore errors during cleanup - } - } - await tap.stopForcefully(); -}); - -export default tap.start(); diff --git a/test/test.socket-handler-unit.ts b/test/test.socket-handler-unit.ts deleted file mode 100644 index e5e5a0d..0000000 --- a/test/test.socket-handler-unit.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { tap, expect } from '@git.zone/tstest/tapbundle'; -import { DcRouter } from '../ts/classes.dcrouter.js'; - -/** - * Unit tests for socket-handler functionality - * These tests focus on the configuration and route generation logic - * without actually starting services on real ports - */ - -let dcRouter: DcRouter; - -tap.test('DNS route generation with dnsNsDomains', async () => { - dcRouter = new DcRouter({ - dnsNsDomains: ['dns.unit.test'] - }); - - // Test the route generation directly - const dnsRoutes = (dcRouter as any).generateDnsRoutes(); - - expect(dnsRoutes).toBeDefined(); - expect(dnsRoutes.length).toEqual(2); - - // Check /dns-query route - const dnsQueryRoute = dnsRoutes[0]; - expect(dnsQueryRoute.name).toEqual('dns-over-https-dns-query'); - expect(dnsQueryRoute.match.ports).toEqual([443]); - expect(dnsQueryRoute.match.domains).toEqual(['dns.unit.test']); - expect(dnsQueryRoute.match.path).toEqual('/dns-query'); - expect(dnsQueryRoute.action.type).toEqual('socket-handler'); - expect(dnsQueryRoute.action.socketHandler).toBeDefined(); - - // Check /resolve route - const resolveRoute = dnsRoutes[1]; - expect(resolveRoute.name).toEqual('dns-over-https-resolve'); - expect(resolveRoute.match.ports).toEqual([443]); - expect(resolveRoute.match.domains).toEqual(['dns.unit.test']); - expect(resolveRoute.match.path).toEqual('/resolve'); - expect(resolveRoute.action.type).toEqual('socket-handler'); - expect(resolveRoute.action.socketHandler).toBeDefined(); -}); - -tap.test('DNS route generation without dnsNsDomains', async () => { - dcRouter = new DcRouter({ - // No dnsNsDomains set - }); - - const dnsRoutes = (dcRouter as any).generateDnsRoutes(); - - expect(dnsRoutes).toBeDefined(); - expect(dnsRoutes.length).toEqual(0); // No routes generated -}); - -tap.test('Email route generation with socket-handler', async () => { - const emailConfig = { - ports: [25, 587, 465], - hostname: 'mail.unit.test', - domains: ['unit.test'], - routes: [], - useSocketHandler: true - }; - - dcRouter = new DcRouter({ emailConfig }); - - const emailRoutes = (dcRouter as any).generateEmailRoutes(emailConfig); - - expect(emailRoutes).toBeDefined(); - expect(emailRoutes.length).toEqual(3); - - // Check all routes use socket-handler - emailRoutes.forEach((route: any) => { - expect(route.action.type).toEqual('socket-handler'); - expect(route.action.socketHandler).toBeDefined(); - expect(typeof route.action.socketHandler).toEqual('function'); - }); - - // Check specific ports - const port25Route = emailRoutes.find((r: any) => r.match.ports[0] === 25); - expect(port25Route.name).toEqual('smtp-route'); - - const port587Route = emailRoutes.find((r: any) => r.match.ports[0] === 587); - expect(port587Route.name).toEqual('submission-route'); - - const port465Route = emailRoutes.find((r: any) => r.match.ports[0] === 465); - expect(port465Route.name).toEqual('smtps-route'); -}); - -tap.test('Email route generation with traditional forwarding', async () => { - const emailConfig = { - ports: [25, 587], - hostname: 'mail.unit.test', - domains: ['unit.test'], - routes: [], - useSocketHandler: false // Traditional mode - }; - - dcRouter = new DcRouter({ emailConfig }); - - const emailRoutes = (dcRouter as any).generateEmailRoutes(emailConfig); - - expect(emailRoutes).toBeDefined(); - expect(emailRoutes.length).toEqual(2); - - // Check all routes use forward action - emailRoutes.forEach((route: any) => { - expect(route.action.type).toEqual('forward'); - expect(route.action.target).toBeDefined(); - expect(route.action.target.host).toEqual('localhost'); - expect(route.action.target.port).toBeGreaterThan(10000); // Internal port - }); -}); - -tap.test('Email TLS modes are set correctly', async () => { - const emailConfig = { - ports: [25, 465], - hostname: 'mail.unit.test', - domains: ['unit.test'], - routes: [], - useSocketHandler: false - }; - - dcRouter = new DcRouter({ emailConfig }); - - const emailRoutes = (dcRouter as any).generateEmailRoutes(emailConfig); - - // Port 25 should use passthrough (STARTTLS) - const port25Route = emailRoutes.find((r: any) => r.match.ports[0] === 25); - expect(port25Route.action.tls.mode).toEqual('passthrough'); - - // Port 465 should use terminate (implicit TLS) - const port465Route = emailRoutes.find((r: any) => r.match.ports[0] === 465); - expect(port465Route.action.tls.mode).toEqual('terminate'); - expect(port465Route.action.tls.certificate).toEqual('auto'); -}); - -tap.test('Combined DNS and email configuration', async () => { - dcRouter = new DcRouter({ - dnsNsDomains: ['dns.combined.test'], - emailConfig: { - ports: [25], - hostname: 'mail.combined.test', - domains: ['combined.test'], - routes: [], - useSocketHandler: true - } - }); - - // Generate both types of routes - const dnsRoutes = (dcRouter as any).generateDnsRoutes(); - const emailRoutes = (dcRouter as any).generateEmailRoutes(dcRouter.options.emailConfig); - - // Check DNS routes - expect(dnsRoutes.length).toEqual(2); - dnsRoutes.forEach((route: any) => { - expect(route.action.type).toEqual('socket-handler'); - expect(route.match.domains).toEqual(['dns.combined.test']); - }); - - // Check email routes - expect(emailRoutes.length).toEqual(1); - expect(emailRoutes[0].action.type).toEqual('socket-handler'); - expect(emailRoutes[0].match.ports).toEqual([25]); -}); - -tap.test('Socket handler functions are created correctly', async () => { - dcRouter = new DcRouter({ - dnsNsDomains: ['dns.handler.test'], - emailConfig: { - ports: [25, 465], - hostname: 'mail.handler.test', - domains: ['handler.test'], - routes: [], - useSocketHandler: true - } - }); - - // Test DNS socket handler creation - const dnsHandler = (dcRouter as any).createDnsSocketHandler(); - expect(dnsHandler).toBeDefined(); - expect(typeof dnsHandler).toEqual('function'); - - // Test email socket handler creation for different ports - const smtp25Handler = (dcRouter as any).createMailSocketHandler(25); - expect(smtp25Handler).toBeDefined(); - expect(typeof smtp25Handler).toEqual('function'); - - const smtp465Handler = (dcRouter as any).createMailSocketHandler(465); - expect(smtp465Handler).toBeDefined(); - expect(typeof smtp465Handler).toEqual('function'); - - // Handlers should be different functions - expect(smtp25Handler).not.toEqual(smtp465Handler); -}); - -tap.test('stop', async () => { - await tap.stopForcefully(); -}); - -export default tap.start(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index d66d9d7..11f7dd9 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '4.1.1', + version: '5.0.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/cache/classes.cache.cleaner.ts b/ts/cache/classes.cache.cleaner.ts index 61407a8..205825c 100644 --- a/ts/cache/classes.cache.cleaner.ts +++ b/ts/cache/classes.cache.cleaner.ts @@ -5,9 +5,6 @@ import { CacheDb } from './classes.cachedb.js'; // Import document classes for cleanup import { CachedEmail } from './documents/classes.cached.email.js'; import { CachedIPReputation } from './documents/classes.cached.ip.reputation.js'; -import { CachedBounce } from './documents/classes.cached.bounce.js'; -import { CachedSuppression } from './documents/classes.cached.suppression.js'; -import { CachedDKIMKey } from './documents/classes.cached.dkim.js'; /** * Configuration for the cache cleaner @@ -98,22 +95,12 @@ export class CacheCleaner { const results: { collection: string; deleted: number }[] = []; try { - // Clean each collection using smartdata's getInstances + delete pattern const emailsDeleted = await this.cleanExpiredDocuments(CachedEmail, now); results.push({ collection: 'CachedEmail', deleted: emailsDeleted }); const ipReputationDeleted = await this.cleanExpiredDocuments(CachedIPReputation, now); results.push({ collection: 'CachedIPReputation', deleted: ipReputationDeleted }); - const bouncesDeleted = await this.cleanExpiredDocuments(CachedBounce, now); - results.push({ collection: 'CachedBounce', deleted: bouncesDeleted }); - - const suppressionDeleted = await this.cleanExpiredDocuments(CachedSuppression, now); - results.push({ collection: 'CachedSuppression', deleted: suppressionDeleted }); - - const dkimDeleted = await this.cleanExpiredDocuments(CachedDKIMKey, now); - results.push({ collection: 'CachedDKIMKey', deleted: dkimDeleted }); - // Log results const totalDeleted = results.reduce((sum, r) => sum + r.deleted, 0); if (totalDeleted > 0 || this.options.verbose) { diff --git a/ts/cache/documents/classes.cached.bounce.ts b/ts/cache/documents/classes.cached.bounce.ts deleted file mode 100644 index 15bcb88..0000000 --- a/ts/cache/documents/classes.cached.bounce.ts +++ /dev/null @@ -1,254 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { CachedDocument, TTL } from '../classes.cached.document.js'; -import { CacheDb } from '../classes.cachedb.js'; - -/** - * Helper to get the smartdata database instance - */ -const getDb = () => CacheDb.getInstance().getDb(); - -/** - * Bounce type classification - */ -export type TBounceType = 'hard' | 'soft' | 'complaint' | 'unknown'; - -/** - * Bounce category for detailed classification - */ -export type TBounceCategory = - | 'invalid-recipient' - | 'mailbox-full' - | 'domain-not-found' - | 'connection-failed' - | 'policy-rejection' - | 'spam-rejection' - | 'rate-limited' - | 'other'; - -/** - * CachedBounce - Stores email bounce records - * - * Tracks bounce events for emails to help with deliverability - * analysis and suppression list management. - */ -@plugins.smartdata.Collection(() => getDb()) -export class CachedBounce extends CachedDocument { - // TTL fields from base class (decorators required on concrete class) - @plugins.smartdata.svDb() - public createdAt: Date = new Date(); - - @plugins.smartdata.svDb() - public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30); - - @plugins.smartdata.svDb() - public lastAccessedAt: Date = new Date(); - - /** - * Unique identifier for this bounce record - */ - @plugins.smartdata.unI() - @plugins.smartdata.svDb() - public id: string; - - /** - * Email address that bounced - */ - @plugins.smartdata.svDb() - public recipient: string; - - /** - * Sender email address - */ - @plugins.smartdata.svDb() - public sender: string; - - /** - * Recipient domain - */ - @plugins.smartdata.svDb() - public domain: string; - - /** - * Type of bounce (hard/soft/complaint) - */ - @plugins.smartdata.svDb() - public bounceType: TBounceType; - - /** - * Detailed bounce category - */ - @plugins.smartdata.svDb() - public bounceCategory: TBounceCategory; - - /** - * SMTP response code - */ - @plugins.smartdata.svDb() - public smtpCode: number; - - /** - * Full SMTP response message - */ - @plugins.smartdata.svDb() - public smtpResponse: string; - - /** - * Diagnostic code from DSN - */ - @plugins.smartdata.svDb() - public diagnosticCode: string; - - /** - * Original message ID that bounced - */ - @plugins.smartdata.svDb() - public originalMessageId: string; - - /** - * Number of bounces for this recipient - */ - @plugins.smartdata.svDb() - public bounceCount: number = 1; - - /** - * Timestamp of the first bounce - */ - @plugins.smartdata.svDb() - public firstBounceAt: Date; - - /** - * Timestamp of the most recent bounce - */ - @plugins.smartdata.svDb() - public lastBounceAt: Date; - - constructor() { - super(); - this.setTTL(TTL.DAYS_30); // Default 30-day TTL - this.bounceType = 'unknown'; - this.bounceCategory = 'other'; - this.firstBounceAt = new Date(); - this.lastBounceAt = new Date(); - } - - /** - * Create a new bounce record - */ - public static createNew(): CachedBounce { - const bounce = new CachedBounce(); - bounce.id = plugins.uuid.v4(); - return bounce; - } - - /** - * Find bounces by recipient email - */ - public static async findByRecipient(recipient: string): Promise { - return await CachedBounce.getInstances({ - recipient, - }); - } - - /** - * Find bounces by domain - */ - public static async findByDomain(domain: string): Promise { - return await CachedBounce.getInstances({ - domain, - }); - } - - /** - * Find all hard bounces - */ - public static async findHardBounces(): Promise { - return await CachedBounce.getInstances({ - bounceType: 'hard', - }); - } - - /** - * Find bounces by category - */ - public static async findByCategory(category: TBounceCategory): Promise { - return await CachedBounce.getInstances({ - bounceCategory: category, - }); - } - - /** - * Check if a recipient has recent hard bounces - */ - public static async hasRecentHardBounce(recipient: string): Promise { - const bounces = await CachedBounce.getInstances({ - recipient, - bounceType: 'hard', - }); - return bounces.length > 0; - } - - /** - * Record an additional bounce for the same recipient - */ - public recordAdditionalBounce(smtpCode?: number, smtpResponse?: string): void { - this.bounceCount++; - this.lastBounceAt = new Date(); - if (smtpCode) { - this.smtpCode = smtpCode; - } - if (smtpResponse) { - this.smtpResponse = smtpResponse; - } - this.touch(); - } - - /** - * Extract domain from recipient email - */ - public updateDomain(): void { - if (this.recipient) { - const match = this.recipient.match(/@([^>]+)>?$/); - if (match) { - this.domain = match[1].toLowerCase(); - } - } - } - - /** - * Classify bounce based on SMTP code - */ - public classifyFromSmtpCode(code: number): void { - this.smtpCode = code; - - // 5xx = permanent failure (hard bounce) - if (code >= 500 && code < 600) { - this.bounceType = 'hard'; - - if (code === 550) { - this.bounceCategory = 'invalid-recipient'; - } else if (code === 551) { - this.bounceCategory = 'policy-rejection'; - } else if (code === 552) { - this.bounceCategory = 'mailbox-full'; - } else if (code === 553) { - this.bounceCategory = 'invalid-recipient'; - } else if (code === 554) { - this.bounceCategory = 'spam-rejection'; - } - } - // 4xx = temporary failure (soft bounce) - else if (code >= 400 && code < 500) { - this.bounceType = 'soft'; - - if (code === 421) { - this.bounceCategory = 'rate-limited'; - } else if (code === 450) { - this.bounceCategory = 'mailbox-full'; - } else if (code === 451) { - this.bounceCategory = 'connection-failed'; - } else if (code === 452) { - this.bounceCategory = 'rate-limited'; - } - } - } -} diff --git a/ts/cache/documents/classes.cached.dkim.ts b/ts/cache/documents/classes.cached.dkim.ts deleted file mode 100644 index 27915f9..0000000 --- a/ts/cache/documents/classes.cached.dkim.ts +++ /dev/null @@ -1,251 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { CachedDocument, TTL } from '../classes.cached.document.js'; -import { CacheDb } from '../classes.cachedb.js'; - -/** - * Helper to get the smartdata database instance - */ -const getDb = () => CacheDb.getInstance().getDb(); - -/** - * CachedDKIMKey - Stores DKIM key pairs for email signing - * - * Caches DKIM private/public key pairs per domain and selector. - * Default TTL is 90 days (typical key rotation interval). - */ -@plugins.smartdata.Collection(() => getDb()) -export class CachedDKIMKey extends CachedDocument { - // TTL fields from base class (decorators required on concrete class) - @plugins.smartdata.svDb() - public createdAt: Date = new Date(); - - @plugins.smartdata.svDb() - public expiresAt: Date = new Date(Date.now() + TTL.DAYS_90); - - @plugins.smartdata.svDb() - public lastAccessedAt: Date = new Date(); - - /** - * Composite key: domain:selector - */ - @plugins.smartdata.unI() - @plugins.smartdata.svDb() - public domainSelector: string; - - /** - * Domain for this DKIM key - */ - @plugins.smartdata.svDb() - public domain: string; - - /** - * DKIM selector (e.g., 'mta', 'default', '2024') - */ - @plugins.smartdata.svDb() - public selector: string; - - /** - * Private key in PEM format - */ - @plugins.smartdata.svDb() - public privateKey: string; - - /** - * Public key in PEM format - */ - @plugins.smartdata.svDb() - public publicKey: string; - - /** - * Public key for DNS TXT record (base64, no headers) - */ - @plugins.smartdata.svDb() - public publicKeyDns: string; - - /** - * Key size in bits (e.g., 1024, 2048) - */ - @plugins.smartdata.svDb() - public keySize: number = 2048; - - /** - * Key algorithm (e.g., 'rsa-sha256') - */ - @plugins.smartdata.svDb() - public algorithm: string = 'rsa-sha256'; - - /** - * When the key was generated - */ - @plugins.smartdata.svDb() - public generatedAt: Date; - - /** - * When the key was last rotated - */ - @plugins.smartdata.svDb() - public rotatedAt: Date; - - /** - * Previous selector (for key rotation) - */ - @plugins.smartdata.svDb() - public previousSelector: string; - - /** - * Number of emails signed with this key - */ - @plugins.smartdata.svDb() - public signCount: number = 0; - - /** - * Whether this key is currently active - */ - @plugins.smartdata.svDb() - public isActive: boolean = true; - - constructor() { - super(); - this.setTTL(TTL.DAYS_90); // Default 90-day TTL - this.generatedAt = new Date(); - } - - /** - * Create the composite key from domain and selector - */ - public static createDomainSelector(domain: string, selector: string): string { - return `${domain.toLowerCase()}:${selector.toLowerCase()}`; - } - - /** - * Create a new DKIM key entry - */ - public static createNew(domain: string, selector: string): CachedDKIMKey { - const key = new CachedDKIMKey(); - key.domain = domain.toLowerCase(); - key.selector = selector.toLowerCase(); - key.domainSelector = CachedDKIMKey.createDomainSelector(domain, selector); - return key; - } - - /** - * Find by domain and selector - */ - public static async findByDomainSelector( - domain: string, - selector: string - ): Promise { - const domainSelector = CachedDKIMKey.createDomainSelector(domain, selector); - return await CachedDKIMKey.getInstance({ - domainSelector, - }); - } - - /** - * Find all keys for a domain - */ - public static async findByDomain(domain: string): Promise { - return await CachedDKIMKey.getInstances({ - domain: domain.toLowerCase(), - }); - } - - /** - * Find the active key for a domain - */ - public static async findActiveForDomain(domain: string): Promise { - const keys = await CachedDKIMKey.getInstances({ - domain: domain.toLowerCase(), - isActive: true, - }); - return keys.length > 0 ? keys[0] : null; - } - - /** - * Find all active keys - */ - public static async findAllActive(): Promise { - return await CachedDKIMKey.getInstances({ - isActive: true, - }); - } - - /** - * Set the key pair - */ - public setKeyPair(privateKey: string, publicKey: string, publicKeyDns?: string): void { - this.privateKey = privateKey; - this.publicKey = publicKey; - this.publicKeyDns = publicKeyDns || this.extractPublicKeyDns(publicKey); - this.generatedAt = new Date(); - } - - /** - * Extract the base64 public key for DNS from PEM format - */ - private extractPublicKeyDns(publicKeyPem: string): string { - // Remove PEM headers and newlines - return publicKeyPem - .replace(/-----BEGIN PUBLIC KEY-----/g, '') - .replace(/-----END PUBLIC KEY-----/g, '') - .replace(/\s/g, ''); - } - - /** - * Generate the DNS TXT record value - */ - public getDnsTxtRecord(): string { - return `v=DKIM1; k=rsa; p=${this.publicKeyDns}`; - } - - /** - * Get the full DNS record name - */ - public getDnsRecordName(): string { - return `${this.selector}._domainkey.${this.domain}`; - } - - /** - * Record that this key was used to sign an email - */ - public recordSign(): void { - this.signCount++; - this.touch(); - } - - /** - * Deactivate this key (e.g., during rotation) - */ - public deactivate(): void { - this.isActive = false; - } - - /** - * Activate this key - */ - public activate(): void { - this.isActive = true; - } - - /** - * Rotate to a new selector - */ - public rotate(newSelector: string): void { - this.previousSelector = this.selector; - this.selector = newSelector.toLowerCase(); - this.domainSelector = CachedDKIMKey.createDomainSelector(this.domain, this.selector); - this.rotatedAt = new Date(); - this.signCount = 0; - // Reset TTL on rotation - this.setTTL(TTL.DAYS_90); - } - - /** - * Check if key needs rotation (based on age or sign count) - */ - public needsRotation(maxAgeDays: number = 90, maxSignCount: number = 1000000): boolean { - const ageMs = Date.now() - this.generatedAt.getTime(); - const ageDays = ageMs / (24 * 60 * 60 * 1000); - return ageDays > maxAgeDays || this.signCount > maxSignCount; - } -} diff --git a/ts/cache/documents/classes.cached.suppression.ts b/ts/cache/documents/classes.cached.suppression.ts deleted file mode 100644 index 5a239f1..0000000 --- a/ts/cache/documents/classes.cached.suppression.ts +++ /dev/null @@ -1,272 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { CachedDocument, TTL } from '../classes.cached.document.js'; -import { CacheDb } from '../classes.cachedb.js'; - -/** - * Helper to get the smartdata database instance - */ -const getDb = () => CacheDb.getInstance().getDb(); - -/** - * Reason for suppression - */ -export type TSuppressionReason = - | 'hard-bounce' - | 'soft-bounce-exceeded' - | 'complaint' - | 'unsubscribe' - | 'manual' - | 'spam-trap' - | 'invalid-address'; - -/** - * CachedSuppression - Stores email suppression list entries - * - * Emails to addresses in the suppression list should not be sent. - * Supports both temporary (30-day) and permanent suppression. - */ -@plugins.smartdata.Collection(() => getDb()) -export class CachedSuppression extends CachedDocument { - // TTL fields from base class (decorators required on concrete class) - @plugins.smartdata.svDb() - public createdAt: Date = new Date(); - - @plugins.smartdata.svDb() - public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30); - - @plugins.smartdata.svDb() - public lastAccessedAt: Date = new Date(); - - /** - * Email address to suppress (unique identifier) - */ - @plugins.smartdata.unI() - @plugins.smartdata.svDb() - public email: string; - - /** - * Reason for suppression - */ - @plugins.smartdata.svDb() - public reason: TSuppressionReason; - - /** - * Human-readable description of why this address is suppressed - */ - @plugins.smartdata.svDb() - public description: string; - - /** - * Whether this is a permanent suppression - */ - @plugins.smartdata.svDb() - public permanent: boolean = false; - - /** - * Number of times we've tried to send to this address after suppression - */ - @plugins.smartdata.svDb() - public blockedAttempts: number = 0; - - /** - * Domain of the suppressed email - */ - @plugins.smartdata.svDb() - public domain: string; - - /** - * Related bounce record ID (if suppressed due to bounce) - */ - @plugins.smartdata.svDb() - public relatedBounceId: string; - - /** - * Source that caused the suppression (e.g., campaign ID, message ID) - */ - @plugins.smartdata.svDb() - public source: string; - - /** - * Date when the suppression was first created - */ - @plugins.smartdata.svDb() - public suppressedAt: Date; - - constructor() { - super(); - this.setTTL(TTL.DAYS_30); // Default 30-day TTL - this.suppressedAt = new Date(); - this.blockedAttempts = 0; - } - - /** - * Create a new suppression entry - */ - public static createNew(email: string, reason: TSuppressionReason): CachedSuppression { - const suppression = new CachedSuppression(); - suppression.email = email.toLowerCase().trim(); - suppression.reason = reason; - suppression.updateDomain(); - - // Hard bounces and spam traps should be permanent - if (reason === 'hard-bounce' || reason === 'spam-trap' || reason === 'complaint') { - suppression.setPermanent(); - } - - return suppression; - } - - /** - * Make this suppression permanent (never expires) - */ - public setPermanent(): void { - this.permanent = true; - this.setNeverExpires(); - } - - /** - * Make this suppression temporary with specific TTL - */ - public setTemporary(ttlMs: number): void { - this.permanent = false; - this.setTTL(ttlMs); - } - - /** - * Extract domain from email - */ - public updateDomain(): void { - if (this.email) { - const match = this.email.match(/@(.+)$/); - if (match) { - this.domain = match[1].toLowerCase(); - } - } - } - - /** - * Check if an email is suppressed - */ - public static async isSuppressed(email: string): Promise { - const normalizedEmail = email.toLowerCase().trim(); - const entry = await CachedSuppression.getInstance({ - email: normalizedEmail, - }); - return entry !== null && !entry.isExpired(); - } - - /** - * Get suppression entry for an email - */ - public static async findByEmail(email: string): Promise { - const normalizedEmail = email.toLowerCase().trim(); - return await CachedSuppression.getInstance({ - email: normalizedEmail, - }); - } - - /** - * Find all suppressions for a domain - */ - public static async findByDomain(domain: string): Promise { - return await CachedSuppression.getInstances({ - domain: domain.toLowerCase(), - }); - } - - /** - * Find all permanent suppressions - */ - public static async findPermanent(): Promise { - return await CachedSuppression.getInstances({ - permanent: true, - }); - } - - /** - * Find all suppressions by reason - */ - public static async findByReason(reason: TSuppressionReason): Promise { - return await CachedSuppression.getInstances({ - reason, - }); - } - - /** - * Record a blocked attempt to send to this address - */ - public recordBlockedAttempt(): void { - this.blockedAttempts++; - this.touch(); - } - - /** - * Remove suppression (delete from database) - */ - public static async remove(email: string): Promise { - const normalizedEmail = email.toLowerCase().trim(); - const entry = await CachedSuppression.getInstance({ - email: normalizedEmail, - }); - if (entry) { - await entry.delete(); - return true; - } - return false; - } - - /** - * Add or update a suppression entry - */ - public static async addOrUpdate( - email: string, - reason: TSuppressionReason, - options?: { - permanent?: boolean; - description?: string; - source?: string; - relatedBounceId?: string; - } - ): Promise { - const normalizedEmail = email.toLowerCase().trim(); - - // Check if already suppressed - let entry = await CachedSuppression.findByEmail(normalizedEmail); - - if (entry) { - // Update existing entry - entry.reason = reason; - if (options?.permanent) { - entry.setPermanent(); - } - if (options?.description) { - entry.description = options.description; - } - if (options?.source) { - entry.source = options.source; - } - if (options?.relatedBounceId) { - entry.relatedBounceId = options.relatedBounceId; - } - entry.touch(); - } else { - // Create new entry - entry = CachedSuppression.createNew(normalizedEmail, reason); - if (options?.permanent) { - entry.setPermanent(); - } - if (options?.description) { - entry.description = options.description; - } - if (options?.source) { - entry.source = options.source; - } - if (options?.relatedBounceId) { - entry.relatedBounceId = options.relatedBounceId; - } - } - - await entry.save(); - return entry; - } -} diff --git a/ts/cache/documents/index.ts b/ts/cache/documents/index.ts index 909bfb7..10a0ad9 100644 --- a/ts/cache/documents/index.ts +++ b/ts/cache/documents/index.ts @@ -1,5 +1,2 @@ export * from './classes.cached.email.js'; export * from './classes.cached.ip.reputation.js'; -export * from './classes.cached.bounce.js'; -export * from './classes.cached.suppression.js'; -export * from './classes.cached.dkim.js'; diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 6f8e1cf..b859584 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -3,12 +3,14 @@ import * as paths from './paths.js'; // Certificate types are available via plugins.tsclass -// Import the email server and its configuration -import { UnifiedEmailServer, type IUnifiedEmailServerOptions } from './mail/routing/classes.unified.email.server.js'; -import type { IEmailRoute, IEmailDomainConfig } from './mail/routing/interfaces.js'; +// Import the email server and its configuration from smartmta +import { + UnifiedEmailServer, + type IUnifiedEmailServerOptions, + type IEmailRoute, + type IEmailDomainConfig, +} from '@push.rocks/smartmta'; import { logger } from './logger.js'; -// Import the email configuration helpers directly from mail/delivery -import { configureEmailStorage, configureEmailServer } from './mail/delivery/index.js'; // Import storage manager import { StorageManager, type IStorageConfig } from './storage/index.js'; // Import cache system @@ -221,12 +223,6 @@ export class DcRouter { // Set up unified email handling if configured if (this.options.emailConfig) { await this.setupUnifiedEmailHandling(); - - // Apply custom email storage configuration if available - if (this.emailServer && this.options.emailPortConfig?.receivedEmailsPath) { - logger.log('info', 'Applying custom email storage configuration'); - configureEmailStorage(this.emailServer, this.options); - } } // Set up DNS server if configured with nameservers and scopes @@ -533,37 +529,26 @@ export class DcRouter { break; } - // Create action based on mode - let action: any; - - if (emailConfig.useSocketHandler) { - // Socket-handler mode - action = { - type: 'socket-handler' as any, - socketHandler: this.createMailSocketHandler(port) - }; - } else { - // Traditional forwarding mode - const defaultPortMapping = { - 25: 10025, // SMTP - 587: 10587, // Submission - 465: 10465 // SMTPS - }; - - const portMapping = this.options.emailPortConfig?.portMapping || defaultPortMapping; - const internalPort = portMapping[port] || port + 10000; - - action = { - type: 'forward', - target: { - host: 'localhost', // Forward to internal email server - port: internalPort - }, - tls: { - mode: tlsMode as any - } - }; - } + // Create forward action to route to internal email server ports + const defaultPortMapping: Record = { + 25: 10025, // SMTP + 587: 10587, // Submission + 465: 10465 // SMTPS + }; + + const portMapping = this.options.emailPortConfig?.portMapping || defaultPortMapping; + const internalPort = portMapping[port] || port + 10000; + + let action: any = { + type: 'forward', + targets: [{ + host: 'localhost', // Forward to internal email server + port: internalPort + }], + tls: { + mode: tlsMode as any + } + }; // For TLS terminate mode, add certificate info if (tlsMode === 'terminate' && action.tls) { @@ -845,7 +830,7 @@ export class DcRouter { // Update the unified email server if it exists if (this.emailServer) { - this.emailServer.updateRoutes(routes); + this.emailServer.updateEmailRoutes(routes); } console.log(`Email routes updated with ${routes.length} routes`); @@ -862,67 +847,6 @@ export class DcRouter { return stats; } - /** - * Configure MTA for email handling with custom port and storage settings - * @param config Configuration for the MTA service - */ - public async configureEmailMta(config: { - internalPort: number; - host?: string; - secure?: boolean; - storagePath?: string; - portMapping?: Record; - }): Promise { - logger.log('info', 'Configuring MTA service with custom settings'); - - - // Update email port configuration - if (!this.options.emailPortConfig) { - this.options.emailPortConfig = {}; - } - - // Configure storage paths for received emails - if (config.storagePath) { - // Set the storage path for received emails - this.options.emailPortConfig.receivedEmailsPath = config.storagePath; - } - - // Apply port mapping if provided - if (config.portMapping) { - this.options.emailPortConfig.portMapping = { - ...this.options.emailPortConfig.portMapping, - ...config.portMapping - }; - - logger.log('info', `Updated MTA port mappings: ${JSON.stringify(this.options.emailPortConfig.portMapping)}`); - } - - // Use the dedicated helper to configure the email server - // Pass through the options specified by the implementation - if (this.emailServer) { - configureEmailServer(this.emailServer, { - ports: [config.internalPort], // Use whatever port the implementation specifies - hostname: config.host, - tls: config.secure ? { - // Basic TLS settings if secure mode is enabled - certPath: this.options.tls?.certPath, - keyPath: this.options.tls?.keyPath, - caPath: this.options.tls?.caPath - } : undefined, - storagePath: config.storagePath - }); - } - - // If email handling is already set up, restart it to apply changes - if (this.emailServer) { - logger.log('info', 'Restarting unified email handling to apply MTA configuration changes'); - await this.stopUnifiedEmailComponents(); - await this.setupUnifiedEmailHandling(); - } - - return true; - } - /** * Register DNS records with the DNS server * @param records Array of DNS records to register @@ -1245,8 +1169,8 @@ export class DcRouter { logger.log('info', 'Initializing DKIM keys for email domains...'); - // Get DKIMCreator instance from email server - const dkimCreator = (this.emailServer as any).dkimCreator; + // Get DKIMCreator instance from email server (public in smartmta) + const dkimCreator = this.emailServer.dkimCreator; if (!dkimCreator) { logger.log('warn', 'DKIMCreator not available, skipping DKIM initialization'); return; @@ -1408,51 +1332,6 @@ export class DcRouter { } } - /** - * Create mail socket handler for email traffic - */ - private createMailSocketHandler(port: number): (socket: plugins.net.Socket) => Promise { - return async (socket: plugins.net.Socket) => { - if (!this.emailServer) { - logger.log('error', 'Mail socket handler called but email server not initialized'); - socket.end(); - return; - } - - logger.log('debug', `Mail socket handler: handling connection for port ${port}`); - - try { - // Port 465 requires immediate TLS - if (port === 465) { - // Wrap the socket in TLS - const tlsOptions = { - isServer: true, - key: this.options.tls?.keyPath ? plugins.fs.readFileSync(this.options.tls.keyPath, 'utf8') : undefined, - cert: this.options.tls?.certPath ? plugins.fs.readFileSync(this.options.tls.certPath, 'utf8') : undefined - }; - - const tlsSocket = new plugins.tls.TLSSocket(socket, tlsOptions); - - tlsSocket.on('secure', () => { - // Pass the secure socket to the email server - this.emailServer!.handleSocket(tlsSocket, port); - }); - - tlsSocket.on('error', (err) => { - logger.log('error', `TLS handshake error on port ${port}: ${err.message}`); - socket.destroy(); - }); - } else { - // For ports 25 and 587, pass raw socket (STARTTLS handled by email server) - await this.emailServer.handleSocket(socket, port); - } - } catch (error) { - logger.log('error', `Mail socket handler error on port ${port}: ${error.message}`); - socket.destroy(); - } - }; - } - /** * Set up RADIUS server for network authentication */ diff --git a/ts/deliverability/classes.ipwarmupmanager.ts b/ts/deliverability/classes.ipwarmupmanager.ts deleted file mode 100644 index aa42901..0000000 --- a/ts/deliverability/classes.ipwarmupmanager.ts +++ /dev/null @@ -1,896 +0,0 @@ -import * as plugins from '../plugins.js'; -import * as paths from '../paths.js'; -import { logger } from '../logger.js'; -import { LRUCache } from 'lru-cache'; - -/** - * Represents a single stage in the warmup process - */ -export interface IWarmupStage { - /** Stage number (1-based) */ - stage: number; - /** Maximum daily email volume for this stage */ - maxDailyVolume: number; - /** Duration of this stage in days */ - durationDays: number; - /** Target engagement metrics for this stage */ - targetMetrics?: { - /** Minimum open rate (percentage) */ - minOpenRate?: number; - /** Maximum bounce rate (percentage) */ - maxBounceRate?: number; - /** Maximum spam complaint rate (percentage) */ - maxComplaintRate?: number; - }; -} - -/** - * Configuration for IP warmup process - */ -export interface IIPWarmupConfig { - /** Whether the warmup is enabled */ - enabled?: boolean; - /** List of IP addresses to warm up */ - ipAddresses?: string[]; - /** Target domains to warm up (e.g. your sending domains) */ - targetDomains?: string[]; - /** Warmup stages defining volume and duration */ - stages?: IWarmupStage[]; - /** Date when warmup process started */ - startDate?: Date; - /** Default hourly distribution for sending (percentage of daily volume per hour) */ - hourlyDistribution?: number[]; - /** Whether to automatically advance stages based on metrics */ - autoAdvanceStages?: boolean; - /** Whether to suspend warmup if metrics decline */ - suspendOnMetricDecline?: boolean; - /** Percentage of traffic to send through fallback provider during warmup */ - fallbackPercentage?: number; - /** Whether to prioritize engaged subscribers during warmup */ - prioritizeEngagedSubscribers?: boolean; -} - -/** - * Status for a specific IP's warmup process - */ -export interface IIPWarmupStatus { - /** IP address being warmed up */ - ipAddress: string; - /** Current warmup stage */ - currentStage: number; - /** Start date of the warmup process */ - startDate: Date; - /** Start date of the current stage */ - currentStageStartDate: Date; - /** Target completion date for entire warmup */ - targetCompletionDate: Date; - /** Daily volume allocation for current stage */ - currentDailyAllocation: number; - /** Emails sent in current stage */ - sentInCurrentStage: number; - /** Total emails sent during warmup process */ - totalSent: number; - /** Whether the warmup is currently active */ - isActive: boolean; - /** Daily statistics for the past week */ - dailyStats: Array<{ - /** Date of the statistics */ - date: string; - /** Number of emails sent */ - sent: number; - /** Number of emails opened */ - opened: number; - /** Number of bounces */ - bounces: number; - /** Number of spam complaints */ - complaints: number; - }>; - /** Current metrics */ - metrics: { - /** Open rate percentage */ - openRate: number; - /** Bounce rate percentage */ - bounceRate: number; - /** Complaint rate percentage */ - complaintRate: number; - }; -} - -/** - * Defines methods for a policy used to allocate emails to different IPs - */ -export interface IIPAllocationPolicy { - /** Name of the policy */ - name: string; - - /** - * Allocate an IP address for sending an email - * @param availableIPs List of available IP addresses - * @param emailInfo Information about the email being sent - * @returns The IP to use, or null if no IP is available - */ - allocateIP( - availableIPs: Array<{ ip: string; priority: number; capacity: number }>, - emailInfo: { - from: string; - to: string[]; - domain: string; - isTransactional: boolean; - isWarmup: boolean; - } - ): string | null; -} - -/** - * Default IP warmup configuration with industry standard stages - */ -const DEFAULT_WARMUP_CONFIG: Required = { - enabled: true, - ipAddresses: [], - targetDomains: [], - stages: [ - { stage: 1, maxDailyVolume: 50, durationDays: 2, targetMetrics: { maxBounceRate: 8, minOpenRate: 15 } }, - { stage: 2, maxDailyVolume: 100, durationDays: 2, targetMetrics: { maxBounceRate: 7, minOpenRate: 18 } }, - { stage: 3, maxDailyVolume: 500, durationDays: 3, targetMetrics: { maxBounceRate: 6, minOpenRate: 20 } }, - { stage: 4, maxDailyVolume: 1000, durationDays: 3, targetMetrics: { maxBounceRate: 5, minOpenRate: 20 } }, - { stage: 5, maxDailyVolume: 5000, durationDays: 5, targetMetrics: { maxBounceRate: 3, minOpenRate: 22 } }, - { stage: 6, maxDailyVolume: 10000, durationDays: 5, targetMetrics: { maxBounceRate: 2, minOpenRate: 25 } }, - { stage: 7, maxDailyVolume: 20000, durationDays: 5, targetMetrics: { maxBounceRate: 1, minOpenRate: 25 } }, - { stage: 8, maxDailyVolume: 50000, durationDays: 5, targetMetrics: { maxBounceRate: 1, minOpenRate: 25 } }, - ], - startDate: new Date(), - // Default hourly distribution (percentage per hour, sums to 100%) - hourlyDistribution: [ - 1, 1, 1, 1, 1, 2, 3, 5, 7, 8, 10, 11, - 10, 9, 8, 6, 5, 4, 3, 2, 1, 1, 1, 0 - ], - autoAdvanceStages: true, - suspendOnMetricDecline: true, - fallbackPercentage: 50, - prioritizeEngagedSubscribers: true -}; - -/** - * Manages the IP warming process for new sending IPs - */ -export class IPWarmupManager { - private static instance: IPWarmupManager; - private config: Required; - private warmupStatuses: Map = new Map(); - private dailySendCounts: Map = new Map(); - private hourlySendCounts: Map = new Map(); - private isInitialized: boolean = false; - private allocationPolicies: Map = new Map(); - private activePolicy: string = 'balanced'; - - /** - * Constructor for IPWarmupManager - * @param config Warmup configuration - */ - constructor(config: IIPWarmupConfig = {}) { - this.config = { - ...DEFAULT_WARMUP_CONFIG, - ...config, - stages: config.stages || [...DEFAULT_WARMUP_CONFIG.stages] - }; - - // Register default allocation policies - this.registerAllocationPolicy('balanced', new BalancedAllocationPolicy()); - this.registerAllocationPolicy('roundRobin', new RoundRobinAllocationPolicy()); - this.registerAllocationPolicy('dedicated', new DedicatedDomainPolicy()); - - this.initialize(); - } - - /** - * Get the singleton instance of IPWarmupManager - * @param config Warmup configuration - * @returns Singleton instance - */ - public static getInstance(config: IIPWarmupConfig = {}): IPWarmupManager { - if (!IPWarmupManager.instance) { - IPWarmupManager.instance = new IPWarmupManager(config); - } - return IPWarmupManager.instance; - } - - /** - * Initialize the warmup manager - */ - private initialize(): void { - if (this.isInitialized) return; - - try { - // Load warmup statuses from storage - this.loadWarmupStatuses(); - - // Initialize any new IPs that might have been added to config - for (const ip of this.config.ipAddresses) { - if (!this.warmupStatuses.has(ip)) { - this.initializeIPWarmup(ip); - } - } - - // Initialize daily and hourly counters - const today = new Date().toISOString().split('T')[0]; - for (const ip of this.config.ipAddresses) { - this.dailySendCounts.set(ip, 0); - this.hourlySendCounts.set(ip, Array(24).fill(0)); - } - - // Schedule daily reset of counters - this.scheduleDailyReset(); - - // Schedule daily evaluation of warmup progress - this.scheduleDailyEvaluation(); - - this.isInitialized = true; - logger.log('info', `IP Warmup Manager initialized with ${this.config.ipAddresses.length} IPs`); - } catch (error) { - logger.log('error', `Failed to initialize IP Warmup Manager: ${error.message}`, { - stack: error.stack - }); - } - } - - /** - * Initialize warmup status for a new IP address - * @param ipAddress IP address to initialize - */ - private initializeIPWarmup(ipAddress: string): void { - const startDate = new Date(); - let targetCompletionDate = new Date(startDate); - - // Calculate target completion date based on stages - let totalDays = 0; - for (const stage of this.config.stages) { - totalDays += stage.durationDays; - } - - targetCompletionDate.setDate(targetCompletionDate.getDate() + totalDays); - - const warmupStatus: IIPWarmupStatus = { - ipAddress, - currentStage: 1, - startDate, - currentStageStartDate: new Date(), - targetCompletionDate, - currentDailyAllocation: this.config.stages[0].maxDailyVolume, - sentInCurrentStage: 0, - totalSent: 0, - isActive: true, - dailyStats: [], - metrics: { - openRate: 0, - bounceRate: 0, - complaintRate: 0 - } - }; - - this.warmupStatuses.set(ipAddress, warmupStatus); - this.saveWarmupStatuses(); - - logger.log('info', `Initialized warmup for IP ${ipAddress}`, { - currentStage: 1, - targetCompletion: targetCompletionDate.toISOString().split('T')[0] - }); - } - - /** - * Schedule daily reset of send counters - */ - private scheduleDailyReset(): void { - // Calculate time until midnight - const now = new Date(); - const tomorrow = new Date(now); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(0, 0, 0, 0); - - const timeUntilMidnight = tomorrow.getTime() - now.getTime(); - - // Schedule reset - setTimeout(() => { - this.resetDailyCounts(); - // Reschedule for next day - this.scheduleDailyReset(); - }, timeUntilMidnight); - - logger.log('info', `Scheduled daily counter reset in ${Math.floor(timeUntilMidnight / 60000)} minutes`); - } - - /** - * Reset daily send counters - */ - private resetDailyCounts(): void { - for (const ip of this.config.ipAddresses) { - // Save yesterday's count to history before resetting - const status = this.warmupStatuses.get(ip); - if (status) { - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - - // Update daily stats with yesterday's data - const sentCount = this.dailySendCounts.get(ip) || 0; - status.dailyStats.push({ - date: yesterday.toISOString().split('T')[0], - sent: sentCount, - opened: Math.floor(sentCount * status.metrics.openRate / 100), - bounces: Math.floor(sentCount * status.metrics.bounceRate / 100), - complaints: Math.floor(sentCount * status.metrics.complaintRate / 100) - }); - - // Keep only the last 7 days of stats - if (status.dailyStats.length > 7) { - status.dailyStats.shift(); - } - } - - // Reset counters for today - this.dailySendCounts.set(ip, 0); - this.hourlySendCounts.set(ip, Array(24).fill(0)); - } - - // Save updated statuses - this.saveWarmupStatuses(); - - logger.log('info', 'Daily send counters reset'); - } - - /** - * Schedule daily evaluation of warmup progress - */ - private scheduleDailyEvaluation(): void { - // Calculate time until 1 AM (do evaluation after midnight) - const now = new Date(); - const evaluationTime = new Date(now); - evaluationTime.setDate(evaluationTime.getDate() + 1); - evaluationTime.setHours(1, 0, 0, 0); - - const timeUntilEvaluation = evaluationTime.getTime() - now.getTime(); - - // Schedule evaluation - setTimeout(() => { - this.evaluateWarmupProgress(); - // Reschedule for next day - this.scheduleDailyEvaluation(); - }, timeUntilEvaluation); - - logger.log('info', `Scheduled daily warmup evaluation in ${Math.floor(timeUntilEvaluation / 60000)} minutes`); - } - - /** - * Evaluate warmup progress and possibly advance stages - */ - private evaluateWarmupProgress(): void { - if (!this.config.autoAdvanceStages) { - logger.log('info', 'Auto-advance stages is disabled, skipping evaluation'); - return; - } - - // Convert entries to array for compatibility with older JS versions - Array.from(this.warmupStatuses.entries()).forEach(([ip, status]) => { - if (!status.isActive) return; - - // Check if current stage duration has elapsed - const currentStage = this.config.stages[status.currentStage - 1]; - const now = new Date(); - const daysSinceStageStart = Math.floor( - (now.getTime() - status.currentStageStartDate.getTime()) / (24 * 60 * 60 * 1000) - ); - - if (daysSinceStageStart >= currentStage.durationDays) { - // Check if metrics meet requirements for advancing - const metricsOK = this.checkStageMetrics(status, currentStage); - - if (metricsOK) { - // Advance to next stage if not at the final stage - if (status.currentStage < this.config.stages.length) { - this.advanceToNextStage(ip); - } else { - logger.log('info', `IP ${ip} has completed the warmup process`); - } - } else if (this.config.suspendOnMetricDecline) { - // Suspend warmup if metrics don't meet requirements - status.isActive = false; - logger.log('warn', `Suspended warmup for IP ${ip} due to poor metrics`, { - openRate: status.metrics.openRate, - bounceRate: status.metrics.bounceRate, - complaintRate: status.metrics.complaintRate - }); - } else { - // Extend current stage if metrics don't meet requirements - logger.log('info', `Extending stage ${status.currentStage} for IP ${ip} due to metrics not meeting requirements`); - } - } - }); - - // Save updated statuses - this.saveWarmupStatuses(); - } - - /** - * Check if the current metrics meet the requirements for the stage - * @param status Warmup status to check - * @param stage Stage to check against - * @returns Whether metrics meet requirements - */ - private checkStageMetrics(status: IIPWarmupStatus, stage: IWarmupStage): boolean { - // If no target metrics specified, assume met - if (!stage.targetMetrics) return true; - - const metrics = status.metrics; - let meetsRequirements = true; - - // Check each metric against requirements - if (stage.targetMetrics.minOpenRate !== undefined && - metrics.openRate < stage.targetMetrics.minOpenRate) { - meetsRequirements = false; - logger.log('info', `Open rate ${metrics.openRate}% below target ${stage.targetMetrics.minOpenRate}% for IP ${status.ipAddress}`); - } - - if (stage.targetMetrics.maxBounceRate !== undefined && - metrics.bounceRate > stage.targetMetrics.maxBounceRate) { - meetsRequirements = false; - logger.log('info', `Bounce rate ${metrics.bounceRate}% above target ${stage.targetMetrics.maxBounceRate}% for IP ${status.ipAddress}`); - } - - if (stage.targetMetrics.maxComplaintRate !== undefined && - metrics.complaintRate > stage.targetMetrics.maxComplaintRate) { - meetsRequirements = false; - logger.log('info', `Complaint rate ${metrics.complaintRate}% above target ${stage.targetMetrics.maxComplaintRate}% for IP ${status.ipAddress}`); - } - - return meetsRequirements; - } - - /** - * Advance IP to the next warmup stage - * @param ipAddress IP address to advance - */ - private advanceToNextStage(ipAddress: string): void { - const status = this.warmupStatuses.get(ipAddress); - if (!status) return; - - // Store metrics for the completed stage - const completedStage = status.currentStage; - - // Advance to next stage - status.currentStage++; - status.currentStageStartDate = new Date(); - status.sentInCurrentStage = 0; - - // Update allocation - const newStage = this.config.stages[status.currentStage - 1]; - status.currentDailyAllocation = newStage.maxDailyVolume; - - logger.log('info', `Advanced IP ${ipAddress} to warmup stage ${status.currentStage}`, { - previousStage: completedStage, - newDailyLimit: status.currentDailyAllocation, - durationDays: newStage.durationDays - }); - } - - /** - * Get warmup status for all IPs or a specific IP - * @param ipAddress Optional specific IP to get status for - * @returns Warmup status information - */ - public getWarmupStatus(ipAddress?: string): IIPWarmupStatus | Map { - if (ipAddress) { - return this.warmupStatuses.get(ipAddress); - } - return this.warmupStatuses; - } - - /** - * Add a new IP address to the warmup process - * @param ipAddress IP address to add - */ - public addIPToWarmup(ipAddress: string): void { - if (this.config.ipAddresses.includes(ipAddress)) { - logger.log('info', `IP ${ipAddress} is already in warmup`); - return; - } - - // Add to configuration - this.config.ipAddresses.push(ipAddress); - - // Initialize warmup - this.initializeIPWarmup(ipAddress); - - // Initialize counters - this.dailySendCounts.set(ipAddress, 0); - this.hourlySendCounts.set(ipAddress, Array(24).fill(0)); - - logger.log('info', `Added IP ${ipAddress} to warmup process`); - } - - /** - * Remove an IP address from the warmup process - * @param ipAddress IP address to remove - */ - public removeIPFromWarmup(ipAddress: string): void { - const index = this.config.ipAddresses.indexOf(ipAddress); - if (index === -1) { - logger.log('info', `IP ${ipAddress} is not in warmup`); - return; - } - - // Remove from configuration - this.config.ipAddresses.splice(index, 1); - - // Remove from statuses and counters - this.warmupStatuses.delete(ipAddress); - this.dailySendCounts.delete(ipAddress); - this.hourlySendCounts.delete(ipAddress); - - this.saveWarmupStatuses(); - - logger.log('info', `Removed IP ${ipAddress} from warmup process`); - } - - /** - * Update metrics for an IP address - * @param ipAddress IP address to update - * @param metrics New metrics - */ - public updateMetrics( - ipAddress: string, - metrics: { openRate?: number; bounceRate?: number; complaintRate?: number } - ): void { - const status = this.warmupStatuses.get(ipAddress); - if (!status) { - logger.log('warn', `Cannot update metrics for IP ${ipAddress} - not in warmup`); - return; - } - - // Update metrics - if (metrics.openRate !== undefined) { - status.metrics.openRate = metrics.openRate; - } - - if (metrics.bounceRate !== undefined) { - status.metrics.bounceRate = metrics.bounceRate; - } - - if (metrics.complaintRate !== undefined) { - status.metrics.complaintRate = metrics.complaintRate; - } - - this.saveWarmupStatuses(); - - logger.log('info', `Updated metrics for IP ${ipAddress}`, { - openRate: status.metrics.openRate, - bounceRate: status.metrics.bounceRate, - complaintRate: status.metrics.complaintRate - }); - } - - /** - * Record a send event for an IP address - * @param ipAddress IP address used for sending - */ - public recordSend(ipAddress: string): void { - if (!this.config.ipAddresses.includes(ipAddress)) { - logger.log('warn', `Cannot record send for IP ${ipAddress} - not in warmup`); - return; - } - - // Increment daily counter - const currentCount = this.dailySendCounts.get(ipAddress) || 0; - this.dailySendCounts.set(ipAddress, currentCount + 1); - - // Increment hourly counter - const hourlyCount = this.hourlySendCounts.get(ipAddress) || Array(24).fill(0); - const currentHour = new Date().getHours(); - hourlyCount[currentHour]++; - this.hourlySendCounts.set(ipAddress, hourlyCount); - - // Update warmup status - const status = this.warmupStatuses.get(ipAddress); - if (status) { - status.sentInCurrentStage++; - status.totalSent++; - } - } - - /** - * Check if an IP can send more emails today - * @param ipAddress IP address to check - * @returns Whether the IP can send more emails - */ - public canSendMoreToday(ipAddress: string): boolean { - if (!this.config.enabled) return true; - - if (!this.config.ipAddresses.includes(ipAddress)) { - // If not in warmup, assume it can send - return true; - } - - const status = this.warmupStatuses.get(ipAddress); - if (!status || !status.isActive) { - return false; - } - - const currentCount = this.dailySendCounts.get(ipAddress) || 0; - return currentCount < status.currentDailyAllocation; - } - - /** - * Check if an IP can send more emails in the current hour - * @param ipAddress IP address to check - * @returns Whether the IP can send more emails this hour - */ - public canSendMoreThisHour(ipAddress: string): boolean { - if (!this.config.enabled) return true; - - if (!this.config.ipAddresses.includes(ipAddress)) { - // If not in warmup, assume it can send - return true; - } - - const status = this.warmupStatuses.get(ipAddress); - if (!status || !status.isActive) { - return false; - } - - const currentDailyLimit = status.currentDailyAllocation; - const currentHour = new Date().getHours(); - const hourlyAllocation = Math.ceil((currentDailyLimit * this.config.hourlyDistribution[currentHour]) / 100); - - const hourlyCount = this.hourlySendCounts.get(ipAddress) || Array(24).fill(0); - const currentHourCount = hourlyCount[currentHour]; - - return currentHourCount < hourlyAllocation; - } - - /** - * Get the best IP to use for sending an email - * @param emailInfo Information about the email being sent - * @returns The best IP to use, or null if no suitable IP is available - */ - public getBestIPForSending(emailInfo: { - from: string; - to: string[]; - domain: string; - isTransactional?: boolean; - }): string | null { - // If warmup is disabled, return null (caller will use default IP) - if (!this.config.enabled || this.config.ipAddresses.length === 0) { - return null; - } - - // Prepare information for allocation policy - const availableIPs = this.config.ipAddresses - .filter(ip => this.canSendMoreToday(ip) && this.canSendMoreThisHour(ip)) - .map(ip => { - const status = this.warmupStatuses.get(ip); - return { - ip, - priority: status ? status.currentStage : 1, - capacity: status ? (status.currentDailyAllocation - (this.dailySendCounts.get(ip) || 0)) : 0 - }; - }); - - // Use the active allocation policy to determine the best IP - const policy = this.allocationPolicies.get(this.activePolicy); - if (!policy) { - logger.log('warn', `No allocation policy named ${this.activePolicy} found`); - return null; - } - - return policy.allocateIP(availableIPs, { - ...emailInfo, - isTransactional: emailInfo.isTransactional || false, - isWarmup: true - }); - } - - /** - * Register a new IP allocation policy - * @param name Policy name - * @param policy Policy implementation - */ - public registerAllocationPolicy(name: string, policy: IIPAllocationPolicy): void { - this.allocationPolicies.set(name, policy); - logger.log('info', `Registered IP allocation policy: ${name}`); - } - - /** - * Set the active IP allocation policy - * @param name Policy name - */ - public setActiveAllocationPolicy(name: string): void { - if (!this.allocationPolicies.has(name)) { - logger.log('warn', `No allocation policy named ${name} found`); - return; - } - - this.activePolicy = name; - logger.log('info', `Set active IP allocation policy to ${name}`); - } - - /** - * Get the total number of stages in the warmup process - * @returns Number of stages - */ - public getStageCount(): number { - return this.config.stages.length; - } - - /** - * Load warmup statuses from storage - */ - private loadWarmupStatuses(): void { - try { - const warmupDir = plugins.path.join(paths.dataDir, 'warmup'); - plugins.fsUtils.ensureDirSync(warmupDir); - - const statusFile = plugins.path.join(warmupDir, 'ip_warmup_status.json'); - - if (plugins.fs.existsSync(statusFile)) { - const data = plugins.fs.readFileSync(statusFile, 'utf8'); - const statuses = JSON.parse(data); - - for (const status of statuses) { - // Restore date objects - status.startDate = new Date(status.startDate); - status.currentStageStartDate = new Date(status.currentStageStartDate); - status.targetCompletionDate = new Date(status.targetCompletionDate); - - this.warmupStatuses.set(status.ipAddress, status); - } - - logger.log('info', `Loaded ${this.warmupStatuses.size} IP warmup statuses from storage`); - } - } catch (error) { - logger.log('error', `Failed to load warmup statuses: ${error.message}`, { - stack: error.stack - }); - } - } - - /** - * Save warmup statuses to storage - */ - private saveWarmupStatuses(): void { - try { - const warmupDir = plugins.path.join(paths.dataDir, 'warmup'); - plugins.fsUtils.ensureDirSync(warmupDir); - - const statusFile = plugins.path.join(warmupDir, 'ip_warmup_status.json'); - const statuses = Array.from(this.warmupStatuses.values()); - - plugins.fsUtils.toFsSync( - JSON.stringify(statuses, null, 2), - statusFile - ); - - logger.log('debug', `Saved ${statuses.length} IP warmup statuses to storage`); - } catch (error) { - logger.log('error', `Failed to save warmup statuses: ${error.message}`, { - stack: error.stack - }); - } - } -} - -/** - * Policy that balances traffic across IPs based on stage and capacity - */ -class BalancedAllocationPolicy implements IIPAllocationPolicy { - name = 'balanced'; - - allocateIP( - availableIPs: Array<{ ip: string; priority: number; capacity: number }>, - emailInfo: { - from: string; - to: string[]; - domain: string; - isTransactional: boolean; - isWarmup: boolean; - } - ): string | null { - if (availableIPs.length === 0) return null; - - // Sort IPs by priority (prefer higher stage IPs) and capacity - const sortedIPs = [...availableIPs].sort((a, b) => { - // First by priority (descending) - if (b.priority !== a.priority) { - return b.priority - a.priority; - } - // Then by remaining capacity (descending) - return b.capacity - a.capacity; - }); - - // Prioritize higher-stage IPs for transactional emails - if (emailInfo.isTransactional) { - return sortedIPs[0].ip; - } - - // For marketing emails, spread across IPs with preference for higher stages - // Use weighted random selection based on stage - const totalWeight = sortedIPs.reduce((sum, ip) => sum + ip.priority, 0); - let randomPoint = Math.random() * totalWeight; - - for (const ip of sortedIPs) { - randomPoint -= ip.priority; - if (randomPoint <= 0) { - return ip.ip; - } - } - - // Fallback to the highest priority IP - return sortedIPs[0].ip; - } -} - -/** - * Policy that rotates through IPs in a round-robin fashion - */ -class RoundRobinAllocationPolicy implements IIPAllocationPolicy { - name = 'roundRobin'; - private lastIndex = -1; - - allocateIP( - availableIPs: Array<{ ip: string; priority: number; capacity: number }>, - emailInfo: { - from: string; - to: string[]; - domain: string; - isTransactional: boolean; - isWarmup: boolean; - } - ): string | null { - if (availableIPs.length === 0) return null; - - // Sort by capacity to ensure even distribution - const sortedIPs = [...availableIPs].sort((a, b) => b.capacity - a.capacity); - - // Move to next IP - this.lastIndex = (this.lastIndex + 1) % sortedIPs.length; - - return sortedIPs[this.lastIndex].ip; - } -} - -/** - * Policy that dedicates specific IPs to specific domains - */ -class DedicatedDomainPolicy implements IIPAllocationPolicy { - name = 'dedicated'; - private domainAssignments: Map = new Map(); - - allocateIP( - availableIPs: Array<{ ip: string; priority: number; capacity: number }>, - emailInfo: { - from: string; - to: string[]; - domain: string; - isTransactional: boolean; - isWarmup: boolean; - } - ): string | null { - if (availableIPs.length === 0) return null; - - // Check if we have a dedicated IP for this domain - if (this.domainAssignments.has(emailInfo.domain)) { - const dedicatedIP = this.domainAssignments.get(emailInfo.domain); - - // Check if the dedicated IP is in the available list - const isAvailable = availableIPs.some(ip => ip.ip === dedicatedIP); - - if (isAvailable) { - return dedicatedIP; - } - } - - // If not, assign one and save the assignment - const sortedIPs = [...availableIPs].sort((a, b) => b.capacity - a.capacity); - const assignedIP = sortedIPs[0].ip; - - this.domainAssignments.set(emailInfo.domain, assignedIP); - - return assignedIP; - } -} \ No newline at end of file diff --git a/ts/deliverability/classes.senderreputationmonitor.ts b/ts/deliverability/classes.senderreputationmonitor.ts deleted file mode 100644 index db50357..0000000 --- a/ts/deliverability/classes.senderreputationmonitor.ts +++ /dev/null @@ -1,1244 +0,0 @@ -import * as plugins from '../plugins.js'; -import * as paths from '../paths.js'; -import { logger } from '../logger.js'; - -/** - * Domain reputation metrics - */ -export interface IDomainReputationMetrics { - /** Domain being monitored */ - domain: string; - /** Date the metrics were last updated */ - lastUpdated: Date; - /** Sending volume metrics */ - volume: { - /** Total emails sent in the tracking period */ - sent: number; - /** Delivered emails (excluding bounces) */ - delivered: number; - /** Hard bounces */ - hardBounces: number; - /** Soft bounces */ - softBounces: number; - /** Daily sending volume for the last 30 days */ - dailySendVolume: Record; - }; - /** Engagement metrics */ - engagement: { - /** Number of opens */ - opens: number; - /** Number of clicks */ - clicks: number; - /** Calculated open rate (percentage) */ - openRate: number; - /** Calculated click rate (percentage) */ - clickRate: number; - /** Click-to-open rate (percentage) */ - clickToOpenRate: number; - }; - /** Complaint metrics */ - complaints: { - /** Number of spam complaints */ - total: number; - /** Complaint rate (percentage) */ - rate: number; - /** Domains with highest complaint rates */ - topDomains: Array<{ domain: string; rate: number; count: number }>; - }; - /** Authentication metrics */ - authentication: { - /** Percentage of emails with valid SPF */ - spfPassRate: number; - /** Percentage of emails with valid DKIM */ - dkimPassRate: number; - /** Percentage of emails with valid DMARC */ - dmarcPassRate: number; - /** Authentication failures */ - failures: Array<{ type: string; domain: string; count: number }>; - }; - /** Blocklist status */ - blocklist: { - /** Current blocklist status */ - listed: boolean; - /** Blocklists the domain is on, if any */ - activeListings: Array<{ list: string; listedSince: Date }>; - /** Recent delistings */ - recentDelistings: Array<{ list: string; listedFrom: Date; listedTo: Date }>; - }; - /** Inbox placement estimates */ - inboxPlacement: { - /** Overall inbox placement rate estimate */ - overall: number; - /** Inbox placement rates by major provider */ - providers: Record; - }; - /** Historical reputation scores */ - historical: { - /** Reputation scores for the last 30 days */ - reputationScores: Record; - /** Trends in key metrics */ - trends: { - /** Open rate trend (positive or negative percentage) */ - openRate: number; - /** Complaint rate trend */ - complaintRate: number; - /** Bounce rate trend */ - bounceRate: number; - /** Spam listing trend */ - spamListings: number; - }; - }; -} - -/** - * Configuration for reputation monitoring - */ -export interface IReputationMonitorConfig { - /** Whether monitoring is enabled */ - enabled?: boolean; - /** Domains to monitor */ - domains?: string[]; - /** How frequently to update metrics (ms) */ - updateFrequency?: number; - /** Endpoints for external data sources */ - dataSources?: { - /** Spam list monitoring service */ - spamLists?: string[]; - /** Deliverability monitoring service endpoint */ - deliverabilityMonitor?: string; - }; - /** Alerting thresholds */ - alertThresholds?: { - /** Minimum safe reputation score */ - minReputationScore?: number; - /** Maximum acceptable complaint rate */ - maxComplaintRate?: number; - /** Maximum acceptable bounce rate */ - maxBounceRate?: number; - /** Minimum acceptable open rate */ - minOpenRate?: number; - }; -} - -/** - * Reputation score components - */ -interface IReputationComponents { - /** Engagement score (0-100) */ - engagement: number; - /** Complaint score (0-100) */ - complaints: number; - /** Authentication score (0-100) */ - authentication: number; - /** Volume stability score (0-100) */ - volumeStability: number; - /** Infrastructure score (0-100) */ - infrastructure: number; - /** Blocklist score (0-100) */ - blocklist: number; -} - -/** - * Default configuration - */ -const DEFAULT_CONFIG: Required = { - enabled: true, - domains: [], - updateFrequency: 24 * 60 * 60 * 1000, // Daily - dataSources: { - spamLists: [ - 'zen.spamhaus.org', - 'bl.spamcop.net', - 'dnsbl.sorbs.net', - 'b.barracudacentral.org' - ], - deliverabilityMonitor: null - }, - alertThresholds: { - minReputationScore: 70, - maxComplaintRate: 0.1, // 0.1% - maxBounceRate: 5, // 5% - minOpenRate: 15 // 15% - } -}; - -/** - * Class for monitoring and tracking sender reputation for domains - */ -export class SenderReputationMonitor { - private static instance: SenderReputationMonitor; - private config: Required; - private reputationData: Map = new Map(); - private updateTimer: NodeJS.Timeout = null; - private isInitialized: boolean = false; - private storageManager?: any; // StorageManager instance - - /** - * Constructor for SenderReputationMonitor - * @param config Configuration options - * @param storageManager Optional StorageManager instance - */ - constructor(config: IReputationMonitorConfig = {}, storageManager?: any) { - // Merge with default config - this.config = { - ...DEFAULT_CONFIG, - ...config, - dataSources: { - ...DEFAULT_CONFIG.dataSources, - ...config.dataSources - }, - alertThresholds: { - ...DEFAULT_CONFIG.alertThresholds, - ...config.alertThresholds - } - }; - - this.storageManager = storageManager; - - // If no storage manager provided, log warning - if (!storageManager) { - logger.log('warn', - '⚠️ WARNING: SenderReputationMonitor initialized without StorageManager.\n' + - ' Reputation data will only be stored to filesystem.\n' + - ' Consider passing a StorageManager instance for better storage flexibility.' - ); - } - - // Initialize (async, but we don't await here to avoid blocking constructor) - this.initialize().catch(error => { - logger.log('error', `Failed to initialize SenderReputationMonitor: ${error.message}`, { - stack: error.stack - }); - }); - } - - /** - * Get the singleton instance - * @param config Configuration options - * @param storageManager Optional StorageManager instance - * @returns Singleton instance - */ - public static getInstance(config: IReputationMonitorConfig = {}, storageManager?: any): SenderReputationMonitor { - if (!SenderReputationMonitor.instance) { - SenderReputationMonitor.instance = new SenderReputationMonitor(config, storageManager); - } - return SenderReputationMonitor.instance; - } - - /** - * Initialize the reputation monitor - */ - private async initialize(): Promise { - if (this.isInitialized) return; - - try { - // Only load data if not running in a test environment - const isTestEnvironment = process.env.NODE_ENV === 'test' || !!process.env.JEST_WORKER_ID; - - if (!isTestEnvironment) { - // Load existing reputation data - await this.loadReputationData(); - } - - // Initialize data for any new domains - for (const domain of this.config.domains) { - if (!this.reputationData.has(domain)) { - this.initializeDomainData(domain); - } - } - - // Schedule updates if enabled and not in test environment - if (this.config.enabled && !isTestEnvironment) { - this.scheduleUpdates(); - } - - this.isInitialized = true; - logger.log('info', `Sender Reputation Monitor initialized for ${this.config.domains.length} domains`); - } catch (error) { - logger.log('error', `Failed to initialize Sender Reputation Monitor: ${error.message}`, { - stack: error.stack - }); - } - } - - /** - * Initialize reputation data for a new domain - * @param domain Domain to initialize - */ - private initializeDomainData(domain: string): void { - // Create new domain reputation metrics with default values - const newMetrics: IDomainReputationMetrics = { - domain, - lastUpdated: new Date(), - volume: { - sent: 0, - delivered: 0, - hardBounces: 0, - softBounces: 0, - dailySendVolume: {} - }, - engagement: { - opens: 0, - clicks: 0, - openRate: 0, - clickRate: 0, - clickToOpenRate: 0 - }, - complaints: { - total: 0, - rate: 0, - topDomains: [] - }, - authentication: { - spfPassRate: 100, // Assume perfect initially - dkimPassRate: 100, - dmarcPassRate: 100, - failures: [] - }, - blocklist: { - listed: false, - activeListings: [], - recentDelistings: [] - }, - inboxPlacement: { - overall: 95, // Start with optimistic estimate - providers: { - gmail: 95, - outlook: 95, - yahoo: 95, - aol: 95, - other: 95 - } - }, - historical: { - reputationScores: {}, - trends: { - openRate: 0, - complaintRate: 0, - bounceRate: 0, - spamListings: 0 - } - } - }; - - // Generate some initial historical data points - const today = new Date(); - for (let i = 0; i < 30; i++) { - const date = new Date(today); - date.setDate(date.getDate() - i); - const dateKey = date.toISOString().split('T')[0]; - newMetrics.historical.reputationScores[dateKey] = 95; // Default good score - newMetrics.volume.dailySendVolume[dateKey] = 0; - } - - // Save the new metrics - this.reputationData.set(domain, newMetrics); - logger.log('info', `Initialized reputation data for domain ${domain}`); - } - - /** - * Schedule regular reputation data updates - */ - private scheduleUpdates(): void { - if (this.updateTimer) { - clearTimeout(this.updateTimer); - } - - this.updateTimer = setTimeout(async () => { - await this.updateAllDomainMetrics(); - this.scheduleUpdates(); // Reschedule for next update - }, this.config.updateFrequency); - - logger.log('info', `Scheduled reputation updates every ${this.config.updateFrequency / (60 * 60 * 1000)} hours`); - } - - /** - * Update metrics for all monitored domains - */ - private async updateAllDomainMetrics(): Promise { - if (!this.config.enabled) return; - - logger.log('info', 'Starting reputation metrics update for all domains'); - - for (const domain of this.config.domains) { - try { - await this.updateDomainMetrics(domain); - logger.log('info', `Updated reputation metrics for ${domain}`); - } catch (error) { - logger.log('error', `Error updating metrics for ${domain}: ${error.message}`, { - stack: error.stack - }); - } - } - - // Save all updated data - await this.saveReputationData(); - - logger.log('info', 'Completed reputation metrics update for all domains'); - } - - /** - * Update reputation metrics for a specific domain - * @param domain Domain to update - */ - private async updateDomainMetrics(domain: string): Promise { - const metrics = this.reputationData.get(domain); - if (!metrics) { - logger.log('warn', `No reputation data found for domain ${domain}`); - return; - } - - try { - // Update last updated timestamp - metrics.lastUpdated = new Date(); - - // Check blocklist status - await this.checkBlocklistStatus(domain, metrics); - - // Update historical data - this.updateHistoricalData(metrics); - - // Calculate current reputation score - const reputationScore = this.calculateReputationScore(metrics); - - // Save current reputation score to historical data - const today = new Date().toISOString().split('T')[0]; - metrics.historical.reputationScores[today] = reputationScore; - - // Calculate trends - this.calculateTrends(metrics); - - // Check alert thresholds - this.checkAlertThresholds(metrics); - } catch (error) { - logger.log('error', `Error in updateDomainMetrics for ${domain}: ${error.message}`, { - stack: error.stack - }); - } - } - - /** - * Check domain blocklist status - * @param domain Domain to check - * @param metrics Metrics to update - */ - private async checkBlocklistStatus(domain: string, metrics: IDomainReputationMetrics): Promise { - // Skip DNS lookups in test environment - const isTestEnvironment = process.env.NODE_ENV === 'test' || !!process.env.JEST_WORKER_ID; - if (isTestEnvironment || !this.config.dataSources.spamLists?.length) { - return; - } - - const previouslyListed = metrics.blocklist.listed; - const previousListings = new Set(metrics.blocklist.activeListings.map(l => l.list)); - - // Store current listings to detect changes - const currentListings: Array<{ list: string; listedSince: Date }> = []; - - // Check each blocklist - for (const list of this.config.dataSources.spamLists) { - try { - const isListed = await this.checkDomainOnBlocklist(domain, list); - - if (isListed) { - // If already known to be listed on this one, keep the original listing date - const existingListing = metrics.blocklist.activeListings.find(l => l.list === list); - if (existingListing) { - currentListings.push(existingListing); - } else { - // New listing - currentListings.push({ - list, - listedSince: new Date() - }); - } - } - } catch (error) { - logger.log('warn', `Error checking ${domain} on blocklist ${list}: ${error.message}`); - } - } - - // Update active listings - metrics.blocklist.activeListings = currentListings; - metrics.blocklist.listed = currentListings.length > 0; - - // Check for delistings - if (previouslyListed) { - const currentListsSet = new Set(currentListings.map(l => l.list)); - - // Convert Set to Array for compatibility with older JS versions - Array.from(previousListings).forEach(list => { - if (!currentListsSet.has(list)) { - // This list no longer contains the domain - it was delisted - const previousListing = metrics.blocklist.activeListings.find(l => l.list === list); - - if (previousListing) { - metrics.blocklist.recentDelistings.push({ - list, - listedFrom: previousListing.listedSince, - listedTo: new Date() - }); - } - } - }); - - // Keep only recent delistings (last 90 days) - const ninetyDaysAgo = new Date(); - ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); - - metrics.blocklist.recentDelistings = metrics.blocklist.recentDelistings - .filter(d => d.listedTo > ninetyDaysAgo); - } - } - - /** - * Check if a domain is on a specific blocklist - * @param domain Domain to check - * @param list Blocklist to check - * @returns Whether the domain is listed - */ - private async checkDomainOnBlocklist(domain: string, list: string): Promise { - try { - // Look up the domain in the blocklist (simplified) - if (list === 'zen.spamhaus.org') { - // For Spamhaus and similar lists, we check the domain MX IPs - const mxRecords = await plugins.dns.promises.resolveMx(domain); - - if (mxRecords && mxRecords.length > 0) { - // Check the primary MX record - const primaryMx = mxRecords.sort((a, b) => a.priority - b.priority)[0].exchange; - - // Resolve IP addresses for the MX - const ips = await plugins.dns.promises.resolve(primaryMx); - - // Check the first IP - if (ips.length > 0) { - const ip = ips[0]; - const reversedIp = ip.split('.').reverse().join('.'); - const lookupDomain = `${reversedIp}.${list}`; - - try { - await plugins.dns.promises.resolve(lookupDomain); - return true; // Listed - } catch (err) { - if (err.code === 'ENOTFOUND') { - return false; // Not listed - } - throw err; // Other error - } - } - } - return false; - } else { - // For domain-based blocklists - const lookupDomain = `${domain}.${list}`; - try { - await plugins.dns.promises.resolve(lookupDomain); - return true; // Listed - } catch (err) { - if (err.code === 'ENOTFOUND') { - return false; // Not listed - } - throw err; // Other error - } - } - } catch (error) { - logger.log('warn', `Error checking blocklist status for ${domain} on ${list}: ${error.message}`); - return false; // Assume not listed on error - } - } - - /** - * Update historical data in metrics - * @param metrics Metrics to update - */ - private updateHistoricalData(metrics: IDomainReputationMetrics): void { - // Keep only the last 30 days of data - const dates = Object.keys(metrics.historical.reputationScores) - .sort((a, b) => b.localeCompare(a)); // Sort descending - - if (dates.length > 30) { - const daysToKeep = dates.slice(0, 30); - const newScores: Record = {}; - - for (const day of daysToKeep) { - newScores[day] = metrics.historical.reputationScores[day]; - } - - metrics.historical.reputationScores = newScores; - } - - // Same for daily send volume - const volumeDates = Object.keys(metrics.volume.dailySendVolume) - .sort((a, b) => b.localeCompare(a)); - - if (volumeDates.length > 30) { - const daysToKeep = volumeDates.slice(0, 30); - const newVolume: Record = {}; - - for (const day of daysToKeep) { - newVolume[day] = metrics.volume.dailySendVolume[day]; - } - - metrics.volume.dailySendVolume = newVolume; - } - } - - /** - * Calculate reputation score from metrics - * @param metrics Domain reputation metrics - * @returns Reputation score (0-100) - */ - private calculateReputationScore(metrics: IDomainReputationMetrics): number { - // Calculate component scores - const components: IReputationComponents = { - engagement: this.calculateEngagementScore(metrics), - complaints: this.calculateComplaintScore(metrics), - authentication: this.calculateAuthenticationScore(metrics), - volumeStability: this.calculateVolumeStabilityScore(metrics), - infrastructure: this.calculateInfrastructureScore(metrics), - blocklist: this.calculateBlocklistScore(metrics) - }; - - // Apply weights to components - const weightedScore = - components.engagement * 0.25 + - components.complaints * 0.25 + - components.authentication * 0.2 + - components.volumeStability * 0.1 + - components.infrastructure * 0.1 + - components.blocklist * 0.1; - - // Round to 2 decimal places - return Math.round(weightedScore * 100) / 100; - } - - /** - * Calculate engagement component score - * @param metrics Domain metrics - * @returns Engagement score (0-100) - */ - private calculateEngagementScore(metrics: IDomainReputationMetrics): number { - const openRate = metrics.engagement.openRate; - const clickRate = metrics.engagement.clickRate; - - // Benchmark open and click rates - // <5% open rate = poor (score: 0-30) - // 5-15% = average (score: 30-70) - // >15% = good (score: 70-100) - let openScore = 0; - if (openRate < 5) { - openScore = openRate * 6; // 0-30 scale - } else if (openRate < 15) { - openScore = 30 + (openRate - 5) * 4; // 30-70 scale - } else { - openScore = 70 + Math.min(30, (openRate - 15) * 2); // 70-100 scale - } - - // Similarly for click rate - let clickScore = 0; - if (clickRate < 1) { - clickScore = clickRate * 30; // 0-30 scale - } else if (clickRate < 5) { - clickScore = 30 + (clickRate - 1) * 10; // 30-70 scale - } else { - clickScore = 70 + Math.min(30, (clickRate - 5) * 6); // 70-100 scale - } - - // Combine with 60% weight to open rate, 40% to click rate - return (openScore * 0.6 + clickScore * 0.4); - } - - /** - * Calculate complaint component score - * @param metrics Domain metrics - * @returns Complaint score (0-100) - */ - private calculateComplaintScore(metrics: IDomainReputationMetrics): number { - const complaintRate = metrics.complaints.rate; - - // Industry standard: complaint rate should be under 0.1% - // 0% = perfect (score: 100) - // 0.1% = threshold (score: 70) - // 0.5% = problematic (score: 30) - // 1%+ = critical (score: 0) - - if (complaintRate === 0) return 100; - if (complaintRate >= 1) return 0; - - if (complaintRate < 0.1) { - // 0-0.1% maps to 100-70 - return 100 - (complaintRate / 0.1) * 30; - } else if (complaintRate < 0.5) { - // 0.1-0.5% maps to 70-30 - return 70 - ((complaintRate - 0.1) / 0.4) * 40; - } else { - // 0.5-1% maps to 30-0 - return 30 - ((complaintRate - 0.5) / 0.5) * 30; - } - } - - /** - * Calculate authentication component score - * @param metrics Domain metrics - * @returns Authentication score (0-100) - */ - private calculateAuthenticationScore(metrics: IDomainReputationMetrics): number { - const spfRate = metrics.authentication.spfPassRate; - const dkimRate = metrics.authentication.dkimPassRate; - const dmarcRate = metrics.authentication.dmarcPassRate; - - // Weight SPF, DKIM, and DMARC - return (spfRate * 0.3 + dkimRate * 0.3 + dmarcRate * 0.4); - } - - /** - * Calculate volume stability component score - * @param metrics Domain metrics - * @returns Volume stability score (0-100) - */ - private calculateVolumeStabilityScore(metrics: IDomainReputationMetrics): number { - const volumes = Object.values(metrics.volume.dailySendVolume); - - if (volumes.length < 2) return 100; // Not enough data - - // Calculate coefficient of variation (stdev / mean) - const mean = volumes.reduce((sum, v) => sum + v, 0) / volumes.length; - if (mean === 0) return 100; // No sending activity - - const variance = volumes.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / volumes.length; - const stdev = Math.sqrt(variance); - const cv = stdev / mean; - - // Convert to score: lower CV means more stability - // CV < 0.1 is very stable (score: 90-100) - // CV < 0.5 is normal (score: 60-90) - // CV < 1.0 is somewhat unstable (score: 30-60) - // CV >= 1.0 is unstable (score: 0-30) - - if (cv < 0.1) { - return 90 + (1 - cv / 0.1) * 10; - } else if (cv < 0.5) { - return 60 + (1 - (cv - 0.1) / 0.4) * 30; - } else if (cv < 1.0) { - return 30 + (1 - (cv - 0.5) / 0.5) * 30; - } else { - return Math.max(0, 30 - (cv - 1.0) * 10); - } - } - - /** - * Calculate infrastructure component score - * @param metrics Domain metrics - * @returns Infrastructure score (0-100) - */ - private calculateInfrastructureScore(metrics: IDomainReputationMetrics): number { - // This is a placeholder; in reality, this would be based on: - // - IP reputation - // - Reverse DNS configuration - // - IP warming status - // - Historical IP behavior - - // For now, assume good infrastructure - return 90; - } - - /** - * Calculate blocklist component score - * @param metrics Domain metrics - * @returns Blocklist score (0-100) - */ - private calculateBlocklistScore(metrics: IDomainReputationMetrics): number { - // If currently listed on any blocklist, score is heavily impacted - if (metrics.blocklist.listed) { - // Number of active listings determines severity - const listingCount = metrics.blocklist.activeListings.length; - if (listingCount >= 3) return 0; // Critical: listed on 3+ lists - if (listingCount === 2) return 20; // Severe: listed on 2 lists - return 40; // Serious: listed on 1 list - } - - // If recently delisted, some penalty still applies - if (metrics.blocklist.recentDelistings.length > 0) { - // Check how recent the delistings are - const now = new Date(); - const mostRecent = metrics.blocklist.recentDelistings - .reduce((latest, delisting) => - delisting.listedTo > latest ? delisting.listedTo : latest, - new Date(0)); - - const daysSinceDelisting = Math.floor( - (now.getTime() - mostRecent.getTime()) / (24 * 60 * 60 * 1000) - ); - - // Score improves as time passes since delisting - if (daysSinceDelisting < 7) return 60; // Delisted within last week - if (daysSinceDelisting < 30) return 80; // Delisted within last month - return 90; // Delisted over a month ago - } - - // Never listed - return 100; - } - - /** - * Calculate trend metrics - * @param metrics Domain metrics to update - */ - private calculateTrends(metrics: IDomainReputationMetrics): void { - // Get dates in descending order - const dates = Object.keys(metrics.historical.reputationScores) - .sort((a, b) => b.localeCompare(a)); - - if (dates.length < 7) { - // Not enough data for trends - metrics.historical.trends = { - openRate: 0, - complaintRate: 0, - bounceRate: 0, - spamListings: 0 - }; - return; - } - - // Calculate trends over past 7 days compared to previous 7 days - const current7Days = dates.slice(0, 7); - const previous7Days = dates.slice(7, 14); - - if (previous7Days.length < 7) { - // Not enough historical data - return; - } - - // Calculate averages for the periods - const currentReputation = current7Days.reduce( - (sum, date) => sum + metrics.historical.reputationScores[date], 0 - ) / current7Days.length; - - const previousReputation = previous7Days.reduce( - (sum, date) => sum + metrics.historical.reputationScores[date], 0 - ) / previous7Days.length; - - // Calculate percent change - const reputationChange = ((currentReputation - previousReputation) / previousReputation) * 100; - - // For now, use reputation change for all trends (in a real implementation - // we would calculate each metric's trend separately) - metrics.historical.trends = { - openRate: reputationChange, - complaintRate: -reputationChange, // Inverse for complaint rate (negative is good) - bounceRate: -reputationChange, // Inverse for bounce rate - spamListings: -reputationChange // Inverse for spam listings - }; - } - - /** - * Check if metrics exceed alert thresholds - * @param metrics Domain metrics to check - */ - private checkAlertThresholds(metrics: IDomainReputationMetrics): void { - const thresholds = this.config.alertThresholds; - const today = new Date().toISOString().split('T')[0]; - const todayScore = metrics.historical.reputationScores[today] || 0; - - // Check reputation score - if (todayScore < thresholds.minReputationScore) { - this.sendAlert(metrics.domain, 'reputation_score', { - score: todayScore, - threshold: thresholds.minReputationScore - }); - } - - // Check complaint rate - if (metrics.complaints.rate > thresholds.maxComplaintRate) { - this.sendAlert(metrics.domain, 'complaint_rate', { - rate: metrics.complaints.rate, - threshold: thresholds.maxComplaintRate - }); - } - - // Check bounce rate - const bounceRate = (metrics.volume.hardBounces + metrics.volume.softBounces) / - Math.max(1, metrics.volume.sent) * 100; - - if (bounceRate > thresholds.maxBounceRate) { - this.sendAlert(metrics.domain, 'bounce_rate', { - rate: bounceRate, - threshold: thresholds.maxBounceRate - }); - } - - // Check open rate - if (metrics.engagement.openRate < thresholds.minOpenRate) { - this.sendAlert(metrics.domain, 'open_rate', { - rate: metrics.engagement.openRate, - threshold: thresholds.minOpenRate - }); - } - - // Check blocklist status - if (metrics.blocklist.listed) { - this.sendAlert(metrics.domain, 'blocklist', { - lists: metrics.blocklist.activeListings.map(l => l.list) - }); - } - } - - /** - * Send an alert for a reputation issue - * @param domain Domain with the issue - * @param alertType Type of alert - * @param data Alert data - */ - private sendAlert(domain: string, alertType: string, data: any): void { - logger.log('warn', `Reputation alert for ${domain}: ${alertType}`, data); - - // In a real implementation, this would send alerts via email, - // notification systems, webhooks, etc. - } - - /** - * Record a send event for domain reputation tracking - * @param domain The domain sending the email - * @param event Event details - */ - public recordSendEvent(domain: string, event: { - type: 'sent' | 'delivered' | 'bounce' | 'complaint' | 'open' | 'click'; - count?: number; - hardBounce?: boolean; - receivingDomain?: string; - }): void { - // Ensure we have metrics for this domain - if (!this.reputationData.has(domain)) { - this.initializeDomainData(domain); - } - - const metrics = this.reputationData.get(domain); - const count = event.count || 1; - const today = new Date().toISOString().split('T')[0]; - - // Update metrics based on event type - switch (event.type) { - case 'sent': - metrics.volume.sent += count; - // Update daily send volume - metrics.volume.dailySendVolume[today] = - (metrics.volume.dailySendVolume[today] || 0) + count; - break; - - case 'delivered': - metrics.volume.delivered += count; - break; - - case 'bounce': - if (event.hardBounce) { - metrics.volume.hardBounces += count; - } else { - metrics.volume.softBounces += count; - } - break; - - case 'complaint': - metrics.complaints.total += count; - - // Track by receiving domain - if (event.receivingDomain) { - const domainIndex = metrics.complaints.topDomains.findIndex( - d => d.domain === event.receivingDomain - ); - - if (domainIndex >= 0) { - metrics.complaints.topDomains[domainIndex].count += count; - metrics.complaints.topDomains[domainIndex].rate = - metrics.complaints.topDomains[domainIndex].count / Math.max(1, metrics.volume.sent); - } else { - metrics.complaints.topDomains.push({ - domain: event.receivingDomain, - count, - rate: count / Math.max(1, metrics.volume.sent) - }); - } - - // Sort by count - metrics.complaints.topDomains.sort((a, b) => b.count - a.count); - - // Keep only top 10 - if (metrics.complaints.topDomains.length > 10) { - metrics.complaints.topDomains = metrics.complaints.topDomains.slice(0, 10); - } - } - - // Update overall complaint rate - metrics.complaints.rate = - metrics.complaints.total / Math.max(1, metrics.volume.sent); - break; - - case 'open': - metrics.engagement.opens += count; - metrics.engagement.openRate = - metrics.engagement.opens / Math.max(1, metrics.volume.delivered); - break; - - case 'click': - metrics.engagement.clicks += count; - metrics.engagement.clickRate = - metrics.engagement.clicks / Math.max(1, metrics.volume.delivered); - metrics.engagement.clickToOpenRate = - metrics.engagement.clicks / Math.max(1, metrics.engagement.opens); - break; - } - - // Update last updated timestamp - metrics.lastUpdated = new Date(); - - // Save data periodically (not after every event to avoid excessive I/O) - // Skip in test environment - const isTestEnvironment = process.env.NODE_ENV === 'test' || !!process.env.JEST_WORKER_ID; - if (!isTestEnvironment && Math.random() < 0.01) { // ~1% chance to save on each event - this.saveReputationData().catch(error => { - logger.log('error', `Failed to save reputation data: ${error.message}`, { - stack: error.stack - }); - }); - } - } - - /** - * Get reputation data for a domain - * @param domain Domain to get data for - * @returns Reputation data - */ - public getReputationData(domain: string): IDomainReputationMetrics | null { - return this.reputationData.get(domain) || null; - } - - /** - * Get summary reputation data for all domains - * @returns Summary data for all domains - */ - public getReputationSummary(): Array<{ - domain: string; - score: number; - status: 'excellent' | 'good' | 'fair' | 'poor' | 'critical'; - listed: boolean; - trend: number; - }> { - return Array.from(this.reputationData.entries()) - .map(([domain, metrics]) => { - const today = new Date().toISOString().split('T')[0]; - const score = metrics.historical.reputationScores[today] || 0; - - // Determine status based on score - let status: 'excellent' | 'good' | 'fair' | 'poor' | 'critical'; - if (score >= 90) status = 'excellent'; - else if (score >= 75) status = 'good'; - else if (score >= 60) status = 'fair'; - else if (score >= 40) status = 'poor'; - else status = 'critical'; - - return { - domain, - score, - status, - listed: metrics.blocklist.listed, - trend: metrics.historical.trends.openRate // Use open rate trend as overall trend - }; - }) - .sort((a, b) => b.score - a.score); // Sort by score descending - } - - /** - * Add a domain to monitor - * @param domain Domain to monitor - */ - public addDomain(domain: string): void { - if (this.config.domains.includes(domain)) { - logger.log('info', `Domain ${domain} is already being monitored`); - return; - } - - this.config.domains.push(domain); - this.initializeDomainData(domain); - this.saveReputationData().catch(error => { - logger.log('error', `Failed to save reputation data after adding domain: ${error.message}`, { - stack: error.stack - }); - }); - - logger.log('info', `Added ${domain} to reputation monitoring`); - } - - /** - * Remove a domain from monitoring - * @param domain Domain to remove - */ - public removeDomain(domain: string): void { - const index = this.config.domains.indexOf(domain); - if (index === -1) { - logger.log('info', `Domain ${domain} is not being monitored`); - return; - } - - this.config.domains.splice(index, 1); - this.reputationData.delete(domain); - this.saveReputationData().catch(error => { - logger.log('error', `Failed to save reputation data after removing domain: ${error.message}`, { - stack: error.stack - }); - }); - - logger.log('info', `Removed ${domain} from reputation monitoring`); - } - - /** - * Load reputation data from storage - */ - private async loadReputationData(): Promise { - // Skip loading in test environment to prevent file system race conditions - const isTestEnvironment = process.env.NODE_ENV === 'test' || !!process.env.JEST_WORKER_ID; - if (isTestEnvironment) { - return; - } - - try { - // Try to load from storage manager first - if (this.storageManager) { - try { - const data = await this.storageManager.get('/email/reputation/domain-reputation.json'); - if (data) { - const reputationEntries = JSON.parse(data); - - for (const entry of reputationEntries) { - // Restore Date objects - entry.lastUpdated = new Date(entry.lastUpdated); - - for (const listing of entry.blocklist.activeListings) { - listing.listedSince = new Date(listing.listedSince); - } - - for (const delisting of entry.blocklist.recentDelistings) { - delisting.listedFrom = new Date(delisting.listedFrom); - delisting.listedTo = new Date(delisting.listedTo); - } - - this.reputationData.set(entry.domain, entry); - } - - logger.log('info', `Loaded reputation data for ${this.reputationData.size} domains from StorageManager`); - return; - } - } catch (error) { - // Fall through to filesystem migration check - } - - // Check if data exists in filesystem and migrate it to storage manager - const reputationDir = plugins.path.join(paths.dataDir, 'reputation'); - const dataFile = plugins.path.join(reputationDir, 'domain_reputation.json'); - - if (plugins.fs.existsSync(dataFile)) { - const data = plugins.fs.readFileSync(dataFile, 'utf8'); - const reputationEntries = JSON.parse(data); - - for (const entry of reputationEntries) { - // Restore Date objects - entry.lastUpdated = new Date(entry.lastUpdated); - - for (const listing of entry.blocklist.activeListings) { - listing.listedSince = new Date(listing.listedSince); - } - - for (const delisting of entry.blocklist.recentDelistings) { - delisting.listedFrom = new Date(delisting.listedFrom); - delisting.listedTo = new Date(delisting.listedTo); - } - - this.reputationData.set(entry.domain, entry); - } - - // Migrate to storage manager - logger.log('info', `Migrating reputation data for ${this.reputationData.size} domains from filesystem to StorageManager`); - await this.storageManager.set( - '/email/reputation/domain-reputation.json', - JSON.stringify(Array.from(this.reputationData.values()), null, 2) - ); - - logger.log('info', `Loaded and migrated reputation data for ${this.reputationData.size} domains`); - } - } else { - // No storage manager, use filesystem directly - const reputationDir = plugins.path.join(paths.dataDir, 'reputation'); - plugins.fsUtils.ensureDirSync(reputationDir); - - const dataFile = plugins.path.join(reputationDir, 'domain_reputation.json'); - - if (plugins.fs.existsSync(dataFile)) { - const data = plugins.fs.readFileSync(dataFile, 'utf8'); - const reputationEntries = JSON.parse(data); - - for (const entry of reputationEntries) { - // Restore Date objects - entry.lastUpdated = new Date(entry.lastUpdated); - - for (const listing of entry.blocklist.activeListings) { - listing.listedSince = new Date(listing.listedSince); - } - - for (const delisting of entry.blocklist.recentDelistings) { - delisting.listedFrom = new Date(delisting.listedFrom); - delisting.listedTo = new Date(delisting.listedTo); - } - - this.reputationData.set(entry.domain, entry); - } - - logger.log('info', `Loaded reputation data for ${this.reputationData.size} domains from filesystem`); - } - } - } catch (error) { - logger.log('error', `Failed to load reputation data: ${error.message}`, { - stack: error.stack - }); - } - } - - /** - * Save reputation data to storage - */ - private async saveReputationData(): Promise { - // Skip saving in test environment to prevent file system race conditions - const isTestEnvironment = process.env.NODE_ENV === 'test' || !!process.env.JEST_WORKER_ID; - if (isTestEnvironment) { - return; - } - - try { - const reputationEntries = Array.from(this.reputationData.values()); - - // Save to storage manager if available - if (this.storageManager) { - await this.storageManager.set( - '/email/reputation/domain-reputation.json', - JSON.stringify(reputationEntries, null, 2) - ); - logger.log('debug', `Saved reputation data for ${reputationEntries.length} domains to StorageManager`); - } else { - // No storage manager, use filesystem directly - const reputationDir = plugins.path.join(paths.dataDir, 'reputation'); - plugins.fsUtils.ensureDirSync(reputationDir); - - const dataFile = plugins.path.join(reputationDir, 'domain_reputation.json'); - - plugins.fsUtils.toFsSync( - JSON.stringify(reputationEntries, null, 2), - dataFile - ); - - logger.log('debug', `Saved reputation data for ${reputationEntries.length} domains to filesystem`); - } - } catch (error) { - logger.log('error', `Failed to save reputation data: ${error.message}`, { - stack: error.stack - }); - } - } -} \ No newline at end of file diff --git a/ts/deliverability/index.ts b/ts/deliverability/index.ts deleted file mode 100644 index b4b9766..0000000 --- a/ts/deliverability/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { - IPWarmupManager, - type IIPWarmupConfig, - type IWarmupStage, - type IIPWarmupStatus, - type IIPAllocationPolicy -} from './classes.ipwarmupmanager.js'; - -export { - SenderReputationMonitor, - type IDomainReputationMetrics, - type IReputationMonitorConfig -} from './classes.senderreputationmonitor.js'; \ No newline at end of file diff --git a/ts/errors/email.errors.ts b/ts/errors/email.errors.ts deleted file mode 100644 index 30ae875..0000000 --- a/ts/errors/email.errors.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { - PlatformError, - ValidationError, - NetworkError, - ResourceError, - OperationError -} from './base.errors.js'; -import type { IErrorContext } from './base.errors.js'; - -import { - EMAIL_SERVICE_ERROR, - EMAIL_TEMPLATE_ERROR, - EMAIL_VALIDATION_ERROR, - EMAIL_SEND_ERROR, - EMAIL_RECEIVE_ERROR, - EMAIL_ATTACHMENT_ERROR, - EMAIL_PARSE_ERROR, - EMAIL_RATE_LIMIT_EXCEEDED -} from './error.codes.js'; - -/** - * Base class for all email service related errors - */ -export class EmailServiceError extends OperationError { - /** - * Creates a new email service error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, EMAIL_SERVICE_ERROR, context); - } - - /** - * Creates a new instance with updated context - */ - protected createWithContext(context: IErrorContext): PlatformError { - return new (this.constructor as typeof EmailServiceError)( - this.message, - context - ); - } -} - -/** - * Error class for email template errors - */ -export class EmailTemplateError extends OperationError { - /** - * Creates a new email template error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, EMAIL_TEMPLATE_ERROR, context); - } - - /** - * Creates a new instance with updated context - */ - protected createWithContext(context: IErrorContext): PlatformError { - return new (this.constructor as typeof EmailTemplateError)( - this.message, - context - ); - } -} - -/** - * Error class for email validation errors - */ -export class EmailValidationError extends ValidationError { - /** - * Creates a new email validation error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, EMAIL_VALIDATION_ERROR, context); - } - - /** - * Creates a new instance with updated context - */ - protected createWithContext(context: IErrorContext): PlatformError { - return new (this.constructor as typeof EmailValidationError)( - this.message, - context - ); - } -} - -/** - * Error class for email sending errors - */ -export class EmailSendError extends OperationError { - /** - * Creates a new email send error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, EMAIL_SEND_ERROR, context); - } - - /** - * Creates a new instance with updated context - */ - protected createWithContext(context: IErrorContext): PlatformError { - return new (this.constructor as typeof EmailSendError)( - this.message, - context - ); - } - - /** - * Creates an instance for a permanently failed send - * - * @param message Error message - * @param context Additional context - */ - public static permanent( - message: string, - context: IErrorContext = {} - ): EmailSendError { - return new EmailSendError(`Permanent send failure: ${message}`, { - ...context, - data: { - ...context.data, - permanent: true - }, - userMessage: 'The email could not be delivered due to a permanent failure.' - }); - } - - /** - * Creates an instance for a temporary failed send - * - * @param message Error message - * @param maxRetries Maximum number of retries - * @param currentRetry Current retry count - * @param retryDelay Delay between retries in ms - * @param context Additional context - */ - public static temporary( - message: string, - maxRetries: number = 3, - currentRetry: number = 0, - retryDelay: number = 60000, - context: IErrorContext = {} - ): EmailSendError { - const error = new EmailSendError(`Temporary send failure: ${message}`, { - ...context, - data: { - ...context.data, - permanent: false - }, - userMessage: 'The email delivery failed temporarily. It will be retried.' - }); - - return error.withRetry(maxRetries, currentRetry, retryDelay) as EmailSendError; - } - - /** - * Check if this is a permanent send failure - */ - public isPermanent(): boolean { - return !!this.context.data?.permanent; - } -} - -/** - * Error class for email receiving errors - */ -export class EmailReceiveError extends OperationError { - /** - * Creates a new email receive error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, EMAIL_RECEIVE_ERROR, context); - } - - /** - * Creates a new instance with updated context - */ - protected createWithContext(context: IErrorContext): PlatformError { - return new (this.constructor as typeof EmailReceiveError)( - this.message, - context - ); - } -} - -/** - * Error class for email attachment errors - */ -export class EmailAttachmentError extends ValidationError { - /** - * Creates a new email attachment error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, EMAIL_ATTACHMENT_ERROR, context); - } - - /** - * Creates an instance for an attachment too large error - * - * @param size Attachment size in bytes - * @param maxSize Maximum allowed size in bytes - * @param filename Attachment filename - * @param context Additional context - */ - public static tooLarge( - size: number, - maxSize: number, - filename?: string, - context: IErrorContext = {} - ): EmailAttachmentError { - const filenameText = filename ? ` (${filename})` : ''; - return new EmailAttachmentError( - `Attachment${filenameText} size ${size} bytes exceeds maximum allowed size of ${maxSize} bytes`, - { - ...context, - data: { - ...context.data, - size, - maxSize, - filename - }, - userMessage: `The attachment${filenameText} is too large. Maximum size is ${Math.round(maxSize / 1024 / 1024)} MB.` - } - ); - } - - /** - * Creates an instance for an invalid attachment type error - * - * @param contentType Attachment content type - * @param filename Attachment filename - * @param allowedTypes List of allowed content types - * @param context Additional context - */ - public static invalidType( - contentType: string, - filename: string, - allowedTypes: string[], - context: IErrorContext = {} - ): EmailAttachmentError { - return new EmailAttachmentError( - `Attachment '${filename}' with content type '${contentType}' is not allowed. Allowed types: ${allowedTypes.join(', ')}`, - { - ...context, - data: { - ...context.data, - contentType, - filename, - allowedTypes - }, - userMessage: `The attachment type ${contentType} is not allowed.` - } - ); - } -} - -/** - * Error class for email parsing errors - */ -export class EmailParseError extends OperationError { - /** - * Creates a new email parse error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, EMAIL_PARSE_ERROR, context); - } - - /** - * Creates a new instance with updated context - */ - protected createWithContext(context: IErrorContext): PlatformError { - return new (this.constructor as typeof EmailParseError)( - this.message, - context - ); - } -} - -/** - * Error class for email rate limit exceeded errors - */ -export class EmailRateLimitError extends ResourceError { - /** - * Creates a new email rate limit error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, EMAIL_RATE_LIMIT_EXCEEDED, context); - } - - /** - * Creates a new instance with updated context - */ - protected createWithContext(context: IErrorContext): PlatformError { - return new (this.constructor as typeof EmailRateLimitError)( - this.message, - context - ); - } - - /** - * Creates an instance with rate limit information - * - * @param limit Rate limit - * @param remaining Remaining quota - * @param resetAt Time when the quota resets - * @param scope Rate limit scope (global, domain, user, etc.) - * @param context Additional context - */ - public static withLimitInfo( - limit: number, - remaining: number, - resetAt: Date | number, - scope: string = 'global', - context: IErrorContext = {} - ): EmailRateLimitError { - const resetTime = typeof resetAt === 'number' ? new Date(resetAt) : resetAt; - const resetTimeStr = resetTime.toISOString(); - - return new EmailRateLimitError( - `Email rate limit exceeded: ${remaining}/${limit} remaining in ${scope} scope, resets at ${resetTimeStr}`, - { - ...context, - data: { - ...context.data, - limit, - remaining, - resetAt: resetTime.getTime(), - resetTimeStr, - scope - }, - userMessage: `You've reached the email sending limit. Please try again later.` - } - ); - } -} \ No newline at end of file diff --git a/ts/errors/index.ts b/ts/errors/index.ts index 0f46159..b925332 100644 --- a/ts/errors/index.ts +++ b/ts/errors/index.ts @@ -12,8 +12,6 @@ export * from './error.codes.js'; export * from './base.errors.js'; // Export domain-specific error classes -export * from './email.errors.js'; -export * from './mta.errors.js'; export * from './reputation.errors.js'; // Export error handler diff --git a/ts/errors/mta.errors.ts b/ts/errors/mta.errors.ts deleted file mode 100644 index a403fb2..0000000 --- a/ts/errors/mta.errors.ts +++ /dev/null @@ -1,681 +0,0 @@ -import { - PlatformError, - NetworkError, - AuthenticationError, - OperationError, - ConfigurationError -} from './base.errors.js'; -import type { IErrorContext } from './base.errors.js'; - -import { - MTA_CONNECTION_ERROR, - MTA_AUTHENTICATION_ERROR, - MTA_DELIVERY_ERROR, - MTA_CONFIGURATION_ERROR, - MTA_DNS_ERROR, - MTA_TIMEOUT_ERROR, - MTA_PROTOCOL_ERROR -} from './error.codes.js'; - -/** - * Base class for MTA connection errors - */ -export class MtaConnectionError extends NetworkError { - /** - * Creates a new MTA connection error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, MTA_CONNECTION_ERROR, context); - } - - /** - * Creates a new instance with updated context - */ - protected createWithContext(context: IErrorContext): PlatformError { - return new (this.constructor as typeof MtaConnectionError)( - this.message, - context - ); - } - - /** - * Creates an instance for a DNS resolution error - * - * @param hostname Hostname that failed to resolve - * @param originalError Original error - * @param context Additional context - */ - public static dnsError( - hostname: string, - originalError?: Error, - context: IErrorContext = {} - ): MtaConnectionError { - const errorMsg = originalError ? `: ${originalError.message}` : ''; - return new MtaConnectionError( - `Failed to resolve DNS for ${hostname}${errorMsg}`, - { - ...context, - data: { - ...context.data, - hostname, - originalError: originalError ? { - message: originalError.message, - stack: originalError.stack - } : undefined - }, - userMessage: `Could not connect to mail server for ${hostname}.` - } - ); - } - - /** - * Creates an instance for a connection timeout - * - * @param hostname Hostname that timed out - * @param port Port number - * @param timeout Timeout in milliseconds - * @param context Additional context - */ - public static timeout( - hostname: string, - port: number, - timeout: number, - context: IErrorContext = {} - ): MtaConnectionError { - return new MtaConnectionError( - `Connection to ${hostname}:${port} timed out after ${timeout}ms`, - { - ...context, - data: { - ...context.data, - hostname, - port, - timeout - }, - userMessage: `Connection to mail server timed out.` - } - ); - } - - /** - * Creates an instance for a connection refused error - * - * @param hostname Hostname that refused connection - * @param port Port number - * @param context Additional context - */ - public static refused( - hostname: string, - port: number, - context: IErrorContext = {} - ): MtaConnectionError { - return new MtaConnectionError( - `Connection to ${hostname}:${port} refused`, - { - ...context, - data: { - ...context.data, - hostname, - port - }, - userMessage: `Connection to mail server was refused.` - } - ); - } -} - -/** - * Error class for MTA authentication errors - */ -export class MtaAuthenticationError extends AuthenticationError { - /** - * Creates a new MTA authentication error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, MTA_AUTHENTICATION_ERROR, context); - } - - /** - * Creates a new instance with updated context - */ - protected createWithContext(context: IErrorContext): PlatformError { - return new (this.constructor as typeof MtaAuthenticationError)( - this.message, - context - ); - } - - /** - * Creates an instance for invalid credentials - * - * @param hostname Hostname where authentication failed - * @param username Username that failed authentication - * @param context Additional context - */ - public static invalidCredentials( - hostname: string, - username: string, - context: IErrorContext = {} - ): MtaAuthenticationError { - return new MtaAuthenticationError( - `Authentication failed for user ${username} at ${hostname}`, - { - ...context, - data: { - ...context.data, - hostname, - username - }, - userMessage: `Authentication to mail server failed.` - } - ); - } - - /** - * Creates an instance for unsupported authentication method - * - * @param hostname Hostname - * @param method Authentication method that is not supported - * @param supportedMethods List of supported authentication methods - * @param context Additional context - */ - public static unsupportedMethod( - hostname: string, - method: string, - supportedMethods: string[] = [], - context: IErrorContext = {} - ): MtaAuthenticationError { - return new MtaAuthenticationError( - `Authentication method ${method} not supported by ${hostname}${supportedMethods.length > 0 ? `. Supported methods: ${supportedMethods.join(', ')}` : ''}`, - { - ...context, - data: { - ...context.data, - hostname, - method, - supportedMethods - }, - userMessage: `The mail server doesn't support the required authentication method.` - } - ); - } -} - -/** - * Error class for MTA delivery errors - */ -export class MtaDeliveryError extends OperationError { - /** - * Creates a new MTA delivery error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, MTA_DELIVERY_ERROR, context); - } - - /** - * Creates a new instance with updated context - */ - protected createWithContext(context: IErrorContext): PlatformError { - return new (this.constructor as typeof MtaDeliveryError)( - this.message, - context - ); - } - - /** - * Creates an instance for a permanent delivery failure - * - * @param message Error message - * @param recipientAddress Recipient email address - * @param statusCode SMTP status code - * @param smtpResponse Full SMTP response - * @param context Additional context - */ - public static permanent( - message: string, - recipientAddress: string, - statusCode?: string, - smtpResponse?: string, - context: IErrorContext = {} - ): MtaDeliveryError { - const statusCodeStr = statusCode ? ` (${statusCode})` : ''; - return new MtaDeliveryError( - `Permanent delivery failure to ${recipientAddress}${statusCodeStr}: ${message}`, - { - ...context, - data: { - ...context.data, - recipientAddress, - statusCode, - smtpResponse, - permanent: true - }, - userMessage: `The email could not be delivered to ${recipientAddress}.` - } - ); - } - - /** - * Creates an instance for a temporary delivery failure - * - * @param message Error message - * @param recipientAddress Recipient email address - * @param statusCode SMTP status code - * @param smtpResponse Full SMTP response - * @param maxRetries Maximum number of retries - * @param currentRetry Current retry count - * @param retryDelay Delay between retries in ms - * @param context Additional context - */ - public static temporary( - message: string, - recipientAddress: string, - statusCode?: string, - smtpResponse?: string, - maxRetries: number = 3, - currentRetry: number = 0, - retryDelay: number = 60000, - context: IErrorContext = {} - ): MtaDeliveryError { - const statusCodeStr = statusCode ? ` (${statusCode})` : ''; - const error = new MtaDeliveryError( - `Temporary delivery failure to ${recipientAddress}${statusCodeStr}: ${message}`, - { - ...context, - data: { - ...context.data, - recipientAddress, - statusCode, - smtpResponse, - permanent: false - }, - userMessage: `The email delivery to ${recipientAddress} failed temporarily. It will be retried.` - } - ); - - return error.withRetry(maxRetries, currentRetry, retryDelay) as MtaDeliveryError; - } - - /** - * Check if this is a permanent delivery failure - */ - public isPermanent(): boolean { - return !!this.context.data?.permanent; - } - - /** - * Get the recipient address associated with this delivery error - */ - public getRecipientAddress(): string | undefined { - return this.context.data?.recipientAddress; - } - - /** - * Get the SMTP status code associated with this delivery error - */ - public getStatusCode(): string | undefined { - return this.context.data?.statusCode; - } -} - -/** - * Error class for MTA configuration errors - */ -export class MtaConfigurationError extends ConfigurationError { - /** - * Creates a new MTA configuration error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, MTA_CONFIGURATION_ERROR, context); - } - - /** - * Creates a new instance with updated context - */ - protected createWithContext(context: IErrorContext): PlatformError { - return new (this.constructor as typeof MtaConfigurationError)( - this.message, - context - ); - } - - /** - * Creates an instance for a missing configuration value - * - * @param propertyPath Path to the missing property - * @param context Additional context - */ - public static missingConfig( - propertyPath: string, - context: IErrorContext = {} - ): MtaConfigurationError { - return new MtaConfigurationError( - `Missing required configuration: ${propertyPath}`, - { - ...context, - data: { - ...context.data, - propertyPath - }, - userMessage: `The mail server is missing required configuration.` - } - ); - } - - /** - * Creates an instance for an invalid configuration value - * - * @param propertyPath Path to the invalid property - * @param value Current value - * @param expectedType Expected type or format - * @param context Additional context - */ - public static invalidConfig( - propertyPath: string, - value: any, - expectedType: string, - context: IErrorContext = {} - ): MtaConfigurationError { - return new MtaConfigurationError( - `Invalid configuration value for ${propertyPath}: got ${value} (${typeof value}), expected ${expectedType}`, - { - ...context, - data: { - ...context.data, - propertyPath, - value, - expectedType - }, - userMessage: `The mail server has an invalid configuration value.` - } - ); - } -} - -/** - * Error class for MTA DNS errors - */ -export class MtaDnsError extends NetworkError { - /** - * Creates a new MTA DNS error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, MTA_DNS_ERROR, context); - } - - /** - * Creates a new instance with updated context - */ - protected createWithContext(context: IErrorContext): PlatformError { - return new (this.constructor as typeof MtaDnsError)( - this.message, - context - ); - } - - /** - * Creates an instance for an MX record lookup failure - * - * @param domain Domain that failed MX lookup - * @param originalError Original error - * @param context Additional context - */ - public static mxLookupFailed( - domain: string, - originalError?: Error, - context: IErrorContext = {} - ): MtaDnsError { - const errorMsg = originalError ? `: ${originalError.message}` : ''; - return new MtaDnsError( - `Failed to lookup MX records for ${domain}${errorMsg}`, - { - ...context, - data: { - ...context.data, - domain, - recordType: 'MX', - originalError: originalError ? { - message: originalError.message, - stack: originalError.stack - } : undefined - }, - userMessage: `Could not find mail servers for ${domain}.` - } - ); - } - - /** - * Creates an instance for a TXT record lookup failure - * - * @param domain Domain that failed TXT lookup - * @param recordPrefix Optional record prefix (e.g., 'spf', 'dkim', 'dmarc') - * @param originalError Original error - * @param context Additional context - */ - public static txtLookupFailed( - domain: string, - recordPrefix?: string, - originalError?: Error, - context: IErrorContext = {} - ): MtaDnsError { - const recordType = recordPrefix ? `${recordPrefix} TXT` : 'TXT'; - const errorMsg = originalError ? `: ${originalError.message}` : ''; - - return new MtaDnsError( - `Failed to lookup ${recordType} records for ${domain}${errorMsg}`, - { - ...context, - data: { - ...context.data, - domain, - recordType, - recordPrefix, - originalError: originalError ? { - message: originalError.message, - stack: originalError.stack - } : undefined - }, - userMessage: `Could not verify ${recordPrefix || ''} records for ${domain}.` - } - ); - } -} - -/** - * Error class for MTA timeout errors - */ -export class MtaTimeoutError extends NetworkError { - /** - * Creates a new MTA timeout error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, MTA_TIMEOUT_ERROR, context); - } - - /** - * Creates a new instance with updated context - */ - protected createWithContext(context: IErrorContext): PlatformError { - return new (this.constructor as typeof MtaTimeoutError)( - this.message, - context - ); - } - - /** - * Creates an instance for an SMTP command timeout - * - * @param command SMTP command that timed out - * @param server Server hostname - * @param timeout Timeout in milliseconds - * @param context Additional context - */ - public static commandTimeout( - command: string, - server: string, - timeout: number, - context: IErrorContext = {} - ): MtaTimeoutError { - return new MtaTimeoutError( - `SMTP command ${command} to ${server} timed out after ${timeout}ms`, - { - ...context, - data: { - ...context.data, - command, - server, - timeout - }, - userMessage: `The mail server took too long to respond.` - } - ); - } - - /** - * Creates an instance for an overall transaction timeout - * - * @param server Server hostname - * @param timeout Timeout in milliseconds - * @param context Additional context - */ - public static transactionTimeout( - server: string, - timeout: number, - context: IErrorContext = {} - ): MtaTimeoutError { - return new MtaTimeoutError( - `SMTP transaction with ${server} timed out after ${timeout}ms`, - { - ...context, - data: { - ...context.data, - server, - timeout - }, - userMessage: `The mail server transaction took too long to complete.` - } - ); - } -} - -/** - * Error class for MTA protocol errors - */ -export class MtaProtocolError extends OperationError { - /** - * Creates a new MTA protocol error - * - * @param message Error message - * @param context Additional context - */ - constructor( - message: string, - context: IErrorContext = {} - ) { - super(message, MTA_PROTOCOL_ERROR, context); - } - - /** - * Creates a new instance with updated context - */ - protected createWithContext(context: IErrorContext): PlatformError { - return new (this.constructor as typeof MtaProtocolError)( - this.message, - context - ); - } - - /** - * Creates an instance for an unexpected server response - * - * @param command SMTP command that received unexpected response - * @param response Unexpected response - * @param expected Expected response pattern - * @param server Server hostname - * @param context Additional context - */ - public static unexpectedResponse( - command: string, - response: string, - expected: string, - server: string, - context: IErrorContext = {} - ): MtaProtocolError { - return new MtaProtocolError( - `Unexpected SMTP response from ${server} for command ${command}: got "${response}", expected "${expected}"`, - { - ...context, - data: { - ...context.data, - command, - response, - expected, - server - }, - userMessage: `Received an unexpected response from the mail server.` - } - ); - } - - /** - * Creates an instance for a syntax error - * - * @param details Error details - * @param server Server hostname - * @param context Additional context - */ - public static syntaxError( - details: string, - server: string, - context: IErrorContext = {} - ): MtaProtocolError { - return new MtaProtocolError( - `SMTP syntax error in communication with ${server}: ${details}`, - { - ...context, - data: { - ...context.data, - details, - server - }, - userMessage: `There was a protocol error communicating with the mail server.` - } - ); - } -} \ No newline at end of file diff --git a/ts/index.ts b/ts/index.ts index 08c1188..90514f2 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1,5 +1,8 @@ export * from './00_commitinfo_data.js'; -export * from './mail/index.js'; + +// Re-export smartmta (excluding commitinfo to avoid naming conflict) +export { UnifiedEmailServer } from '@push.rocks/smartmta'; +export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta'; // DcRouter export * from './classes.dcrouter.js'; @@ -7,4 +10,4 @@ export * from './classes.dcrouter.js'; // RADIUS module export * from './radius/index.js'; -export const runCli = async () => {} \ No newline at end of file +export const runCli = async () => {}; diff --git a/ts/mail/core/classes.bouncemanager.ts b/ts/mail/core/classes.bouncemanager.ts deleted file mode 100644 index 91a29b8..0000000 --- a/ts/mail/core/classes.bouncemanager.ts +++ /dev/null @@ -1,965 +0,0 @@ -import * as plugins from '../../plugins.js'; -import * as paths from '../../paths.js'; -import { logger } from '../../logger.js'; -import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js'; -import { LRUCache } from 'lru-cache'; -import type { Email } from './classes.email.js'; - -/** - * Bounce types for categorizing the reasons for bounces - */ -export enum BounceType { - // Hard bounces (permanent failures) - INVALID_RECIPIENT = 'invalid_recipient', - DOMAIN_NOT_FOUND = 'domain_not_found', - MAILBOX_FULL = 'mailbox_full', - MAILBOX_INACTIVE = 'mailbox_inactive', - BLOCKED = 'blocked', - SPAM_RELATED = 'spam_related', - POLICY_RELATED = 'policy_related', - - // Soft bounces (temporary failures) - SERVER_UNAVAILABLE = 'server_unavailable', - TEMPORARY_FAILURE = 'temporary_failure', - QUOTA_EXCEEDED = 'quota_exceeded', - NETWORK_ERROR = 'network_error', - TIMEOUT = 'timeout', - - // Special cases - AUTO_RESPONSE = 'auto_response', - CHALLENGE_RESPONSE = 'challenge_response', - UNKNOWN = 'unknown' -} - -/** - * Hard vs soft bounce classification - */ -export enum BounceCategory { - HARD = 'hard', - SOFT = 'soft', - AUTO_RESPONSE = 'auto_response', - UNKNOWN = 'unknown' -} - -/** - * Bounce data structure - */ -export interface BounceRecord { - id: string; - originalEmailId?: string; - recipient: string; - sender: string; - domain: string; - subject?: string; - bounceType: BounceType; - bounceCategory: BounceCategory; - timestamp: number; - smtpResponse?: string; - diagnosticCode?: string; - statusCode?: string; - headers?: Record; - processed: boolean; - retryCount?: number; - nextRetryTime?: number; -} - -/** - * Email bounce patterns to identify bounce types in SMTP responses and bounce messages - */ -const BOUNCE_PATTERNS = { - // Hard bounce patterns - [BounceType.INVALID_RECIPIENT]: [ - /no such user/i, - /user unknown/i, - /does not exist/i, - /invalid recipient/i, - /unknown recipient/i, - /no mailbox/i, - /user not found/i, - /recipient address rejected/i, - /550 5\.1\.1/i - ], - [BounceType.DOMAIN_NOT_FOUND]: [ - /domain not found/i, - /unknown domain/i, - /no such domain/i, - /host not found/i, - /domain invalid/i, - /550 5\.1\.2/i - ], - [BounceType.MAILBOX_FULL]: [ - /mailbox full/i, - /over quota/i, - /quota exceeded/i, - /552 5\.2\.2/i - ], - [BounceType.MAILBOX_INACTIVE]: [ - /mailbox disabled/i, - /mailbox inactive/i, - /account disabled/i, - /mailbox not active/i, - /account suspended/i - ], - [BounceType.BLOCKED]: [ - /blocked/i, - /rejected/i, - /denied/i, - /blacklisted/i, - /prohibited/i, - /refused/i, - /550 5\.7\./i - ], - [BounceType.SPAM_RELATED]: [ - /spam/i, - /bulk mail/i, - /content rejected/i, - /message rejected/i, - /550 5\.7\.1/i - ], - - // Soft bounce patterns - [BounceType.SERVER_UNAVAILABLE]: [ - /server unavailable/i, - /service unavailable/i, - /try again later/i, - /try later/i, - /451 4\.3\./i, - /421 4\.3\./i - ], - [BounceType.TEMPORARY_FAILURE]: [ - /temporary failure/i, - /temporary error/i, - /temporary problem/i, - /try again/i, - /451 4\./i - ], - [BounceType.QUOTA_EXCEEDED]: [ - /quota temporarily exceeded/i, - /mailbox temporarily full/i, - /452 4\.2\.2/i - ], - [BounceType.NETWORK_ERROR]: [ - /network error/i, - /connection error/i, - /connection timed out/i, - /routing error/i, - /421 4\.4\./i - ], - [BounceType.TIMEOUT]: [ - /timed out/i, - /timeout/i, - /450 4\.4\.2/i - ], - - // Auto-responses - [BounceType.AUTO_RESPONSE]: [ - /auto[- ]reply/i, - /auto[- ]response/i, - /vacation/i, - /out of office/i, - /away from office/i, - /on vacation/i, - /automatic reply/i - ], - [BounceType.CHALLENGE_RESPONSE]: [ - /challenge[- ]response/i, - /verify your email/i, - /confirm your email/i, - /email verification/i - ] -}; - -/** - * Retry strategy configuration for soft bounces - */ -interface RetryStrategy { - maxRetries: number; - initialDelay: number; // milliseconds - maxDelay: number; // milliseconds - backoffFactor: number; -} - -/** - * Manager for handling email bounces - */ -export class BounceManager { - // Retry strategy with exponential backoff - private retryStrategy: RetryStrategy = { - maxRetries: 5, - initialDelay: 15 * 60 * 1000, // 15 minutes - maxDelay: 24 * 60 * 60 * 1000, // 24 hours - backoffFactor: 2 - }; - - // Store of bounced emails - private bounceStore: BounceRecord[] = []; - - // Cache of recently bounced email addresses to avoid sending to known bad addresses - private bounceCache: LRUCache; - - // Suppression list for addresses that should not receive emails - private suppressionList: Map = new Map(); - - private storageManager?: any; // StorageManager instance - - constructor(options?: { - retryStrategy?: Partial; - maxCacheSize?: number; - cacheTTL?: number; - storageManager?: any; - }) { - // Set retry strategy with defaults - if (options?.retryStrategy) { - this.retryStrategy = { - ...this.retryStrategy, - ...options.retryStrategy - }; - } - - // Initialize bounce cache with LRU (least recently used) caching - this.bounceCache = new LRUCache({ - max: options?.maxCacheSize || 10000, - ttl: options?.cacheTTL || 30 * 24 * 60 * 60 * 1000, // 30 days default - }); - - // Store storage manager reference - this.storageManager = options?.storageManager; - - // Load suppression list from storage - // Note: This is async but we can't await in constructor - // The suppression list will be loaded asynchronously - this.loadSuppressionList().catch(error => { - logger.log('error', `Failed to load suppression list on startup: ${error.message}`); - }); - } - - /** - * Process a bounce notification - * @param bounceData Bounce data to process - * @returns Processed bounce record - */ - public async processBounce(bounceData: Partial): Promise { - try { - // Add required fields if missing - const bounce: BounceRecord = { - id: bounceData.id || plugins.uuid.v4(), - recipient: bounceData.recipient, - sender: bounceData.sender, - domain: bounceData.domain || bounceData.recipient.split('@')[1], - subject: bounceData.subject, - bounceType: bounceData.bounceType || BounceType.UNKNOWN, - bounceCategory: bounceData.bounceCategory || BounceCategory.UNKNOWN, - timestamp: bounceData.timestamp || Date.now(), - smtpResponse: bounceData.smtpResponse, - diagnosticCode: bounceData.diagnosticCode, - statusCode: bounceData.statusCode, - headers: bounceData.headers, - processed: false, - originalEmailId: bounceData.originalEmailId, - retryCount: bounceData.retryCount || 0, - nextRetryTime: bounceData.nextRetryTime - }; - - // Determine bounce type and category if not provided - if (!bounceData.bounceType || bounceData.bounceType === BounceType.UNKNOWN) { - const bounceInfo = this.detectBounceType( - bounce.smtpResponse || '', - bounce.diagnosticCode || '', - bounce.statusCode || '' - ); - - bounce.bounceType = bounceInfo.type; - bounce.bounceCategory = bounceInfo.category; - } - - // Process the bounce based on category - switch (bounce.bounceCategory) { - case BounceCategory.HARD: - // Handle hard bounce - add to suppression list - await this.handleHardBounce(bounce); - break; - - case BounceCategory.SOFT: - // Handle soft bounce - schedule retry if eligible - await this.handleSoftBounce(bounce); - break; - - case BounceCategory.AUTO_RESPONSE: - // Handle auto-response - typically no action needed - logger.log('info', `Auto-response detected for ${bounce.recipient}`); - break; - - default: - // Unknown bounce type - log for investigation - logger.log('warn', `Unknown bounce type for ${bounce.recipient}`, { - bounceType: bounce.bounceType, - smtpResponse: bounce.smtpResponse - }); - break; - } - - // Store the bounce record - bounce.processed = true; - this.bounceStore.push(bounce); - - // Update the bounce cache - this.updateBounceCache(bounce); - - // Log the bounce - logger.log( - bounce.bounceCategory === BounceCategory.HARD ? 'warn' : 'info', - `Email bounce processed: ${bounce.bounceCategory} bounce for ${bounce.recipient}`, - { - bounceType: bounce.bounceType, - domain: bounce.domain, - category: bounce.bounceCategory - } - ); - - // Enhanced security logging - SecurityLogger.getInstance().logEvent({ - level: bounce.bounceCategory === BounceCategory.HARD - ? SecurityLogLevel.WARN - : SecurityLogLevel.INFO, - type: SecurityEventType.EMAIL_VALIDATION, - message: `Email bounce detected: ${bounce.bounceCategory} bounce for recipient`, - domain: bounce.domain, - details: { - recipient: bounce.recipient, - bounceType: bounce.bounceType, - smtpResponse: bounce.smtpResponse, - diagnosticCode: bounce.diagnosticCode, - statusCode: bounce.statusCode - }, - success: false - }); - - return bounce; - } catch (error) { - logger.log('error', `Error processing bounce: ${error.message}`, { - error: error.message, - bounceData - }); - throw error; - } - } - - /** - * Process an SMTP failure as a bounce - * @param recipient Recipient email - * @param smtpResponse SMTP error response - * @param options Additional options - * @returns Processed bounce record - */ - public async processSmtpFailure( - recipient: string, - smtpResponse: string, - options: { - sender?: string; - originalEmailId?: string; - statusCode?: string; - headers?: Record; - } = {} - ): Promise { - // Create bounce data from SMTP failure - const bounceData: Partial = { - recipient, - sender: options.sender || '', - domain: recipient.split('@')[1], - smtpResponse, - statusCode: options.statusCode, - headers: options.headers, - originalEmailId: options.originalEmailId, - timestamp: Date.now() - }; - - // Process as a regular bounce - return this.processBounce(bounceData); - } - - /** - * Process a bounce notification email - * @param bounceEmail The email containing bounce information - * @returns Processed bounce record or null if not a bounce - */ - public async processBounceEmail(bounceEmail: Email): Promise { - try { - // Check if this is a bounce notification - const subject = bounceEmail.getSubject(); - const body = bounceEmail.getBody(); - - // Check for common bounce notification subject patterns - const isBounceSubject = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject); - - if (!isBounceSubject) { - // Not a bounce notification based on subject - return null; - } - - // Extract original recipient from the body or headers - let recipient = ''; - let originalMessageId = ''; - - // Extract recipient from common bounce formats - const recipientMatch = body.match(/(?:failed recipient|to[:=]\s*|recipient:|delivery failed:)\s*?/i); - if (recipientMatch && recipientMatch[1]) { - recipient = recipientMatch[1]; - } - - // Extract diagnostic code - let diagnosticCode = ''; - const diagnosticMatch = body.match(/diagnostic(?:-|\\s+)code:\s*(.+)(?:\n|$)/i); - if (diagnosticMatch && diagnosticMatch[1]) { - diagnosticCode = diagnosticMatch[1].trim(); - } - - // Extract SMTP status code - let statusCode = ''; - const statusMatch = body.match(/status(?:-|\\s+)code:\s*([0-9.]+)/i); - if (statusMatch && statusMatch[1]) { - statusCode = statusMatch[1].trim(); - } - - // If recipient not found in standard patterns, try DSN (Delivery Status Notification) format - if (!recipient) { - // Look for DSN format with Original-Recipient or Final-Recipient fields - const originalRecipientMatch = body.match(/original-recipient:.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i); - const finalRecipientMatch = body.match(/final-recipient:.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i); - - if (originalRecipientMatch && originalRecipientMatch[1]) { - recipient = originalRecipientMatch[1]; - } else if (finalRecipientMatch && finalRecipientMatch[1]) { - recipient = finalRecipientMatch[1]; - } - } - - // If still no recipient, can't process as bounce - if (!recipient) { - logger.log('warn', 'Could not extract recipient from bounce notification', { - subject, - sender: bounceEmail.from - }); - return null; - } - - // Extract original message ID if available - const messageIdMatch = body.match(/original[ -]message[ -]id:[ \t]*]+)>?/i); - if (messageIdMatch && messageIdMatch[1]) { - originalMessageId = messageIdMatch[1].trim(); - } - - // Create bounce data - const bounceData: Partial = { - recipient, - sender: bounceEmail.from, - domain: recipient.split('@')[1], - subject: bounceEmail.getSubject(), - diagnosticCode, - statusCode, - timestamp: Date.now(), - headers: {} - }; - - // Process as a regular bounce - return this.processBounce(bounceData); - } catch (error) { - logger.log('error', `Error processing bounce email: ${error.message}`); - return null; - } - } - - /** - * Handle a hard bounce by adding to suppression list - * @param bounce The bounce record - */ - private async handleHardBounce(bounce: BounceRecord): Promise { - // Add to suppression list permanently (no expiry) - this.addToSuppressionList(bounce.recipient, `Hard bounce: ${bounce.bounceType}`, undefined); - - // Increment bounce count in cache - this.updateBounceCache(bounce); - - // Save to permanent storage - await this.saveBounceRecord(bounce); - - // Log hard bounce for monitoring - logger.log('warn', `Hard bounce for ${bounce.recipient}: ${bounce.bounceType}`, { - domain: bounce.domain, - smtpResponse: bounce.smtpResponse, - diagnosticCode: bounce.diagnosticCode - }); - } - - /** - * Handle a soft bounce by scheduling a retry if eligible - * @param bounce The bounce record - */ - private async handleSoftBounce(bounce: BounceRecord): Promise { - // Check if we've exceeded max retries - if (bounce.retryCount >= this.retryStrategy.maxRetries) { - logger.log('warn', `Max retries exceeded for ${bounce.recipient}, treating as hard bounce`); - - // Convert to hard bounce after max retries - bounce.bounceCategory = BounceCategory.HARD; - await this.handleHardBounce(bounce); - return; - } - - // Calculate next retry time with exponential backoff - const delay = Math.min( - this.retryStrategy.initialDelay * Math.pow(this.retryStrategy.backoffFactor, bounce.retryCount), - this.retryStrategy.maxDelay - ); - - bounce.retryCount++; - bounce.nextRetryTime = Date.now() + delay; - - // Add to suppression list temporarily (with expiry) - this.addToSuppressionList( - bounce.recipient, - `Soft bounce: ${bounce.bounceType}`, - bounce.nextRetryTime - ); - - // Log the retry schedule - logger.log('info', `Scheduled retry ${bounce.retryCount} for ${bounce.recipient} at ${new Date(bounce.nextRetryTime).toISOString()}`, { - bounceType: bounce.bounceType, - retryCount: bounce.retryCount, - nextRetry: bounce.nextRetryTime - }); - } - - /** - * Add an email address to the suppression list - * @param email Email address to suppress - * @param reason Reason for suppression - * @param expiresAt Expiration timestamp (undefined for permanent) - */ - public addToSuppressionList( - email: string, - reason: string, - expiresAt?: number - ): void { - this.suppressionList.set(email.toLowerCase(), { - reason, - timestamp: Date.now(), - expiresAt - }); - - // Save asynchronously without blocking - this.saveSuppressionList().catch(error => { - logger.log('error', `Failed to save suppression list after adding ${email}: ${error.message}`); - }); - - logger.log('info', `Added ${email} to suppression list`, { - reason, - expiresAt: expiresAt ? new Date(expiresAt).toISOString() : 'permanent' - }); - } - - /** - * Remove an email address from the suppression list - * @param email Email address to remove - */ - public removeFromSuppressionList(email: string): void { - const wasRemoved = this.suppressionList.delete(email.toLowerCase()); - - if (wasRemoved) { - // Save asynchronously without blocking - this.saveSuppressionList().catch(error => { - logger.log('error', `Failed to save suppression list after removing ${email}: ${error.message}`); - }); - logger.log('info', `Removed ${email} from suppression list`); - } - } - - /** - * Check if an email is on the suppression list - * @param email Email address to check - * @returns Whether the email is suppressed - */ - public isEmailSuppressed(email: string): boolean { - const lowercaseEmail = email.toLowerCase(); - const suppression = this.suppressionList.get(lowercaseEmail); - - if (!suppression) { - return false; - } - - // Check if suppression has expired - if (suppression.expiresAt && Date.now() > suppression.expiresAt) { - this.suppressionList.delete(lowercaseEmail); - // Save asynchronously without blocking - this.saveSuppressionList().catch(error => { - logger.log('error', `Failed to save suppression list after expiry cleanup: ${error.message}`); - }); - return false; - } - - return true; - } - - /** - * Get suppression information for an email - * @param email Email address to check - * @returns Suppression information or null if not suppressed - */ - public getSuppressionInfo(email: string): { - reason: string; - timestamp: number; - expiresAt?: number; - } | null { - const lowercaseEmail = email.toLowerCase(); - const suppression = this.suppressionList.get(lowercaseEmail); - - if (!suppression) { - return null; - } - - // Check if suppression has expired - if (suppression.expiresAt && Date.now() > suppression.expiresAt) { - this.suppressionList.delete(lowercaseEmail); - // Save asynchronously without blocking - this.saveSuppressionList().catch(error => { - logger.log('error', `Failed to save suppression list after expiry cleanup: ${error.message}`); - }); - return null; - } - - return suppression; - } - - /** - * Save suppression list to disk - */ - private async saveSuppressionList(): Promise { - try { - const suppressionData = JSON.stringify(Array.from(this.suppressionList.entries())); - - if (this.storageManager) { - // Use storage manager - await this.storageManager.set('/email/bounces/suppression-list.json', suppressionData); - } else { - // Fall back to filesystem - plugins.fsUtils.toFsSync( - suppressionData, - plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json') - ); - } - } catch (error) { - logger.log('error', `Failed to save suppression list: ${error.message}`); - } - } - - /** - * Load suppression list from disk - */ - private async loadSuppressionList(): Promise { - try { - let entries = null; - let needsMigration = false; - - if (this.storageManager) { - // Try to load from storage manager first - const suppressionData = await this.storageManager.get('/email/bounces/suppression-list.json'); - - if (suppressionData) { - entries = JSON.parse(suppressionData); - } else { - // Check if data exists in filesystem and migrate - const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json'); - - if (plugins.fs.existsSync(suppressionPath)) { - const data = plugins.fs.readFileSync(suppressionPath, 'utf8'); - entries = JSON.parse(data); - needsMigration = true; - - logger.log('info', 'Migrating suppression list from filesystem to StorageManager'); - } - } - } else { - // No storage manager, use filesystem directly - const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json'); - - if (plugins.fs.existsSync(suppressionPath)) { - const data = plugins.fs.readFileSync(suppressionPath, 'utf8'); - entries = JSON.parse(data); - } - } - - if (entries) { - this.suppressionList = new Map(entries); - - // Clean expired entries - const now = Date.now(); - let expiredCount = 0; - - for (const [email, info] of this.suppressionList.entries()) { - if (info.expiresAt && now > info.expiresAt) { - this.suppressionList.delete(email); - expiredCount++; - } - } - - if (expiredCount > 0 || needsMigration) { - logger.log('info', `Cleaned ${expiredCount} expired entries from suppression list`); - await this.saveSuppressionList(); - } - - logger.log('info', `Loaded ${this.suppressionList.size} entries from suppression list`); - } - } catch (error) { - logger.log('error', `Failed to load suppression list: ${error.message}`); - } - } - - /** - * Save bounce record to disk - * @param bounce Bounce record to save - */ - private async saveBounceRecord(bounce: BounceRecord): Promise { - try { - const bounceData = JSON.stringify(bounce, null, 2); - - if (this.storageManager) { - // Use storage manager - await this.storageManager.set(`/email/bounces/records/${bounce.id}.json`, bounceData); - } else { - // Fall back to filesystem - const bouncePath = plugins.path.join( - paths.dataDir, - 'emails', - 'bounces', - `${bounce.id}.json` - ); - - // Ensure directory exists - const bounceDir = plugins.path.join(paths.dataDir, 'emails', 'bounces'); - plugins.fsUtils.ensureDirSync(bounceDir); - - plugins.fsUtils.toFsSync(bounceData, bouncePath); - } - } catch (error) { - logger.log('error', `Failed to save bounce record: ${error.message}`); - } - } - - /** - * Update bounce cache with new bounce information - * @param bounce Bounce record to update cache with - */ - private updateBounceCache(bounce: BounceRecord): void { - const email = bounce.recipient.toLowerCase(); - const existing = this.bounceCache.get(email); - - if (existing) { - // Update existing cache entry - existing.lastBounce = bounce.timestamp; - existing.count++; - existing.type = bounce.bounceType; - existing.category = bounce.bounceCategory; - } else { - // Create new cache entry - this.bounceCache.set(email, { - lastBounce: bounce.timestamp, - count: 1, - type: bounce.bounceType, - category: bounce.bounceCategory - }); - } - } - - /** - * Check bounce history for an email address - * @param email Email address to check - * @returns Bounce information or null if no bounces - */ - public getBounceInfo(email: string): { - lastBounce: number; - count: number; - type: BounceType; - category: BounceCategory; - } | null { - return this.bounceCache.get(email.toLowerCase()) || null; - } - - /** - * Analyze SMTP response and diagnostic codes to determine bounce type - * @param smtpResponse SMTP response string - * @param diagnosticCode Diagnostic code from bounce - * @param statusCode Status code from bounce - * @returns Detected bounce type and category - */ - private detectBounceType( - smtpResponse: string, - diagnosticCode: string, - statusCode: string - ): { - type: BounceType; - category: BounceCategory; - } { - // Combine all text for comprehensive pattern matching - const fullText = `${smtpResponse} ${diagnosticCode} ${statusCode}`.toLowerCase(); - - // Check for auto-responses first - if (this.matchesPattern(fullText, BounceType.AUTO_RESPONSE) || - this.matchesPattern(fullText, BounceType.CHALLENGE_RESPONSE)) { - return { - type: BounceType.AUTO_RESPONSE, - category: BounceCategory.AUTO_RESPONSE - }; - } - - // Check for hard bounces - for (const bounceType of [ - BounceType.INVALID_RECIPIENT, - BounceType.DOMAIN_NOT_FOUND, - BounceType.MAILBOX_FULL, - BounceType.MAILBOX_INACTIVE, - BounceType.BLOCKED, - BounceType.SPAM_RELATED, - BounceType.POLICY_RELATED - ]) { - if (this.matchesPattern(fullText, bounceType)) { - return { - type: bounceType, - category: BounceCategory.HARD - }; - } - } - - // Check for soft bounces - for (const bounceType of [ - BounceType.SERVER_UNAVAILABLE, - BounceType.TEMPORARY_FAILURE, - BounceType.QUOTA_EXCEEDED, - BounceType.NETWORK_ERROR, - BounceType.TIMEOUT - ]) { - if (this.matchesPattern(fullText, bounceType)) { - return { - type: bounceType, - category: BounceCategory.SOFT - }; - } - } - - // Handle DSN (Delivery Status Notification) status codes - if (statusCode) { - // Format: class.subject.detail - const parts = statusCode.split('.'); - if (parts.length >= 2) { - const statusClass = parts[0]; - const statusSubject = parts[1]; - - // 5.X.X is permanent failure (hard bounce) - if (statusClass === '5') { - // Try to determine specific type based on subject - if (statusSubject === '1') { - return { type: BounceType.INVALID_RECIPIENT, category: BounceCategory.HARD }; - } else if (statusSubject === '2') { - return { type: BounceType.MAILBOX_FULL, category: BounceCategory.HARD }; - } else if (statusSubject === '7') { - return { type: BounceType.BLOCKED, category: BounceCategory.HARD }; - } else { - return { type: BounceType.UNKNOWN, category: BounceCategory.HARD }; - } - } - - // 4.X.X is temporary failure (soft bounce) - if (statusClass === '4') { - // Try to determine specific type based on subject - if (statusSubject === '2') { - return { type: BounceType.QUOTA_EXCEEDED, category: BounceCategory.SOFT }; - } else if (statusSubject === '3') { - return { type: BounceType.SERVER_UNAVAILABLE, category: BounceCategory.SOFT }; - } else if (statusSubject === '4') { - return { type: BounceType.NETWORK_ERROR, category: BounceCategory.SOFT }; - } else { - return { type: BounceType.TEMPORARY_FAILURE, category: BounceCategory.SOFT }; - } - } - } - } - - // Default to unknown - return { - type: BounceType.UNKNOWN, - category: BounceCategory.UNKNOWN - }; - } - - /** - * Check if text matches any pattern for a bounce type - * @param text Text to check against patterns - * @param bounceType Bounce type to get patterns for - * @returns Whether the text matches any pattern - */ - private matchesPattern(text: string, bounceType: BounceType): boolean { - const patterns = BOUNCE_PATTERNS[bounceType]; - - if (!patterns) { - return false; - } - - for (const pattern of patterns) { - if (pattern.test(text)) { - return true; - } - } - - return false; - } - - /** - * Get all known hard bounced addresses - * @returns Array of hard bounced email addresses - */ - public getHardBouncedAddresses(): string[] { - const hardBounced: string[] = []; - - for (const [email, info] of this.bounceCache.entries()) { - if (info.category === BounceCategory.HARD) { - hardBounced.push(email); - } - } - - return hardBounced; - } - - /** - * Get suppression list - * @returns Array of suppressed email addresses - */ - public getSuppressionList(): string[] { - return Array.from(this.suppressionList.keys()); - } - - /** - * Clear old bounce records (for maintenance) - * @param olderThan Timestamp to remove records older than - * @returns Number of records removed - */ - public clearOldBounceRecords(olderThan: number): number { - let removed = 0; - - this.bounceStore = this.bounceStore.filter(bounce => { - if (bounce.timestamp < olderThan) { - removed++; - return false; - } - return true; - }); - - return removed; - } -} \ No newline at end of file diff --git a/ts/mail/core/classes.email.ts b/ts/mail/core/classes.email.ts deleted file mode 100644 index ca07ba1..0000000 --- a/ts/mail/core/classes.email.ts +++ /dev/null @@ -1,942 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { EmailValidator } from './classes.emailvalidator.js'; - -export interface IAttachment { - filename: string; - content: Buffer; - contentType: string; - contentId?: string; // Optional content ID for inline attachments - encoding?: string; // Optional encoding specification -} - -export interface IEmailOptions { - from: string; - to?: string | string[]; // Optional for templates - cc?: string | string[]; // Optional CC recipients - bcc?: string | string[]; // Optional BCC recipients - subject: string; - text: string; - html?: string; // Optional HTML version - attachments?: IAttachment[]; - headers?: Record; // Optional additional headers - mightBeSpam?: boolean; - priority?: 'high' | 'normal' | 'low'; // Optional email priority - skipAdvancedValidation?: boolean; // Skip advanced validation for special cases - variables?: Record; // Template variables for placeholder replacement -} - -/** - * Email class represents a complete email message. - * - * This class takes IEmailOptions in the constructor and normalizes the data: - * - 'to', 'cc', 'bcc' are always converted to arrays - * - Optional properties get default values - * - Additional properties like messageId and envelopeFrom are generated - */ -export class Email { - // INormalizedEmail properties - from: string; - to: string[]; - cc: string[]; - bcc: string[]; - subject: string; - text: string; - html?: string; - attachments: IAttachment[]; - headers: Record; - mightBeSpam: boolean; - priority: 'high' | 'normal' | 'low'; - variables: Record; - - // Additional Email-specific properties - private envelopeFrom: string; - private messageId: string; - - // Static validator instance for reuse - private static emailValidator: EmailValidator; - - constructor(options: IEmailOptions) { - // Initialize validator if not already - if (!Email.emailValidator) { - Email.emailValidator = new EmailValidator(); - } - - // Validate and set the from address using improved validation - if (!this.isValidEmail(options.from)) { - throw new Error(`Invalid sender email address: ${options.from}`); - } - this.from = options.from; - - // Handle to addresses (single or multiple) - this.to = options.to ? this.parseRecipients(options.to) : []; - - // Handle optional cc and bcc - this.cc = options.cc ? this.parseRecipients(options.cc) : []; - this.bcc = options.bcc ? this.parseRecipients(options.bcc) : []; - - // Note: Templates may be created without recipients - // Recipients will be added when the email is actually sent - - // Set subject with sanitization - this.subject = this.sanitizeString(options.subject || ''); - - // Set text content with sanitization - this.text = this.sanitizeString(options.text || ''); - - // Set optional HTML content - this.html = options.html ? this.sanitizeString(options.html) : undefined; - - // Set attachments - this.attachments = Array.isArray(options.attachments) ? options.attachments : []; - - // Set additional headers - this.headers = options.headers || {}; - - // Set spam flag - this.mightBeSpam = options.mightBeSpam || false; - - // Set priority - this.priority = options.priority || 'normal'; - - // Set template variables - this.variables = options.variables || {}; - - // Initialize envelope from (defaults to the from address) - this.envelopeFrom = this.from; - - // Generate message ID if not provided - this.messageId = `<${Date.now()}.${Math.random().toString(36).substring(2, 15)}@${this.getFromDomain() || 'localhost'}>`; - } - - /** - * Validates an email address using smartmail's EmailAddressValidator - * For constructor validation, we only check syntax to avoid delays - * Supports RFC-compliant addresses including display names and bounce addresses. - * - * @param email The email address to validate - * @returns boolean indicating if the email is valid - */ - private isValidEmail(email: string): boolean { - if (!email || typeof email !== 'string') return false; - - // Handle empty return path (bounce address) - if (email === '<>' || email === '') { - return true; // Empty return path is valid for bounces per RFC 5321 - } - - // Extract email from display name format - const extractedEmail = this.extractEmailAddress(email); - if (!extractedEmail) return false; - - // Convert IDN (International Domain Names) to ASCII for validation - let emailToValidate = extractedEmail; - const atIndex = extractedEmail.indexOf('@'); - if (atIndex > 0) { - const localPart = extractedEmail.substring(0, atIndex); - const domainPart = extractedEmail.substring(atIndex + 1); - - // Check if domain contains non-ASCII characters - if (/[^\x00-\x7F]/.test(domainPart)) { - try { - // Convert IDN to ASCII using the URL API (built-in punycode support) - const url = new URL(`http://${domainPart}`); - emailToValidate = `${localPart}@${url.hostname}`; - } catch (e) { - // If conversion fails, allow the original domain - // This supports testing and edge cases - emailToValidate = extractedEmail; - } - } - } - - // Use smartmail's validation for the ASCII-converted email address - return Email.emailValidator.isValidFormat(emailToValidate); - } - - /** - * Extracts the email address from a string that may contain a display name. - * Handles formats like: - * - simple@example.com - * - "John Doe" - * - John Doe - * - * @param emailString The email string to parse - * @returns The extracted email address or null - */ - private extractEmailAddress(emailString: string): string | null { - if (!emailString || typeof emailString !== 'string') return null; - - emailString = emailString.trim(); - - // Handle empty return path first - if (emailString === '<>' || emailString === '') { - return ''; - } - - // Check for angle brackets format - updated regex to handle empty content - const angleMatch = emailString.match(/<([^>]*)>/); - if (angleMatch) { - // If matched but content is empty (e.g., <>), return empty string - return angleMatch[1].trim() || ''; - } - - // If no angle brackets, assume it's a plain email - return emailString.trim(); - } - - /** - * Parses and validates recipient email addresses - * @param recipients A string or array of recipient emails - * @returns Array of validated email addresses - */ - private parseRecipients(recipients: string | string[]): string[] { - const result: string[] = []; - - if (typeof recipients === 'string') { - // Handle single recipient - if (this.isValidEmail(recipients)) { - result.push(recipients); - } else { - throw new Error(`Invalid recipient email address: ${recipients}`); - } - } else if (Array.isArray(recipients)) { - // Handle multiple recipients - for (const recipient of recipients) { - if (this.isValidEmail(recipient)) { - result.push(recipient); - } else { - throw new Error(`Invalid recipient email address: ${recipient}`); - } - } - } - - return result; - } - - /** - * Basic sanitization for strings to prevent header injection - * @param input The string to sanitize - * @returns Sanitized string - */ - private sanitizeString(input: string): string { - if (!input) return ''; - - // Remove CR and LF characters to prevent header injection - // But preserve all other special characters including Unicode - return input.replace(/\r|\n/g, ' '); - } - - /** - * Gets the domain part of the from email address - * @returns The domain part of the from email or null if invalid - */ - public getFromDomain(): string | null { - try { - const emailAddress = this.extractEmailAddress(this.from); - if (!emailAddress || emailAddress === '') { - return null; - } - const parts = emailAddress.split('@'); - if (parts.length !== 2 || !parts[1]) { - return null; - } - return parts[1]; - } catch (error) { - console.error('Error extracting domain from email:', error); - return null; - } - } - - /** - * Gets the clean from email address without display name - * @returns The email address without display name - */ - public getFromAddress(): string { - const extracted = this.extractEmailAddress(this.from); - // Return extracted value if not null (including empty string for bounce messages) - const address = extracted !== null ? extracted : this.from; - - // Convert IDN to ASCII for SMTP protocol - return this.convertIDNToASCII(address); - } - - /** - * Converts IDN (International Domain Names) to ASCII - * @param email The email address to convert - * @returns The email with ASCII-converted domain - */ - private convertIDNToASCII(email: string): string { - if (!email || email === '') return email; - - const atIndex = email.indexOf('@'); - if (atIndex <= 0) return email; - - const localPart = email.substring(0, atIndex); - const domainPart = email.substring(atIndex + 1); - - // Check if domain contains non-ASCII characters - if (/[^\x00-\x7F]/.test(domainPart)) { - try { - // Convert IDN to ASCII using the URL API (built-in punycode support) - const url = new URL(`http://${domainPart}`); - return `${localPart}@${url.hostname}`; - } catch (e) { - // If conversion fails, return original - return email; - } - } - - return email; - } - - /** - * Gets clean to email addresses without display names - * @returns Array of email addresses without display names - */ - public getToAddresses(): string[] { - return this.to.map(email => { - const extracted = this.extractEmailAddress(email); - const address = extracted !== null ? extracted : email; - return this.convertIDNToASCII(address); - }); - } - - /** - * Gets clean cc email addresses without display names - * @returns Array of email addresses without display names - */ - public getCcAddresses(): string[] { - return this.cc.map(email => { - const extracted = this.extractEmailAddress(email); - const address = extracted !== null ? extracted : email; - return this.convertIDNToASCII(address); - }); - } - - /** - * Gets clean bcc email addresses without display names - * @returns Array of email addresses without display names - */ - public getBccAddresses(): string[] { - return this.bcc.map(email => { - const extracted = this.extractEmailAddress(email); - const address = extracted !== null ? extracted : email; - return this.convertIDNToASCII(address); - }); - } - - /** - * Gets all recipients (to, cc, bcc) as a unique array - * @returns Array of all unique recipient email addresses - */ - public getAllRecipients(): string[] { - // Combine all recipients and remove duplicates - return [...new Set([...this.to, ...this.cc, ...this.bcc])]; - } - - /** - * Gets primary recipient (first in the to field) - * @returns The primary recipient email or null if none exists - */ - public getPrimaryRecipient(): string | null { - return this.to.length > 0 ? this.to[0] : null; - } - - /** - * Checks if the email has attachments - * @returns Boolean indicating if the email has attachments - */ - public hasAttachments(): boolean { - return this.attachments.length > 0; - } - - /** - * Add a recipient to the email - * @param email The recipient email address - * @param type The recipient type (to, cc, bcc) - * @returns This instance for method chaining - */ - public addRecipient( - email: string, - type: 'to' | 'cc' | 'bcc' = 'to' - ): this { - if (!this.isValidEmail(email)) { - throw new Error(`Invalid recipient email address: ${email}`); - } - - switch (type) { - case 'to': - if (!this.to.includes(email)) { - this.to.push(email); - } - break; - case 'cc': - if (!this.cc.includes(email)) { - this.cc.push(email); - } - break; - case 'bcc': - if (!this.bcc.includes(email)) { - this.bcc.push(email); - } - break; - } - - return this; - } - - /** - * Add an attachment to the email - * @param attachment The attachment to add - * @returns This instance for method chaining - */ - public addAttachment(attachment: IAttachment): this { - this.attachments.push(attachment); - return this; - } - - /** - * Add a custom header to the email - * @param name The header name - * @param value The header value - * @returns This instance for method chaining - */ - public addHeader(name: string, value: string): this { - this.headers[name] = value; - return this; - } - - /** - * Set the email priority - * @param priority The priority level - * @returns This instance for method chaining - */ - public setPriority(priority: 'high' | 'normal' | 'low'): this { - this.priority = priority; - return this; - } - - /** - * Set a template variable - * @param key The variable key - * @param value The variable value - * @returns This instance for method chaining - */ - public setVariable(key: string, value: any): this { - this.variables[key] = value; - return this; - } - - /** - * Set multiple template variables at once - * @param variables The variables object - * @returns This instance for method chaining - */ - public setVariables(variables: Record): this { - this.variables = { ...this.variables, ...variables }; - return this; - } - - /** - * Get the subject with variables applied - * @param variables Optional additional variables to apply - * @returns The processed subject - */ - public getSubjectWithVariables(variables?: Record): string { - return this.applyVariables(this.subject, variables); - } - - /** - * Get the text content with variables applied - * @param variables Optional additional variables to apply - * @returns The processed text content - */ - public getTextWithVariables(variables?: Record): string { - return this.applyVariables(this.text, variables); - } - - /** - * Get the HTML content with variables applied - * @param variables Optional additional variables to apply - * @returns The processed HTML content or undefined if none - */ - public getHtmlWithVariables(variables?: Record): string | undefined { - return this.html ? this.applyVariables(this.html, variables) : undefined; - } - - /** - * Apply template variables to a string - * @param template The template string - * @param additionalVariables Optional additional variables to apply - * @returns The processed string - */ - private applyVariables(template: string, additionalVariables?: Record): string { - // If no template or variables, return as is - if (!template || (!Object.keys(this.variables).length && !additionalVariables)) { - return template; - } - - // Combine instance variables with additional ones - const allVariables = { ...this.variables, ...additionalVariables }; - - // Simple variable replacement - return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => { - const trimmedKey = key.trim(); - return allVariables[trimmedKey] !== undefined ? String(allVariables[trimmedKey]) : match; - }); - } - - /** - * Gets the total size of all attachments in bytes - * @returns Total size of all attachments in bytes - */ - public getAttachmentsSize(): number { - return this.attachments.reduce((total, attachment) => { - return total + (attachment.content?.length || 0); - }, 0); - } - - /** - * Perform advanced validation on sender and recipient email addresses - * This should be called separately after instantiation when ready to check MX records - * @param options Validation options - * @returns Promise resolving to validation results for all addresses - */ - public async validateAddresses(options: { - checkMx?: boolean; - checkDisposable?: boolean; - checkSenderOnly?: boolean; - checkFirstRecipientOnly?: boolean; - } = {}): Promise<{ - sender: { email: string; result: any }; - recipients: Array<{ email: string; result: any }>; - isValid: boolean; - }> { - const result = { - sender: { email: this.from, result: null }, - recipients: [], - isValid: true - }; - - // Validate sender - result.sender.result = await Email.emailValidator.validate(this.from, { - checkMx: options.checkMx !== false, - checkDisposable: options.checkDisposable !== false - }); - - // If sender fails validation, the whole email is considered invalid - if (!result.sender.result.isValid) { - result.isValid = false; - } - - // If we're only checking the sender, return early - if (options.checkSenderOnly) { - return result; - } - - // Validate recipients - const recipientsToCheck = options.checkFirstRecipientOnly ? - [this.to[0]] : this.getAllRecipients(); - - for (const recipient of recipientsToCheck) { - const recipientResult = await Email.emailValidator.validate(recipient, { - checkMx: options.checkMx !== false, - checkDisposable: options.checkDisposable !== false - }); - - result.recipients.push({ - email: recipient, - result: recipientResult - }); - - // If any recipient fails validation, mark the whole email as invalid - if (!recipientResult.isValid) { - result.isValid = false; - } - } - - return result; - } - - /** - * Convert this email to a smartmail instance - * @returns A new Smartmail instance - */ - public async toSmartmail(): Promise> { - const smartmail = new plugins.smartmail.Smartmail({ - from: this.from, - subject: this.subject, - body: this.html || this.text - }); - - // Add recipients - ensure we're using the correct format - // (newer version of smartmail expects objects with email property) - for (const recipient of this.to) { - // Use the proper addRecipient method for the current smartmail version - if (typeof smartmail.addRecipient === 'function') { - smartmail.addRecipient(recipient); - } else { - // Fallback for older versions or different interface - (smartmail.options.to as any[]).push({ - email: recipient, - name: recipient.split('@')[0] // Simple name extraction - }); - } - } - - // Handle CC recipients - for (const ccRecipient of this.cc) { - if (typeof smartmail.addRecipient === 'function') { - smartmail.addRecipient(ccRecipient, 'cc'); - } else { - // Fallback for older versions - if (!smartmail.options.cc) smartmail.options.cc = []; - (smartmail.options.cc as any[]).push({ - email: ccRecipient, - name: ccRecipient.split('@')[0] - }); - } - } - - // Handle BCC recipients - for (const bccRecipient of this.bcc) { - if (typeof smartmail.addRecipient === 'function') { - smartmail.addRecipient(bccRecipient, 'bcc'); - } else { - // Fallback for older versions - if (!smartmail.options.bcc) smartmail.options.bcc = []; - (smartmail.options.bcc as any[]).push({ - email: bccRecipient, - name: bccRecipient.split('@')[0] - }); - } - } - - // Add attachments - const smartFileFactory = plugins.smartfile.SmartFileFactory.nodeFs(); - for (const attachment of this.attachments) { - const smartAttachment = smartFileFactory.fromBuffer( - attachment.filename, - attachment.content - ); - - // Set content type if available - if (attachment.contentType) { - (smartAttachment as any).contentType = attachment.contentType; - } - - smartmail.addAttachment(smartAttachment); - } - - return smartmail; - } - - /** - * Get the from email address - * @returns The from email address - */ - public getFromEmail(): string { - return this.from; - } - - /** - * Get the subject (Smartmail compatibility method) - * @returns The email subject - */ - public getSubject(): string { - return this.subject; - } - - /** - * Get the body content (Smartmail compatibility method) - * @param isHtml Whether to return HTML content if available - * @returns The email body (HTML if requested and available, otherwise plain text) - */ - public getBody(isHtml: boolean = false): string { - if (isHtml && this.html) { - return this.html; - } - return this.text; - } - - /** - * Get the from address (Smartmail compatibility method) - * @returns The sender email address - */ - public getFrom(): string { - return this.from; - } - - /** - * Get the message ID - * @returns The message ID - */ - public getMessageId(): string { - return this.messageId; - } - - /** - * Convert the Email instance back to IEmailOptions format. - * Useful for serialization or passing to APIs that expect IEmailOptions. - * Note: This loses some Email-specific properties like messageId and envelopeFrom. - * - * @returns IEmailOptions representation of this email - */ - public toEmailOptions(): IEmailOptions { - const options: IEmailOptions = { - from: this.from, - to: this.to.length === 1 ? this.to[0] : this.to, - subject: this.subject, - text: this.text - }; - - // Add optional properties only if they have values - if (this.cc && this.cc.length > 0) { - options.cc = this.cc.length === 1 ? this.cc[0] : this.cc; - } - - if (this.bcc && this.bcc.length > 0) { - options.bcc = this.bcc.length === 1 ? this.bcc[0] : this.bcc; - } - - if (this.html) { - options.html = this.html; - } - - if (this.attachments && this.attachments.length > 0) { - options.attachments = this.attachments; - } - - if (this.headers && Object.keys(this.headers).length > 0) { - options.headers = this.headers; - } - - if (this.mightBeSpam) { - options.mightBeSpam = this.mightBeSpam; - } - - if (this.priority !== 'normal') { - options.priority = this.priority; - } - - if (this.variables && Object.keys(this.variables).length > 0) { - options.variables = this.variables; - } - - return options; - } - - /** - * Set a custom message ID - * @param id The message ID to set - * @returns This instance for method chaining - */ - public setMessageId(id: string): this { - this.messageId = id; - return this; - } - - /** - * Get the envelope from address (return-path) - * @returns The envelope from address - */ - public getEnvelopeFrom(): string { - return this.envelopeFrom; - } - - /** - * Set the envelope from address (return-path) - * @param address The envelope from address to set - * @returns This instance for method chaining - */ - public setEnvelopeFrom(address: string): this { - if (!this.isValidEmail(address)) { - throw new Error(`Invalid envelope from address: ${address}`); - } - this.envelopeFrom = address; - return this; - } - - /** - * Creates an RFC822 compliant email string - * @param variables Optional template variables to apply - * @returns The email formatted as an RFC822 compliant string - */ - public toRFC822String(variables?: Record): string { - // Apply variables to content if any - const processedSubject = this.getSubjectWithVariables(variables); - const processedText = this.getTextWithVariables(variables); - - // This is a simplified version - a complete implementation would be more complex - let result = ''; - - // Add headers - result += `From: ${this.from}\r\n`; - result += `To: ${this.to.join(', ')}\r\n`; - - if (this.cc.length > 0) { - result += `Cc: ${this.cc.join(', ')}\r\n`; - } - - result += `Subject: ${processedSubject}\r\n`; - result += `Date: ${new Date().toUTCString()}\r\n`; - result += `Message-ID: ${this.messageId}\r\n`; - result += `Return-Path: <${this.envelopeFrom}>\r\n`; - - // Add custom headers - for (const [key, value] of Object.entries(this.headers)) { - result += `${key}: ${value}\r\n`; - } - - // Add priority if not normal - if (this.priority !== 'normal') { - const priorityValue = this.priority === 'high' ? '1' : '5'; - result += `X-Priority: ${priorityValue}\r\n`; - } - - // Add content type and body - result += `Content-Type: text/plain; charset=utf-8\r\n`; - - // Add HTML content type if available - if (this.html) { - const processedHtml = this.getHtmlWithVariables(variables); - const boundary = `boundary_${Date.now().toString(16)}`; - - // Multipart content for both plain text and HTML - result = result.replace(/Content-Type: .*\r\n/, ''); - result += `MIME-Version: 1.0\r\n`; - result += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n`; - - // Plain text part - result += `--${boundary}\r\n`; - result += `Content-Type: text/plain; charset=utf-8\r\n\r\n`; - result += `${processedText}\r\n\r\n`; - - // HTML part - result += `--${boundary}\r\n`; - result += `Content-Type: text/html; charset=utf-8\r\n\r\n`; - result += `${processedHtml}\r\n\r\n`; - - // End of multipart - result += `--${boundary}--\r\n`; - } else { - // Simple plain text - result += `\r\n${processedText}\r\n`; - } - - return result; - } - - /** - * Convert to simple Smartmail-compatible object (for backward compatibility) - * @returns A Promise with a simple Smartmail-compatible object - */ - public async toSmartmailBasic(): Promise { - // Create a Smartmail-compatible object with the email data - const smartmail = { - options: { - from: this.from, - to: this.to, - subject: this.subject - }, - content: { - text: this.text, - html: this.html || '' - }, - headers: { ...this.headers }, - attachments: this.attachments ? this.attachments.map(attachment => ({ - name: attachment.filename, - data: attachment.content, - type: attachment.contentType, - cid: attachment.contentId - })) : [], - // Add basic Smartmail-compatible methods for compatibility - addHeader: (key: string, value: string) => { - smartmail.headers[key] = value; - } - }; - - return smartmail; - } - - /** - * Create an Email instance from a Smartmail object - * @param smartmail The Smartmail instance to convert - * @returns A new Email instance - */ - public static fromSmartmail(smartmail: plugins.smartmail.Smartmail): Email { - const options: IEmailOptions = { - from: smartmail.options.from, - to: [], - subject: smartmail.getSubject(), - text: smartmail.getBody(false), // Plain text version - html: smartmail.getBody(true), // HTML version - attachments: [] - }; - - // Function to safely extract email address from recipient - const extractEmail = (recipient: any): string => { - // Handle string recipients - if (typeof recipient === 'string') return recipient; - - // Handle object recipients - if (recipient && typeof recipient === 'object') { - const addressObj = recipient as any; - // Try different property names that might contain the email address - if ('address' in addressObj && typeof addressObj.address === 'string') { - return addressObj.address; - } - if ('email' in addressObj && typeof addressObj.email === 'string') { - return addressObj.email; - } - } - - // Fallback for invalid input - return ''; - }; - - // Filter out empty strings from the extracted emails - const filterValidEmails = (emails: string[]): string[] => { - return emails.filter(email => email && email.length > 0); - }; - - // Convert TO recipients - if (smartmail.options.to?.length > 0) { - options.to = filterValidEmails(smartmail.options.to.map(extractEmail)); - } - - // Convert CC recipients - if (smartmail.options.cc?.length > 0) { - options.cc = filterValidEmails(smartmail.options.cc.map(extractEmail)); - } - - // Convert BCC recipients - if (smartmail.options.bcc?.length > 0) { - options.bcc = filterValidEmails(smartmail.options.bcc.map(extractEmail)); - } - - // Convert attachments (note: this handles the synchronous case only) - if (smartmail.attachments?.length > 0) { - options.attachments = smartmail.attachments.map(attachment => { - // For the test case, if the path is exactly "test.txt", use that as the filename - let filename = 'attachment.bin'; - - if (attachment.path === 'test.txt') { - filename = 'test.txt'; - } else if (attachment.parsedPath?.base) { - filename = attachment.parsedPath.base; - } else if (typeof attachment.path === 'string') { - filename = attachment.path.split('/').pop() || 'attachment.bin'; - } - - return { - filename, - content: Buffer.from(attachment.contentBuffer || Buffer.alloc(0)), - contentType: (attachment as any)?.contentType || 'application/octet-stream' - }; - }); - } - - return new Email(options); - } -} \ No newline at end of file diff --git a/ts/mail/core/classes.emailvalidator.ts b/ts/mail/core/classes.emailvalidator.ts deleted file mode 100644 index 26309a5..0000000 --- a/ts/mail/core/classes.emailvalidator.ts +++ /dev/null @@ -1,239 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { logger } from '../../logger.js'; -import { LRUCache } from 'lru-cache'; - -export interface IEmailValidationResult { - isValid: boolean; - hasMx: boolean; - hasSpamMarkings: boolean; - score: number; - details?: { - formatValid?: boolean; - mxRecords?: string[]; - disposable?: boolean; - role?: boolean; - spamIndicators?: string[]; - errorMessage?: string; - }; -} - -/** - * Advanced email validator class using smartmail's capabilities - */ -export class EmailValidator { - private validator: plugins.smartmail.EmailAddressValidator; - private dnsCache: LRUCache; - - constructor(options?: { - maxCacheSize?: number; - cacheTTL?: number; - }) { - this.validator = new plugins.smartmail.EmailAddressValidator(); - - // Initialize LRU cache for DNS records - this.dnsCache = new LRUCache({ - // Default to 1000 entries (reasonable for most applications) - max: options?.maxCacheSize || 1000, - // Default TTL of 1 hour (DNS records don't change frequently) - ttl: options?.cacheTTL || 60 * 60 * 1000, - // Optional cache monitoring - allowStale: false, - updateAgeOnGet: true, - // Add logging for cache events in production environments - disposeAfter: (value, key) => { - logger.log('debug', `DNS cache entry expired for domain: ${key}`); - }, - }); - } - - /** - * Validates an email address using comprehensive checks - * @param email The email to validate - * @param options Validation options - * @returns Validation result with details - */ - public async validate( - email: string, - options: { - checkMx?: boolean; - checkDisposable?: boolean; - checkRole?: boolean; - checkSyntaxOnly?: boolean; - } = {} - ): Promise { - try { - const result: IEmailValidationResult = { - isValid: false, - hasMx: false, - hasSpamMarkings: false, - score: 0, - details: { - formatValid: false, - spamIndicators: [] - } - }; - - // Always check basic format - result.details.formatValid = this.validator.isValidEmailFormat(email); - if (!result.details.formatValid) { - result.details.errorMessage = 'Invalid email format'; - return result; - } - - // If syntax-only check is requested, return early - if (options.checkSyntaxOnly) { - result.isValid = true; - result.score = 0.5; - return result; - } - - // Get domain for additional checks - const domain = email.split('@')[1]; - - // Check MX records - if (options.checkMx !== false) { - try { - const mxRecords = await this.getMxRecords(domain); - result.details.mxRecords = mxRecords; - result.hasMx = mxRecords && mxRecords.length > 0; - - if (!result.hasMx) { - result.details.spamIndicators.push('No MX records'); - result.details.errorMessage = 'Domain has no MX records'; - } - } catch (error) { - logger.log('error', `Error checking MX records: ${error.message}`); - result.details.errorMessage = 'Unable to check MX records'; - } - } - - // Check if domain is disposable - if (options.checkDisposable !== false) { - result.details.disposable = await this.validator.isDisposableEmail(email); - if (result.details.disposable) { - result.details.spamIndicators.push('Disposable email'); - } - } - - // Check if email is a role account - if (options.checkRole !== false) { - result.details.role = this.validator.isRoleAccount(email); - if (result.details.role) { - result.details.spamIndicators.push('Role account'); - } - } - - // Calculate spam score and final validity - result.hasSpamMarkings = result.details.spamIndicators.length > 0; - - // Calculate a score between 0-1 based on checks - let scoreFactors = 0; - let scoreTotal = 0; - - // Format check (highest weight) - scoreFactors += 0.4; - if (result.details.formatValid) scoreTotal += 0.4; - - // MX check (high weight) - if (options.checkMx !== false) { - scoreFactors += 0.3; - if (result.hasMx) scoreTotal += 0.3; - } - - // Disposable check (medium weight) - if (options.checkDisposable !== false) { - scoreFactors += 0.2; - if (!result.details.disposable) scoreTotal += 0.2; - } - - // Role account check (low weight) - if (options.checkRole !== false) { - scoreFactors += 0.1; - if (!result.details.role) scoreTotal += 0.1; - } - - // Normalize score based on factors actually checked - result.score = scoreFactors > 0 ? scoreTotal / scoreFactors : 0; - - // Email is valid if score is above 0.7 (configurable threshold) - result.isValid = result.score >= 0.7; - - return result; - } catch (error) { - logger.log('error', `Email validation error: ${error.message}`); - return { - isValid: false, - hasMx: false, - hasSpamMarkings: true, - score: 0, - details: { - formatValid: false, - errorMessage: `Validation error: ${error.message}`, - spamIndicators: ['Validation error'] - } - }; - } - } - - /** - * Gets MX records for a domain with caching - * @param domain Domain to check - * @returns Array of MX records - */ - private async getMxRecords(domain: string): Promise { - // Check cache first - const cachedRecords = this.dnsCache.get(domain); - if (cachedRecords) { - logger.log('debug', `Using cached MX records for domain: ${domain}`); - return cachedRecords; - } - - try { - // Use smartmail's getMxRecords method - const records = await this.validator.getMxRecords(domain); - - // Store in cache (TTL is handled by the LRU cache configuration) - this.dnsCache.set(domain, records); - logger.log('debug', `Cached MX records for domain: ${domain}`); - - return records; - } catch (error) { - logger.log('error', `Error fetching MX records for ${domain}: ${error.message}`); - return []; - } - } - - /** - * Validates multiple email addresses in batch - * @param emails Array of emails to validate - * @param options Validation options - * @returns Object with email addresses as keys and validation results as values - */ - public async validateBatch( - emails: string[], - options: { - checkMx?: boolean; - checkDisposable?: boolean; - checkRole?: boolean; - checkSyntaxOnly?: boolean; - } = {} - ): Promise> { - const results: Record = {}; - - for (const email of emails) { - results[email] = await this.validate(email, options); - } - - return results; - } - - /** - * Quick check if an email format is valid (synchronous, no DNS checks) - * @param email Email to check - * @returns Boolean indicating if format is valid - */ - public isValidFormat(email: string): boolean { - return this.validator.isValidEmailFormat(email); - } - -} \ No newline at end of file diff --git a/ts/mail/core/classes.templatemanager.ts b/ts/mail/core/classes.templatemanager.ts deleted file mode 100644 index 01f5343..0000000 --- a/ts/mail/core/classes.templatemanager.ts +++ /dev/null @@ -1,320 +0,0 @@ -import * as plugins from '../../plugins.js'; -import * as paths from '../../paths.js'; -import { logger } from '../../logger.js'; -import { Email, type IEmailOptions, type IAttachment } from './classes.email.js'; - -/** - * Email template type definition - */ -export interface IEmailTemplate { - id: string; - name: string; - description: string; - from: string; - subject: string; - bodyHtml: string; - bodyText?: string; - category?: string; - sampleData?: T; - attachments?: Array<{ - name: string; - path: string; - contentType?: string; - }>; -} - -/** - * Email template context - data used to render the template - */ -export interface ITemplateContext { - [key: string]: any; -} - -/** - * Template category definitions - */ -export enum TemplateCategory { - NOTIFICATION = 'notification', - TRANSACTIONAL = 'transactional', - MARKETING = 'marketing', - SYSTEM = 'system' -} - -/** - * Enhanced template manager using Email class for template rendering - */ -export class TemplateManager { - private templates: Map = new Map(); - private defaultConfig: { - from: string; - replyTo?: string; - footerHtml?: string; - footerText?: string; - }; - - constructor(defaultConfig?: { - from?: string; - replyTo?: string; - footerHtml?: string; - footerText?: string; - }) { - // Set default configuration - this.defaultConfig = { - from: defaultConfig?.from || 'noreply@mail.lossless.com', - replyTo: defaultConfig?.replyTo, - footerHtml: defaultConfig?.footerHtml || '', - footerText: defaultConfig?.footerText || '' - }; - - // Initialize with built-in templates - this.registerBuiltinTemplates(); - } - - /** - * Register built-in email templates - */ - private registerBuiltinTemplates(): void { - // Welcome email - this.registerTemplate<{ - firstName: string; - accountUrl: string; - }>({ - id: 'welcome', - name: 'Welcome Email', - description: 'Sent to users when they first sign up', - from: this.defaultConfig.from, - subject: 'Welcome to {{serviceName}}!', - category: TemplateCategory.TRANSACTIONAL, - bodyHtml: ` -

Welcome, {{firstName}}!

-

Thank you for joining {{serviceName}}. We're excited to have you on board.

-

To get started, visit your account.

- `, - bodyText: - `Welcome, {{firstName}}! - - Thank you for joining {{serviceName}}. We're excited to have you on board. - - To get started, visit your account: {{accountUrl}} - `, - sampleData: { - firstName: 'John', - accountUrl: 'https://example.com/account' - } - }); - - // Password reset - this.registerTemplate<{ - resetUrl: string; - expiryHours: number; - }>({ - id: 'password-reset', - name: 'Password Reset', - description: 'Sent when a user requests a password reset', - from: this.defaultConfig.from, - subject: 'Password Reset Request', - category: TemplateCategory.TRANSACTIONAL, - bodyHtml: ` -

Password Reset Request

-

You recently requested to reset your password. Click the link below to reset it:

-

Reset Password

-

This link will expire in {{expiryHours}} hours.

-

If you didn't request a password reset, please ignore this email.

- `, - sampleData: { - resetUrl: 'https://example.com/reset-password?token=abc123', - expiryHours: 24 - } - }); - - // System notification - this.registerTemplate({ - id: 'system-notification', - name: 'System Notification', - description: 'General system notification template', - from: this.defaultConfig.from, - subject: '{{subject}}', - category: TemplateCategory.SYSTEM, - bodyHtml: ` -

{{title}}

-
{{message}}
- `, - sampleData: { - subject: 'Important System Notification', - title: 'System Maintenance', - message: 'The system will be undergoing maintenance on Saturday from 2-4am UTC.' - } - }); - } - - /** - * Register a new email template - * @param template The email template to register - */ - public registerTemplate(template: IEmailTemplate): void { - if (this.templates.has(template.id)) { - logger.log('warn', `Template with ID '${template.id}' already exists and will be overwritten`); - } - - // Add footer to templates if configured - if (this.defaultConfig.footerHtml && template.bodyHtml) { - template.bodyHtml += this.defaultConfig.footerHtml; - } - - if (this.defaultConfig.footerText && template.bodyText) { - template.bodyText += this.defaultConfig.footerText; - } - - this.templates.set(template.id, template); - logger.log('info', `Registered email template: ${template.id}`); - } - - /** - * Get an email template by ID - * @param templateId The template ID - * @returns The template or undefined if not found - */ - public getTemplate(templateId: string): IEmailTemplate | undefined { - return this.templates.get(templateId) as IEmailTemplate; - } - - /** - * List all available templates - * @param category Optional category filter - * @returns Array of email templates - */ - public listTemplates(category?: TemplateCategory): IEmailTemplate[] { - const templates = Array.from(this.templates.values()); - if (category) { - return templates.filter(template => template.category === category); - } - return templates; - } - - /** - * Create an Email instance from a template - * @param templateId The template ID - * @param context The template context data - * @returns A configured Email instance - */ - public async createEmail( - templateId: string, - context?: ITemplateContext - ): Promise { - const template = this.getTemplate(templateId); - - if (!template) { - throw new Error(`Template with ID '${templateId}' not found`); - } - - // Build attachments array for Email - const attachments: IAttachment[] = []; - - if (template.attachments && template.attachments.length > 0) { - for (const attachment of template.attachments) { - try { - const attachmentPath = plugins.path.isAbsolute(attachment.path) - ? attachment.path - : plugins.path.join(paths.MtaAttachmentsDir, attachment.path); - - // Read the file - const fileBuffer = await plugins.fs.promises.readFile(attachmentPath); - - attachments.push({ - filename: attachment.name, - content: fileBuffer, - contentType: attachment.contentType || 'application/octet-stream' - }); - } catch (error) { - logger.log('error', `Failed to add attachment '${attachment.name}': ${error.message}`); - } - } - } - - // Create Email instance with template content - const emailOptions: IEmailOptions = { - from: template.from || this.defaultConfig.from, - subject: template.subject, - text: template.bodyText || '', - html: template.bodyHtml, - // Note: 'to' is intentionally omitted for templates - attachments, - variables: context || {} - }; - - return new Email(emailOptions); - } - - /** - * Create and completely process an Email instance from a template - * @param templateId The template ID - * @param context The template context data - * @returns A complete, processed Email instance ready to send - */ - public async prepareEmail( - templateId: string, - context: ITemplateContext = {} - ): Promise { - const email = await this.createEmail(templateId, context); - - // Email class processes variables when needed, no pre-compilation required - - return email; - } - - /** - * Create a MIME-formatted email from a template - * @param templateId The template ID - * @param context The template context data - * @returns A MIME-formatted email string - */ - public async createMimeEmail( - templateId: string, - context: ITemplateContext = {} - ): Promise { - const email = await this.prepareEmail(templateId, context); - return email.toRFC822String(context); - } - - - /** - * Load templates from a directory - * @param directory The directory containing template JSON files - */ - public async loadTemplatesFromDirectory(directory: string): Promise { - try { - // Ensure directory exists - if (!plugins.fs.existsSync(directory)) { - logger.log('error', `Template directory does not exist: ${directory}`); - return; - } - - // Get all JSON files - const files = plugins.fs.readdirSync(directory) - .filter(file => file.endsWith('.json')); - - for (const file of files) { - try { - const filePath = plugins.path.join(directory, file); - const content = plugins.fs.readFileSync(filePath, 'utf8'); - const template = JSON.parse(content) as IEmailTemplate; - - // Validate template - if (!template.id || !template.subject || (!template.bodyHtml && !template.bodyText)) { - logger.log('warn', `Invalid template in ${file}: missing required fields`); - continue; - } - - this.registerTemplate(template); - } catch (error) { - logger.log('error', `Error loading template from ${file}: ${error.message}`); - } - } - - logger.log('info', `Loaded ${this.templates.size} email templates`); - } catch (error) { - logger.log('error', `Failed to load templates from directory: ${error.message}`); - throw error; - } - } -} \ No newline at end of file diff --git a/ts/mail/core/index.ts b/ts/mail/core/index.ts deleted file mode 100644 index b590ba2..0000000 --- a/ts/mail/core/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Core email components -export * from './classes.email.js'; -export * from './classes.emailvalidator.js'; -export * from './classes.templatemanager.js'; -export * from './classes.bouncemanager.js'; \ No newline at end of file diff --git a/ts/mail/delivery/classes.delivery.queue.ts b/ts/mail/delivery/classes.delivery.queue.ts deleted file mode 100644 index 609a21c..0000000 --- a/ts/mail/delivery/classes.delivery.queue.ts +++ /dev/null @@ -1,645 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { EventEmitter } from 'node:events'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { logger } from '../../logger.js'; -import { type EmailProcessingMode } from '../routing/classes.email.config.js'; -import type { IEmailRoute } from '../routing/interfaces.js'; - -/** - * Queue item status - */ -export type QueueItemStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred'; - -/** - * Queue item interface - */ -export interface IQueueItem { - id: string; - processingMode: EmailProcessingMode; - processingResult: any; - route: IEmailRoute; - status: QueueItemStatus; - attempts: number; - nextAttempt: Date; - lastError?: string; - createdAt: Date; - updatedAt: Date; - deliveredAt?: Date; -} - -/** - * Queue options interface - */ -export interface IQueueOptions { - // Storage options - storageType?: 'memory' | 'disk'; - persistentPath?: string; - - // Queue behavior - checkInterval?: number; - maxQueueSize?: number; - maxPerDestination?: number; - - // Delivery attempts - maxRetries?: number; - baseRetryDelay?: number; - maxRetryDelay?: number; -} - -/** - * Queue statistics interface - */ -export interface IQueueStats { - queueSize: number; - status: { - pending: number; - processing: number; - delivered: number; - failed: number; - deferred: number; - }; - modes: { - forward: number; - mta: number; - process: number; - }; - oldestItem?: Date; - newestItem?: Date; - averageAttempts: number; - totalProcessed: number; - processingActive: boolean; -} - -/** - * A unified queue for all email modes - */ -export class UnifiedDeliveryQueue extends EventEmitter { - private options: Required; - private queue: Map = new Map(); - private checkTimer?: NodeJS.Timeout; - private stats: IQueueStats; - private processing: boolean = false; - private totalProcessed: number = 0; - - /** - * Create a new unified delivery queue - * @param options Queue options - */ - constructor(options: IQueueOptions) { - super(); - - // Set default options - this.options = { - storageType: options.storageType || 'memory', - persistentPath: options.persistentPath || path.join(process.cwd(), 'email-queue'), - checkInterval: options.checkInterval || 30000, // 30 seconds - maxQueueSize: options.maxQueueSize || 10000, - maxPerDestination: options.maxPerDestination || 100, - maxRetries: options.maxRetries || 5, - baseRetryDelay: options.baseRetryDelay || 60000, // 1 minute - maxRetryDelay: options.maxRetryDelay || 3600000 // 1 hour - }; - - // Initialize statistics - this.stats = { - queueSize: 0, - status: { - pending: 0, - processing: 0, - delivered: 0, - failed: 0, - deferred: 0 - }, - modes: { - forward: 0, - mta: 0, - process: 0 - }, - averageAttempts: 0, - totalProcessed: 0, - processingActive: false - }; - } - - /** - * Initialize the queue - */ - public async initialize(): Promise { - logger.log('info', 'Initializing UnifiedDeliveryQueue'); - - try { - // Create persistent storage directory if using disk storage - if (this.options.storageType === 'disk') { - if (!fs.existsSync(this.options.persistentPath)) { - fs.mkdirSync(this.options.persistentPath, { recursive: true }); - } - - // Load existing items from disk - await this.loadFromDisk(); - } - - // Start the queue processing timer - this.startProcessing(); - - // Emit initialized event - this.emit('initialized'); - logger.log('info', 'UnifiedDeliveryQueue initialized successfully'); - } catch (error) { - logger.log('error', `Failed to initialize queue: ${error.message}`); - throw error; - } - } - - /** - * Start queue processing - */ - private startProcessing(): void { - if (this.checkTimer) { - clearInterval(this.checkTimer); - } - - this.checkTimer = setInterval(() => this.processQueue(), this.options.checkInterval); - this.processing = true; - this.stats.processingActive = true; - this.emit('processingStarted'); - logger.log('info', 'Queue processing started'); - } - - /** - * Stop queue processing - */ - private stopProcessing(): void { - if (this.checkTimer) { - clearInterval(this.checkTimer); - this.checkTimer = undefined; - } - - this.processing = false; - this.stats.processingActive = false; - this.emit('processingStopped'); - logger.log('info', 'Queue processing stopped'); - } - - /** - * Check for items that need to be processed - */ - private async processQueue(): Promise { - try { - const now = new Date(); - let readyItems: IQueueItem[] = []; - - // Find items ready for processing - for (const item of this.queue.values()) { - if (item.status === 'pending' || (item.status === 'deferred' && item.nextAttempt <= now)) { - readyItems.push(item); - } - } - - if (readyItems.length === 0) { - return; - } - - // Sort by oldest first - readyItems.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); - - // Emit event for ready items - this.emit('itemsReady', readyItems); - logger.log('info', `Found ${readyItems.length} items ready for processing`); - - // Update statistics - this.updateStats(); - } catch (error) { - logger.log('error', `Error processing queue: ${error.message}`); - this.emit('error', error); - } - } - - /** - * Add an item to the queue - * @param processingResult Processing result to queue - * @param mode Processing mode - * @param route Email route - */ - public async enqueue(processingResult: any, mode: EmailProcessingMode, route: IEmailRoute): Promise { - // Check if queue is full - if (this.queue.size >= this.options.maxQueueSize) { - throw new Error('Queue is full'); - } - - // Generate a unique ID - const id = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; - - // Create queue item - const item: IQueueItem = { - id, - processingMode: mode, - processingResult, - route, - status: 'pending', - attempts: 0, - nextAttempt: new Date(), - createdAt: new Date(), - updatedAt: new Date() - }; - - // Add to queue - this.queue.set(id, item); - - // Persist to disk if using disk storage - if (this.options.storageType === 'disk') { - await this.persistItem(item); - } - - // Update statistics - this.updateStats(); - - // Emit event - this.emit('itemEnqueued', item); - logger.log('info', `Item enqueued with ID ${id}, mode: ${mode}`); - - return id; - } - - /** - * Get an item from the queue - * @param id Item ID - */ - public getItem(id: string): IQueueItem | undefined { - return this.queue.get(id); - } - - /** - * Mark an item as being processed - * @param id Item ID - */ - public async markProcessing(id: string): Promise { - const item = this.queue.get(id); - - if (!item) { - return false; - } - - // Update status - item.status = 'processing'; - item.attempts++; - item.updatedAt = new Date(); - - // Persist changes if using disk storage - if (this.options.storageType === 'disk') { - await this.persistItem(item); - } - - // Update statistics - this.updateStats(); - - // Emit event - this.emit('itemProcessing', item); - logger.log('info', `Item ${id} marked as processing, attempt ${item.attempts}`); - - return true; - } - - /** - * Mark an item as delivered - * @param id Item ID - */ - public async markDelivered(id: string): Promise { - const item = this.queue.get(id); - - if (!item) { - return false; - } - - // Update status - item.status = 'delivered'; - item.updatedAt = new Date(); - item.deliveredAt = new Date(); - - // Persist changes if using disk storage - if (this.options.storageType === 'disk') { - await this.persistItem(item); - } - - // Update statistics - this.totalProcessed++; - this.updateStats(); - - // Emit event - this.emit('itemDelivered', item); - logger.log('info', `Item ${id} marked as delivered after ${item.attempts} attempts`); - - return true; - } - - /** - * Mark an item as failed - * @param id Item ID - * @param error Error message - */ - public async markFailed(id: string, error: string): Promise { - const item = this.queue.get(id); - - if (!item) { - return false; - } - - // Determine if we should retry - if (item.attempts < this.options.maxRetries) { - // Calculate next retry time with exponential backoff - const delay = Math.min( - this.options.baseRetryDelay * Math.pow(2, item.attempts - 1), - this.options.maxRetryDelay - ); - - // Update status - item.status = 'deferred'; - item.lastError = error; - item.nextAttempt = new Date(Date.now() + delay); - item.updatedAt = new Date(); - - // Persist changes if using disk storage - if (this.options.storageType === 'disk') { - await this.persistItem(item); - } - - // Emit event - this.emit('itemDeferred', item); - logger.log('info', `Item ${id} deferred for ${delay}ms, attempt ${item.attempts}, error: ${error}`); - } else { - // Mark as permanently failed - item.status = 'failed'; - item.lastError = error; - item.updatedAt = new Date(); - - // Persist changes if using disk storage - if (this.options.storageType === 'disk') { - await this.persistItem(item); - } - - // Update statistics - this.totalProcessed++; - - // Emit event - this.emit('itemFailed', item); - logger.log('warn', `Item ${id} permanently failed after ${item.attempts} attempts, error: ${error}`); - } - - // Update statistics - this.updateStats(); - - return true; - } - - /** - * Remove an item from the queue - * @param id Item ID - */ - public async removeItem(id: string): Promise { - const item = this.queue.get(id); - - if (!item) { - return false; - } - - // Remove from queue - this.queue.delete(id); - - // Remove from disk if using disk storage - if (this.options.storageType === 'disk') { - await this.removeItemFromDisk(id); - } - - // Update statistics - this.updateStats(); - - // Emit event - this.emit('itemRemoved', item); - logger.log('info', `Item ${id} removed from queue`); - - return true; - } - - /** - * Persist an item to disk - * @param item Item to persist - */ - private async persistItem(item: IQueueItem): Promise { - try { - const filePath = path.join(this.options.persistentPath, `${item.id}.json`); - await fs.promises.writeFile(filePath, JSON.stringify(item, null, 2), 'utf8'); - } catch (error) { - logger.log('error', `Failed to persist item ${item.id}: ${error.message}`); - this.emit('error', error); - } - } - - /** - * Remove an item from disk - * @param id Item ID - */ - private async removeItemFromDisk(id: string): Promise { - try { - const filePath = path.join(this.options.persistentPath, `${id}.json`); - - if (fs.existsSync(filePath)) { - await fs.promises.unlink(filePath); - } - } catch (error) { - logger.log('error', `Failed to remove item ${id} from disk: ${error.message}`); - this.emit('error', error); - } - } - - /** - * Load queue items from disk - */ - private async loadFromDisk(): Promise { - try { - // Check if directory exists - if (!fs.existsSync(this.options.persistentPath)) { - return; - } - - // Get all JSON files - const files = fs.readdirSync(this.options.persistentPath).filter(file => file.endsWith('.json')); - - // Load each file - for (const file of files) { - try { - const filePath = path.join(this.options.persistentPath, file); - const data = await fs.promises.readFile(filePath, 'utf8'); - const item = JSON.parse(data) as IQueueItem; - - // Convert date strings to Date objects - item.createdAt = new Date(item.createdAt); - item.updatedAt = new Date(item.updatedAt); - item.nextAttempt = new Date(item.nextAttempt); - if (item.deliveredAt) { - item.deliveredAt = new Date(item.deliveredAt); - } - - // Add to queue - this.queue.set(item.id, item); - } catch (error) { - logger.log('error', `Failed to load item from ${file}: ${error.message}`); - } - } - - // Update statistics - this.updateStats(); - - logger.log('info', `Loaded ${this.queue.size} items from disk`); - } catch (error) { - logger.log('error', `Failed to load items from disk: ${error.message}`); - throw error; - } - } - - /** - * Update queue statistics - */ - private updateStats(): void { - // Reset counters - this.stats.queueSize = this.queue.size; - this.stats.status = { - pending: 0, - processing: 0, - delivered: 0, - failed: 0, - deferred: 0 - }; - this.stats.modes = { - forward: 0, - mta: 0, - process: 0 - }; - - let totalAttempts = 0; - let oldestTime = Date.now(); - let newestTime = 0; - - // Count by status and mode - for (const item of this.queue.values()) { - // Count by status - this.stats.status[item.status]++; - - // Count by mode - this.stats.modes[item.processingMode]++; - - // Track total attempts - totalAttempts += item.attempts; - - // Track oldest and newest - const itemTime = item.createdAt.getTime(); - if (itemTime < oldestTime) { - oldestTime = itemTime; - } - if (itemTime > newestTime) { - newestTime = itemTime; - } - } - - // Calculate average attempts - this.stats.averageAttempts = this.queue.size > 0 ? totalAttempts / this.queue.size : 0; - - // Set oldest and newest - this.stats.oldestItem = this.queue.size > 0 ? new Date(oldestTime) : undefined; - this.stats.newestItem = this.queue.size > 0 ? new Date(newestTime) : undefined; - - // Set total processed - this.stats.totalProcessed = this.totalProcessed; - - // Set processing active - this.stats.processingActive = this.processing; - - // Emit statistics event - this.emit('statsUpdated', this.stats); - } - - /** - * Get queue statistics - */ - public getStats(): IQueueStats { - return { ...this.stats }; - } - - /** - * Pause queue processing - */ - public pause(): void { - if (this.processing) { - this.stopProcessing(); - logger.log('info', 'Queue processing paused'); - } - } - - /** - * Resume queue processing - */ - public resume(): void { - if (!this.processing) { - this.startProcessing(); - logger.log('info', 'Queue processing resumed'); - } - } - - /** - * Clean up old delivered and failed items - * @param maxAge Maximum age in milliseconds (default: 7 days) - */ - public async cleanupOldItems(maxAge: number = 7 * 24 * 60 * 60 * 1000): Promise { - const cutoff = new Date(Date.now() - maxAge); - let removedCount = 0; - - // Find old items - for (const item of this.queue.values()) { - if (['delivered', 'failed'].includes(item.status) && item.updatedAt < cutoff) { - // Remove item - await this.removeItem(item.id); - removedCount++; - } - } - - logger.log('info', `Cleaned up ${removedCount} old items`); - return removedCount; - } - - /** - * Shutdown the queue - */ - public async shutdown(): Promise { - logger.log('info', 'Shutting down UnifiedDeliveryQueue'); - - // Stop processing - this.stopProcessing(); - - // Clear the check timer to prevent memory leaks - if (this.checkTimer) { - clearInterval(this.checkTimer); - this.checkTimer = undefined; - } - - // If using disk storage, make sure all items are persisted - if (this.options.storageType === 'disk') { - const pendingWrites: Promise[] = []; - - for (const item of this.queue.values()) { - pendingWrites.push(this.persistItem(item)); - } - - // Wait for all writes to complete - await Promise.all(pendingWrites); - } - - // Clear the queue (memory only) - this.queue.clear(); - - // Update statistics - this.updateStats(); - - // Emit shutdown event - this.emit('shutdown'); - logger.log('info', 'UnifiedDeliveryQueue shut down successfully'); - } -} \ No newline at end of file diff --git a/ts/mail/delivery/classes.delivery.system.ts b/ts/mail/delivery/classes.delivery.system.ts deleted file mode 100644 index 0624751..0000000 --- a/ts/mail/delivery/classes.delivery.system.ts +++ /dev/null @@ -1,1085 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { EventEmitter } from 'node:events'; -import * as net from 'node:net'; -import * as tls from 'node:tls'; -import { logger } from '../../logger.js'; -import { - SecurityLogger, - SecurityLogLevel, - SecurityEventType -} from '../../security/index.js'; -import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js'; -import type { Email } from '../core/classes.email.js'; -import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js'; -import type { SmtpClient } from './smtpclient/smtp-client.js'; - -/** - * Delivery status enumeration - */ -export enum DeliveryStatus { - PENDING = 'pending', - DELIVERING = 'delivering', - DELIVERED = 'delivered', - DEFERRED = 'deferred', - FAILED = 'failed' -} - -/** - * Delivery handler interface - */ -export interface IDeliveryHandler { - deliver(item: IQueueItem): Promise; -} - -/** - * Delivery options - */ -export interface IMultiModeDeliveryOptions { - // Connection options - connectionPoolSize?: number; - socketTimeout?: number; - - // Delivery behavior - concurrentDeliveries?: number; - sendTimeout?: number; - - // TLS options - verifyCertificates?: boolean; - tlsMinVersion?: string; - - // Mode-specific handlers - forwardHandler?: IDeliveryHandler; - deliveryHandler?: IDeliveryHandler; - processHandler?: IDeliveryHandler; - - // Rate limiting - globalRateLimit?: number; - perPatternRateLimit?: Record; - - // Bounce handling - processBounces?: boolean; - bounceHandler?: { - processSmtpFailure: (recipient: string, smtpResponse: string, options: any) => Promise; - }; - - // Event hooks - onDeliveryStart?: (item: IQueueItem) => Promise; - onDeliverySuccess?: (item: IQueueItem, result: any) => Promise; - onDeliveryFailed?: (item: IQueueItem, error: string) => Promise; -} - -/** - * Delivery system statistics - */ -export interface IDeliveryStats { - activeDeliveries: number; - totalSuccessful: number; - totalFailed: number; - avgDeliveryTime: number; - byMode: { - forward: { - successful: number; - failed: number; - }; - mta: { - successful: number; - failed: number; - }; - process: { - successful: number; - failed: number; - }; - }; - rateLimiting: { - currentRate: number; - globalLimit: number; - throttled: number; - }; -} - -/** - * Handles delivery for all email processing modes - */ -export class MultiModeDeliverySystem extends EventEmitter { - private queue: UnifiedDeliveryQueue; - private options: Required; - private stats: IDeliveryStats; - private deliveryTimes: number[] = []; - private activeDeliveries: Set = new Set(); - private running: boolean = false; - private throttled: boolean = false; - private rateLimitLastCheck: number = Date.now(); - private rateLimitCounter: number = 0; - private emailServer?: UnifiedEmailServer; - - /** - * Create a new multi-mode delivery system - * @param queue Unified delivery queue - * @param options Delivery options - * @param emailServer Optional reference to unified email server for SmtpClient access - */ - constructor(queue: UnifiedDeliveryQueue, options: IMultiModeDeliveryOptions, emailServer?: UnifiedEmailServer) { - super(); - - this.queue = queue; - this.emailServer = emailServer; - - // Set default options - this.options = { - connectionPoolSize: options.connectionPoolSize || 10, - socketTimeout: options.socketTimeout || 30000, // 30 seconds - concurrentDeliveries: options.concurrentDeliveries || 10, - sendTimeout: options.sendTimeout || 60000, // 1 minute - verifyCertificates: options.verifyCertificates !== false, // Default to true - tlsMinVersion: options.tlsMinVersion || 'TLSv1.2', - forwardHandler: options.forwardHandler || { - deliver: this.handleForwardDelivery.bind(this) - }, - deliveryHandler: options.deliveryHandler || { - deliver: this.handleMtaDelivery.bind(this) - }, - processHandler: options.processHandler || { - deliver: this.handleProcessDelivery.bind(this) - }, - globalRateLimit: options.globalRateLimit || 100, // 100 emails per minute - perPatternRateLimit: options.perPatternRateLimit || {}, - processBounces: options.processBounces !== false, // Default to true - bounceHandler: options.bounceHandler || null, - onDeliveryStart: options.onDeliveryStart || (async () => {}), - onDeliverySuccess: options.onDeliverySuccess || (async () => {}), - onDeliveryFailed: options.onDeliveryFailed || (async () => {}) - }; - - // Initialize statistics - this.stats = { - activeDeliveries: 0, - totalSuccessful: 0, - totalFailed: 0, - avgDeliveryTime: 0, - byMode: { - forward: { - successful: 0, - failed: 0 - }, - mta: { - successful: 0, - failed: 0 - }, - process: { - successful: 0, - failed: 0 - } - }, - rateLimiting: { - currentRate: 0, - globalLimit: this.options.globalRateLimit, - throttled: 0 - } - }; - - // Set up event listeners - this.queue.on('itemsReady', this.processItems.bind(this)); - } - - /** - * Start the delivery system - */ - public async start(): Promise { - logger.log('info', 'Starting MultiModeDeliverySystem'); - - if (this.running) { - logger.log('warn', 'MultiModeDeliverySystem is already running'); - return; - } - - this.running = true; - - // Emit started event - this.emit('started'); - logger.log('info', 'MultiModeDeliverySystem started successfully'); - } - - /** - * Stop the delivery system - */ - public async stop(): Promise { - logger.log('info', 'Stopping MultiModeDeliverySystem'); - - if (!this.running) { - logger.log('warn', 'MultiModeDeliverySystem is already stopped'); - return; - } - - this.running = false; - - // Wait for active deliveries to complete - if (this.activeDeliveries.size > 0) { - logger.log('info', `Waiting for ${this.activeDeliveries.size} active deliveries to complete`); - - // Wait for a maximum of 30 seconds - await new Promise(resolve => { - const checkInterval = setInterval(() => { - if (this.activeDeliveries.size === 0) { - clearInterval(checkInterval); - clearTimeout(forceTimeout); - resolve(); - } - }, 1000); - - // Force resolve after 30 seconds - const forceTimeout = setTimeout(() => { - clearInterval(checkInterval); - resolve(); - }, 30000); - }); - } - - // Emit stopped event - this.emit('stopped'); - logger.log('info', 'MultiModeDeliverySystem stopped successfully'); - } - - /** - * Process ready items from the queue - * @param items Queue items ready for processing - */ - private async processItems(items: IQueueItem[]): Promise { - if (!this.running) { - return; - } - - // Check if we're already at max concurrent deliveries - if (this.activeDeliveries.size >= this.options.concurrentDeliveries) { - logger.log('debug', `Already at max concurrent deliveries (${this.activeDeliveries.size})`); - return; - } - - // Check rate limiting - if (this.checkRateLimit()) { - logger.log('debug', 'Rate limit exceeded, throttling deliveries'); - return; - } - - // Calculate how many more deliveries we can start - const availableSlots = this.options.concurrentDeliveries - this.activeDeliveries.size; - const itemsToProcess = items.slice(0, availableSlots); - - if (itemsToProcess.length === 0) { - return; - } - - logger.log('info', `Processing ${itemsToProcess.length} items for delivery`); - - // Process each item - for (const item of itemsToProcess) { - // Mark as processing - await this.queue.markProcessing(item.id); - - // Add to active deliveries - this.activeDeliveries.add(item.id); - this.stats.activeDeliveries = this.activeDeliveries.size; - - // Deliver asynchronously - this.deliverItem(item).catch(err => { - logger.log('error', `Unhandled error in delivery: ${err.message}`); - }); - } - - // Update statistics - this.emit('statsUpdated', this.stats); - } - - /** - * Deliver an item from the queue - * @param item Queue item to deliver - */ - private async deliverItem(item: IQueueItem): Promise { - const startTime = Date.now(); - - try { - // Call delivery start hook - await this.options.onDeliveryStart(item); - - // Emit delivery start event - this.emit('deliveryStart', item); - logger.log('info', `Starting delivery of item ${item.id}, mode: ${item.processingMode}`); - - // Choose the appropriate handler based on mode - let result: any; - - switch (item.processingMode) { - case 'forward': - result = await this.options.forwardHandler.deliver(item); - break; - - case 'mta': - result = await this.options.deliveryHandler.deliver(item); - break; - - case 'process': - result = await this.options.processHandler.deliver(item); - break; - - default: - throw new Error(`Unknown processing mode: ${item.processingMode}`); - } - - // Mark as delivered - await this.queue.markDelivered(item.id); - - // Update statistics - this.stats.totalSuccessful++; - this.stats.byMode[item.processingMode].successful++; - - // Calculate delivery time - const deliveryTime = Date.now() - startTime; - this.deliveryTimes.push(deliveryTime); - this.updateDeliveryTimeStats(); - - // Call delivery success hook - await this.options.onDeliverySuccess(item, result); - - // Emit delivery success event - this.emit('deliverySuccess', item, result); - logger.log('info', `Item ${item.id} delivered successfully in ${deliveryTime}ms`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.INFO, - type: SecurityEventType.EMAIL_DELIVERY, - message: 'Email delivery successful', - details: { - itemId: item.id, - mode: item.processingMode, - routeName: item.route?.name || 'unknown', - deliveryTime - }, - success: true - }); - } catch (error: any) { - // Calculate delivery attempt time even for failures - const deliveryTime = Date.now() - startTime; - - // Mark as failed - await this.queue.markFailed(item.id, error.message); - - // Update statistics - this.stats.totalFailed++; - this.stats.byMode[item.processingMode].failed++; - - // Call delivery failed hook - await this.options.onDeliveryFailed(item, error.message); - - // Process as bounce if enabled and we have a bounce handler - if (this.options.processBounces && this.options.bounceHandler) { - try { - const email = item.processingResult as Email; - - // Extract recipient and error message - // For multiple recipients, we'd need more sophisticated parsing - const recipient = email.to.length > 0 ? email.to[0] : ''; - - if (recipient) { - logger.log('info', `Processing delivery failure as bounce for recipient ${recipient}`); - - // Process SMTP failure through bounce handler - await this.options.bounceHandler.processSmtpFailure( - recipient, - error.message, - { - sender: email.from, - originalEmailId: item.id, - headers: email.headers - } - ); - - logger.log('info', `Bounce record created for failed delivery to ${recipient}`); - } - } catch (bounceError) { - logger.log('error', `Failed to process bounce: ${bounceError.message}`); - } - } - - // Emit delivery failed event - this.emit('deliveryFailed', item, error); - logger.log('error', `Item ${item.id} delivery failed: ${error.message}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.EMAIL_DELIVERY, - message: 'Email delivery failed', - details: { - itemId: item.id, - mode: item.processingMode, - routeName: item.route?.name || 'unknown', - error: error.message, - deliveryTime - }, - success: false - }); - } finally { - // Remove from active deliveries - this.activeDeliveries.delete(item.id); - this.stats.activeDeliveries = this.activeDeliveries.size; - - // Update statistics - this.emit('statsUpdated', this.stats); - } - } - - /** - * Default handler for forward mode delivery - * @param item Queue item - */ - private async handleForwardDelivery(item: IQueueItem): Promise { - logger.log('info', `Forward delivery for item ${item.id}`); - - const email = item.processingResult as Email; - const route = item.route; - - // Get target server information - const targetServer = route?.action.forward?.host; - const targetPort = route?.action.forward?.port || 25; - const useTls = false; // TLS configuration can be enhanced later - - if (!targetServer) { - throw new Error('No target server configured for forward mode'); - } - - logger.log('info', `Forwarding email to ${targetServer}:${targetPort}, TLS: ${useTls}`); - - try { - // Get SMTP client from email server if available - if (!this.emailServer) { - // Fall back to raw socket implementation if no email server - logger.log('warn', 'No email server available, falling back to raw socket implementation'); - return this.handleForwardDeliveryLegacy(item); - } - - // Get SMTP client from UnifiedEmailServer - const smtpClient = this.emailServer.getSmtpClient(targetServer, targetPort); - - // Apply DKIM signing if configured in the route - if (item.route?.action.options?.mtaOptions?.dkimSign) { - await this.applyDkimSigning(email, item.route.action.options.mtaOptions); - } - - // Send the email using SmtpClient - const result = await smtpClient.sendMail(email); - - if (result.success) { - logger.log('info', `Email forwarded successfully to ${targetServer}:${targetPort}`); - - return { - targetServer: targetServer, - targetPort: targetPort, - recipients: result.acceptedRecipients.length, - messageId: result.messageId, - rejectedRecipients: result.rejectedRecipients - }; - } else { - throw new Error(result.error?.message || 'Failed to forward email'); - } - } catch (error: any) { - logger.log('error', `Failed to forward email: ${error.message}`); - throw error; - } - } - - /** - * Legacy forward delivery using raw sockets (fallback) - * @param item Queue item - */ - private async handleForwardDeliveryLegacy(item: IQueueItem): Promise { - const email = item.processingResult as Email; - const route = item.route; - - // Get target server information - const targetServer = route?.action.forward?.host; - const targetPort = route?.action.forward?.port || 25; - const useTls = false; // TLS configuration can be enhanced later - - if (!targetServer) { - throw new Error('No target server configured for forward mode'); - } - - // Create a socket connection to the target server - const socket = new net.Socket(); - - // Set timeout - socket.setTimeout(this.options.socketTimeout); - - try { - // Connect to the target server - await new Promise((resolve, reject) => { - // Handle connection events - socket.on('connect', () => { - logger.log('debug', `Connected to ${targetServer}:${targetPort}`); - resolve(); - }); - - socket.on('timeout', () => { - reject(new Error(`Connection timeout to ${targetServer}:${targetPort}`)); - }); - - socket.on('error', (err) => { - reject(new Error(`Connection error to ${targetServer}:${targetPort}: ${err.message}`)); - }); - - // Connect to the server - socket.connect({ - host: targetServer, - port: targetPort - }); - }); - - // Send EHLO - await this.smtpCommand(socket, `EHLO ${route?.action.options?.mtaOptions?.domain || 'localhost'}`); - - // Start TLS if required - if (useTls) { - await this.smtpCommand(socket, 'STARTTLS'); - - // Upgrade to TLS - const tlsSocket = await this.upgradeTls(socket, targetServer); - - // Send EHLO again after STARTTLS - await this.smtpCommand(tlsSocket, `EHLO ${route?.action.options?.mtaOptions?.domain || 'localhost'}`); - - // Use tlsSocket for remaining commands - return this.completeSMTPExchange(tlsSocket, email, route); - } - - // Complete the SMTP exchange - return this.completeSMTPExchange(socket, email, route); - } catch (error: any) { - logger.log('error', `Failed to forward email: ${error.message}`); - - // Close the connection - socket.destroy(); - - throw error; - } - } - - /** - * Complete the SMTP exchange after connection and initial setup - * @param socket Network socket - * @param email Email to send - * @param rule Domain rule - */ - private async completeSMTPExchange(socket: net.Socket | tls.TLSSocket, email: Email, route: any): Promise { - try { - // Authenticate if credentials provided - if (route?.action?.forward?.auth?.user && route?.action?.forward?.auth?.pass) { - // Send AUTH LOGIN - await this.smtpCommand(socket, 'AUTH LOGIN'); - - // Send username (base64) - const username = Buffer.from(route.action.forward.auth.user).toString('base64'); - await this.smtpCommand(socket, username); - - // Send password (base64) - const password = Buffer.from(route.action.forward.auth.pass).toString('base64'); - await this.smtpCommand(socket, password); - } - - // Send MAIL FROM - await this.smtpCommand(socket, `MAIL FROM:<${email.from}>`); - - // Send RCPT TO for each recipient - for (const recipient of email.getAllRecipients()) { - await this.smtpCommand(socket, `RCPT TO:<${recipient}>`); - } - - // Send DATA - await this.smtpCommand(socket, 'DATA'); - - // Send email content (simplified) - const emailContent = await this.getFormattedEmail(email); - await this.smtpData(socket, emailContent); - - // Send QUIT - await this.smtpCommand(socket, 'QUIT'); - - // Close the connection - socket.end(); - - logger.log('info', `Email forwarded successfully to ${route?.action?.forward?.host}:${route?.action?.forward?.port || 25}`); - - return { - targetServer: route?.action?.forward?.host, - targetPort: route?.action?.forward?.port || 25, - recipients: email.getAllRecipients().length - }; - } catch (error: any) { - logger.log('error', `Failed to forward email: ${error.message}`); - - // Close the connection - socket.destroy(); - - throw error; - } - } - - /** - * Default handler for MTA mode delivery - * @param item Queue item - */ - private async handleMtaDelivery(item: IQueueItem): Promise { - logger.log('info', `MTA delivery for item ${item.id}`); - - const email = item.processingResult as Email; - const route = item.route; - - try { - // Apply DKIM signing if configured in the route - if (item.route?.action.options?.mtaOptions?.dkimSign) { - await this.applyDkimSigning(email, item.route.action.options.mtaOptions); - } - - // In a full implementation, this would use the MTA service - // For now, we'll simulate a successful delivery - - logger.log('info', `Email processed by MTA: ${email.subject} to ${email.getAllRecipients().join(', ')}`); - - // Note: The MTA implementation would handle actual local delivery - - // Simulate successful delivery - return { - recipients: email.getAllRecipients().length, - subject: email.subject, - dkimSigned: !!item.route?.action.options?.mtaOptions?.dkimSign - }; - } catch (error: any) { - logger.log('error', `Failed to process email in MTA mode: ${error.message}`); - throw error; - } - } - - /** - * Default handler for process mode delivery - * @param item Queue item - */ - private async handleProcessDelivery(item: IQueueItem): Promise { - logger.log('info', `Process delivery for item ${item.id}`); - - const email = item.processingResult as Email; - const route = item.route; - - try { - // Apply content scanning if enabled - if (route?.action.options?.contentScanning && route?.action.options?.scanners && route.action.options.scanners.length > 0) { - logger.log('info', 'Performing content scanning'); - - // Apply each scanner - for (const scanner of route.action.options.scanners) { - switch (scanner.type) { - case 'spam': - logger.log('info', 'Scanning for spam content'); - // Implement spam scanning - break; - - case 'virus': - logger.log('info', 'Scanning for virus content'); - // Implement virus scanning - break; - - case 'attachment': - logger.log('info', 'Scanning attachments'); - - // Check for blocked extensions - if (scanner.blockedExtensions && scanner.blockedExtensions.length > 0) { - for (const attachment of email.attachments) { - const ext = this.getFileExtension(attachment.filename); - if (scanner.blockedExtensions.includes(ext)) { - if (scanner.action === 'reject') { - throw new Error(`Blocked attachment type: ${ext}`); - } else { // tag - email.addHeader('X-Attachment-Warning', `Potentially unsafe attachment: ${attachment.filename}`); - } - } - } - } - break; - } - } - } - - // Apply transformations if defined - if (route?.action.options?.transformations && route?.action.options?.transformations.length > 0) { - logger.log('info', 'Applying email transformations'); - - for (const transform of route.action.options.transformations) { - switch (transform.type) { - case 'addHeader': - if (transform.header && transform.value) { - email.addHeader(transform.header, transform.value); - } - break; - } - } - } - - // Apply DKIM signing if configured (after all transformations) - if (item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim) { - await this.applyDkimSigning(email, item.route.action.options?.mtaOptions || {}); - } - - logger.log('info', `Email successfully processed in store-and-forward mode`); - - // Simulate successful delivery - return { - recipients: email.getAllRecipients().length, - subject: email.subject, - scanned: !!route?.action.options?.contentScanning, - transformed: !!(route?.action.options?.transformations && route?.action.options?.transformations.length > 0), - dkimSigned: !!(item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim) - }; - } catch (error: any) { - logger.log('error', `Failed to process email: ${error.message}`); - throw error; - } - } - - /** - * Get file extension from filename - */ - private getFileExtension(filename: string): string { - return filename.substring(filename.lastIndexOf('.')).toLowerCase(); - } - - /** - * Apply DKIM signing to an email - */ - private async applyDkimSigning(email: Email, mtaOptions: any): Promise { - if (!this.emailServer) { - logger.log('warn', 'Cannot apply DKIM signing without email server reference'); - return; - } - - const domainName = mtaOptions.dkimOptions?.domainName || email.from.split('@')[1]; - const keySelector = mtaOptions.dkimOptions?.keySelector || 'default'; - - try { - // Ensure DKIM keys exist for the domain - await this.emailServer.dkimCreator.handleDKIMKeysForDomain(domainName); - - // Convert Email to raw format for signing - const rawEmail = email.toRFC822String(); - - // Sign the email - const dkimKeys = await this.emailServer.dkimCreator.readDKIMKeys(domainName); - const signResult = await plugins.dkimSign(rawEmail, { - signingDomain: domainName, - selector: keySelector, - privateKey: dkimKeys.privateKey, - canonicalization: 'relaxed/relaxed', - algorithm: 'rsa-sha256', - signTime: new Date(), - }); - - // Add the DKIM-Signature header to the email - if (signResult.signatures) { - email.addHeader('DKIM-Signature', signResult.signatures); - logger.log('info', `Successfully added DKIM signature for ${domainName}`); - } - } catch (error) { - logger.log('error', `Failed to apply DKIM signature: ${error.message}`); - // Don't throw - allow email to be sent without DKIM if signing fails - } - } - - /** - * Format email for SMTP transmission - * @param email Email to format - */ - private async getFormattedEmail(email: Email): Promise { - // This is a simplified implementation - // In a full implementation, this would use proper MIME formatting - - let content = ''; - - // Add headers - content += `From: ${email.from}\r\n`; - content += `To: ${email.to.join(', ')}\r\n`; - content += `Subject: ${email.subject}\r\n`; - - // Add additional headers - for (const [name, value] of Object.entries(email.headers || {})) { - content += `${name}: ${value}\r\n`; - } - - // Add content type for multipart - if (email.attachments && email.attachments.length > 0) { - const boundary = `----_=_NextPart_${Math.random().toString(36).substr(2)}`; - content += `MIME-Version: 1.0\r\n`; - content += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`; - content += `\r\n`; - - // Add text part - content += `--${boundary}\r\n`; - content += `Content-Type: text/plain; charset="UTF-8"\r\n`; - content += `\r\n`; - content += `${email.text}\r\n`; - - // Add HTML part if present - if (email.html) { - content += `--${boundary}\r\n`; - content += `Content-Type: text/html; charset="UTF-8"\r\n`; - content += `\r\n`; - content += `${email.html}\r\n`; - } - - // Add attachments - for (const attachment of email.attachments) { - content += `--${boundary}\r\n`; - content += `Content-Type: ${attachment.contentType || 'application/octet-stream'}; name="${attachment.filename}"\r\n`; - content += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`; - content += `Content-Transfer-Encoding: base64\r\n`; - content += `\r\n`; - - // Add base64 encoded content - const base64Content = attachment.content.toString('base64'); - - // Split into lines of 76 characters - for (let i = 0; i < base64Content.length; i += 76) { - content += base64Content.substring(i, i + 76) + '\r\n'; - } - } - - // End boundary - content += `--${boundary}--\r\n`; - } else { - // Simple email with just text - content += `Content-Type: text/plain; charset="UTF-8"\r\n`; - content += `\r\n`; - content += `${email.text}\r\n`; - } - - return content; - } - - /** - * Send SMTP command and wait for response - * @param socket Socket connection - * @param command SMTP command to send - */ - private async smtpCommand(socket: net.Socket, command: string): Promise { - return new Promise((resolve, reject) => { - const onData = (data: Buffer) => { - const response = data.toString().trim(); - - // Clean up listeners - socket.removeListener('data', onData); - socket.removeListener('error', onError); - socket.removeListener('timeout', onTimeout); - - // Check response code - if (response.charAt(0) === '2' || response.charAt(0) === '3') { - resolve(response); - } else { - reject(new Error(`SMTP error: ${response}`)); - } - }; - - const onError = (err: Error) => { - // Clean up listeners - socket.removeListener('data', onData); - socket.removeListener('error', onError); - socket.removeListener('timeout', onTimeout); - - reject(err); - }; - - const onTimeout = () => { - // Clean up listeners - socket.removeListener('data', onData); - socket.removeListener('error', onError); - socket.removeListener('timeout', onTimeout); - - reject(new Error('SMTP command timeout')); - }; - - // Set up listeners - socket.once('data', onData); - socket.once('error', onError); - socket.once('timeout', onTimeout); - - // Send command - socket.write(command + '\r\n'); - }); - } - - /** - * Send SMTP DATA command with content - * @param socket Socket connection - * @param data Email content to send - */ - private async smtpData(socket: net.Socket, data: string): Promise { - return new Promise((resolve, reject) => { - const onData = (responseData: Buffer) => { - const response = responseData.toString().trim(); - - // Clean up listeners - socket.removeListener('data', onData); - socket.removeListener('error', onError); - socket.removeListener('timeout', onTimeout); - - // Check response code - if (response.charAt(0) === '2') { - resolve(response); - } else { - reject(new Error(`SMTP error: ${response}`)); - } - }; - - const onError = (err: Error) => { - // Clean up listeners - socket.removeListener('data', onData); - socket.removeListener('error', onError); - socket.removeListener('timeout', onTimeout); - - reject(err); - }; - - const onTimeout = () => { - // Clean up listeners - socket.removeListener('data', onData); - socket.removeListener('error', onError); - socket.removeListener('timeout', onTimeout); - - reject(new Error('SMTP data timeout')); - }; - - // Set up listeners - socket.once('data', onData); - socket.once('error', onError); - socket.once('timeout', onTimeout); - - // Send data and end with CRLF.CRLF - socket.write(data + '\r\n.\r\n'); - }); - } - - /** - * Upgrade socket to TLS - * @param socket Socket connection - * @param hostname Target hostname for TLS - */ - private async upgradeTls(socket: net.Socket, hostname: string): Promise { - return new Promise((resolve, reject) => { - const tlsOptions: tls.ConnectionOptions = { - socket, - servername: hostname, - rejectUnauthorized: this.options.verifyCertificates, - minVersion: this.options.tlsMinVersion as tls.SecureVersion - }; - - const tlsSocket = tls.connect(tlsOptions); - - tlsSocket.once('secureConnect', () => { - resolve(tlsSocket); - }); - - tlsSocket.once('error', (err) => { - reject(new Error(`TLS error: ${err.message}`)); - }); - - tlsSocket.setTimeout(this.options.socketTimeout); - - tlsSocket.once('timeout', () => { - reject(new Error('TLS connection timeout')); - }); - }); - } - - /** - * Update delivery time statistics - */ - private updateDeliveryTimeStats(): void { - if (this.deliveryTimes.length === 0) return; - - // Keep only the last 1000 delivery times - if (this.deliveryTimes.length > 1000) { - this.deliveryTimes = this.deliveryTimes.slice(-1000); - } - - // Calculate average - const sum = this.deliveryTimes.reduce((acc, time) => acc + time, 0); - this.stats.avgDeliveryTime = sum / this.deliveryTimes.length; - } - - /** - * Check if rate limit is exceeded - * @returns True if rate limited, false otherwise - */ - private checkRateLimit(): boolean { - const now = Date.now(); - const elapsed = now - this.rateLimitLastCheck; - - // Reset counter if more than a minute has passed - if (elapsed >= 60000) { - this.rateLimitLastCheck = now; - this.rateLimitCounter = 0; - this.throttled = false; - this.stats.rateLimiting.currentRate = 0; - return false; - } - - // Check if we're already throttled - if (this.throttled) { - return true; - } - - // Increment counter - this.rateLimitCounter++; - - // Calculate current rate (emails per minute) - const rate = (this.rateLimitCounter / elapsed) * 60000; - this.stats.rateLimiting.currentRate = rate; - - // Check if rate limit is exceeded - if (rate > this.options.globalRateLimit) { - this.throttled = true; - this.stats.rateLimiting.throttled++; - - // Schedule throttle reset - const resetDelay = 60000 - elapsed; - setTimeout(() => { - this.throttled = false; - this.rateLimitLastCheck = Date.now(); - this.rateLimitCounter = 0; - this.stats.rateLimiting.currentRate = 0; - }, resetDelay); - - return true; - } - - return false; - } - - /** - * Update delivery options - * @param options New options - */ - public updateOptions(options: Partial): void { - this.options = { - ...this.options, - ...options - }; - - // Update rate limit statistics - if (options.globalRateLimit) { - this.stats.rateLimiting.globalLimit = options.globalRateLimit; - } - - logger.log('info', 'MultiModeDeliverySystem options updated'); - } - - /** - * Get delivery statistics - */ - public getStats(): IDeliveryStats { - return { ...this.stats }; - } -} \ No newline at end of file diff --git a/ts/mail/delivery/classes.emailsendjob.ts b/ts/mail/delivery/classes.emailsendjob.ts deleted file mode 100644 index 9743745..0000000 --- a/ts/mail/delivery/classes.emailsendjob.ts +++ /dev/null @@ -1,447 +0,0 @@ -import * as plugins from '../../plugins.js'; -import * as paths from '../../paths.js'; -import { Email } from '../core/classes.email.js'; -import { EmailSignJob } from './classes.emailsignjob.js'; -import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js'; -import type { SmtpClient } from './smtpclient/smtp-client.js'; -import type { ISmtpSendResult } from './smtpclient/interfaces.js'; - -// Configuration options for email sending -export interface IEmailSendOptions { - maxRetries?: number; - retryDelay?: number; // in milliseconds - connectionTimeout?: number; // in milliseconds - tlsOptions?: plugins.tls.ConnectionOptions; - debugMode?: boolean; -} - -// Email delivery status -export enum DeliveryStatus { - PENDING = 'pending', - SENDING = 'sending', - DELIVERED = 'delivered', - FAILED = 'failed', - DEFERRED = 'deferred' // Temporary failure, will retry -} - -// Detailed information about delivery attempts -export interface DeliveryInfo { - status: DeliveryStatus; - attempts: number; - error?: Error; - lastAttempt?: Date; - nextAttempt?: Date; - mxServer?: string; - deliveryTime?: Date; - logs: string[]; -} - -export class EmailSendJob { - emailServerRef: UnifiedEmailServer; - private email: Email; - private mxServers: string[] = []; - private currentMxIndex = 0; - private options: IEmailSendOptions; - public deliveryInfo: DeliveryInfo; - - constructor(emailServerRef: UnifiedEmailServer, emailArg: Email, options: IEmailSendOptions = {}) { - this.email = emailArg; - this.emailServerRef = emailServerRef; - - // Set default options - this.options = { - maxRetries: options.maxRetries || 3, - retryDelay: options.retryDelay || 30000, // 30 seconds - connectionTimeout: options.connectionTimeout || 60000, // 60 seconds - tlsOptions: options.tlsOptions || {}, - debugMode: options.debugMode || false - }; - - // Initialize delivery info - this.deliveryInfo = { - status: DeliveryStatus.PENDING, - attempts: 0, - logs: [] - }; - } - - /** - * Send the email to its recipients - */ - async send(): Promise { - try { - // Check if the email is valid before attempting to send - this.validateEmail(); - - // Resolve MX records for the recipient domain - await this.resolveMxRecords(); - - // Try to send the email - return await this.attemptDelivery(); - } catch (error) { - this.log(`Critical error in send process: ${error.message}`); - this.deliveryInfo.status = DeliveryStatus.FAILED; - this.deliveryInfo.error = error; - - // Save failed email for potential future retry or analysis - await this.saveFailed(); - return DeliveryStatus.FAILED; - } - } - - /** - * Validate the email before sending - */ - private validateEmail(): void { - if (!this.email.to || this.email.to.length === 0) { - throw new Error('No recipients specified'); - } - - if (!this.email.from) { - throw new Error('No sender specified'); - } - - const fromDomain = this.email.getFromDomain(); - if (!fromDomain) { - throw new Error('Invalid sender domain'); - } - } - - /** - * Resolve MX records for the recipient domain - */ - private async resolveMxRecords(): Promise { - const domain = this.email.getPrimaryRecipient()?.split('@')[1]; - if (!domain) { - throw new Error('Invalid recipient domain'); - } - - this.log(`Resolving MX records for domain: ${domain}`); - try { - const addresses = await this.resolveMx(domain); - - // Sort by priority (lowest number = highest priority) - addresses.sort((a, b) => a.priority - b.priority); - - this.mxServers = addresses.map(mx => mx.exchange); - this.log(`Found ${this.mxServers.length} MX servers: ${this.mxServers.join(', ')}`); - - if (this.mxServers.length === 0) { - throw new Error(`No MX records found for domain: ${domain}`); - } - } catch (error) { - this.log(`Failed to resolve MX records: ${error.message}`); - throw new Error(`MX lookup failed for ${domain}: ${error.message}`); - } - } - - /** - * Attempt to deliver the email with retries - */ - private async attemptDelivery(): Promise { - while (this.deliveryInfo.attempts < this.options.maxRetries) { - this.deliveryInfo.attempts++; - this.deliveryInfo.lastAttempt = new Date(); - this.deliveryInfo.status = DeliveryStatus.SENDING; - - try { - this.log(`Delivery attempt ${this.deliveryInfo.attempts} of ${this.options.maxRetries}`); - - // Try each MX server in order of priority - while (this.currentMxIndex < this.mxServers.length) { - const currentMx = this.mxServers[this.currentMxIndex]; - this.deliveryInfo.mxServer = currentMx; - - try { - this.log(`Attempting delivery to MX server: ${currentMx}`); - await this.connectAndSend(currentMx); - - // If we get here, email was sent successfully - this.deliveryInfo.status = DeliveryStatus.DELIVERED; - this.deliveryInfo.deliveryTime = new Date(); - this.log(`Email delivered successfully to ${currentMx}`); - - // Record delivery for sender reputation monitoring - this.recordDeliveryEvent('delivered'); - - // Save successful email record - await this.saveSuccess(); - return DeliveryStatus.DELIVERED; - } catch (error) { - this.log(`Failed to deliver to ${currentMx}: ${error.message}`); - this.currentMxIndex++; - - // If this MX server failed, try the next one - if (this.currentMxIndex >= this.mxServers.length) { - throw error; // No more MX servers to try - } - } - } - - throw new Error('All MX servers failed'); - } catch (error) { - this.deliveryInfo.error = error; - - // Check if this is a permanent failure - if (this.isPermanentFailure(error)) { - this.log('Permanent failure detected, not retrying'); - this.deliveryInfo.status = DeliveryStatus.FAILED; - - // Record permanent failure for bounce management - this.recordDeliveryEvent('bounced', true); - - await this.saveFailed(); - return DeliveryStatus.FAILED; - } - - // This is a temporary failure - if (this.deliveryInfo.attempts < this.options.maxRetries) { - this.log(`Temporary failure, will retry in ${this.options.retryDelay}ms`); - this.deliveryInfo.status = DeliveryStatus.DEFERRED; - this.deliveryInfo.nextAttempt = new Date(Date.now() + this.options.retryDelay); - - // Record temporary failure for monitoring - this.recordDeliveryEvent('deferred'); - - // Reset MX server index for next retry - this.currentMxIndex = 0; - - // Wait before retrying - await this.delay(this.options.retryDelay); - } - } - } - - // If we get here, all retries failed - this.deliveryInfo.status = DeliveryStatus.FAILED; - await this.saveFailed(); - return DeliveryStatus.FAILED; - } - - /** - * Connect to a specific MX server and send the email using SmtpClient - */ - private async connectAndSend(mxServer: string): Promise { - this.log(`Connecting to ${mxServer}:25`); - - try { - // Check if IP warmup is enabled and get an IP to use - let localAddress: string | undefined = undefined; - try { - const fromDomain = this.email.getFromDomain(); - const bestIP = this.emailServerRef.getBestIPForSending({ - from: this.email.from, - to: this.email.getAllRecipients(), - domain: fromDomain, - isTransactional: this.email.priority === 'high' - }); - - if (bestIP) { - this.log(`Using warmed-up IP ${bestIP} for sending`); - localAddress = bestIP; - - // Record the send for warm-up tracking - this.emailServerRef.recordIPSend(bestIP); - } - } catch (error) { - this.log(`Error selecting IP address: ${error.message}`); - } - - // Get SMTP client from UnifiedEmailServer - const smtpClient = this.emailServerRef.getSmtpClient(mxServer, 25); - - // Sign the email with DKIM if available - let signedEmail = this.email; - try { - const fromDomain = this.email.getFromDomain(); - if (fromDomain && this.emailServerRef.hasDkimKey(fromDomain)) { - // Convert email to RFC822 format for signing - const emailMessage = this.email.toRFC822String(); - - // Create sign job with proper options - const emailSignJob = new EmailSignJob(this.emailServerRef, { - domain: fromDomain, - selector: 'default', // Using default selector - headers: {}, // Headers will be extracted from emailMessage - body: emailMessage - }); - - // Get the DKIM signature header - const signatureHeader = await emailSignJob.getSignatureHeader(emailMessage); - - // Add the signature to the email - if (signatureHeader) { - // For now, we'll use the email as-is since SmtpClient will handle DKIM - this.log(`Email ready for DKIM signing for domain: ${fromDomain}`); - } - } - } catch (error) { - this.log(`Failed to prepare DKIM: ${error.message}`); - } - - // Send the email using SmtpClient - const result: ISmtpSendResult = await smtpClient.sendMail(signedEmail); - - if (result.success) { - this.log(`Email sent successfully: ${result.response}`); - - // Record the send for reputation monitoring - this.recordDeliveryEvent('delivered'); - } else { - throw new Error(result.error?.message || 'Failed to send email'); - } - } catch (error) { - this.log(`Failed to send email via ${mxServer}: ${error.message}`); - throw error; - } - } - - /** - * Record delivery event for monitoring - */ - private recordDeliveryEvent( - eventType: 'delivered' | 'bounced' | 'deferred', - isHardBounce: boolean = false - ): void { - try { - const domain = this.email.getFromDomain(); - if (domain) { - if (eventType === 'delivered') { - this.emailServerRef.recordDelivery(domain); - } else if (eventType === 'bounced') { - // Get the receiving domain for bounce recording - let receivingDomain = null; - const primaryRecipient = this.email.getPrimaryRecipient(); - if (primaryRecipient) { - receivingDomain = primaryRecipient.split('@')[1]; - } - - if (receivingDomain) { - this.emailServerRef.recordBounce( - domain, - receivingDomain, - isHardBounce ? 'hard' : 'soft', - this.deliveryInfo.error?.message || 'Unknown error' - ); - } - } - } - } catch (error) { - this.log(`Failed to record delivery event: ${error.message}`); - } - } - - /** - * Check if an error represents a permanent failure - */ - private isPermanentFailure(error: Error): boolean { - const permanentFailurePatterns = [ - 'User unknown', - 'No such user', - 'Mailbox not found', - 'Invalid recipient', - 'Account disabled', - 'Account suspended', - 'Domain not found', - 'No such domain', - 'Invalid domain', - 'Relay access denied', - 'Access denied', - 'Blacklisted', - 'Blocked', - '550', // Permanent failure SMTP code - '551', - '552', - '553', - '554' - ]; - - const errorMessage = error.message.toLowerCase(); - return permanentFailurePatterns.some(pattern => - errorMessage.includes(pattern.toLowerCase()) - ); - } - - /** - * Resolve MX records for a domain - */ - private resolveMx(domain: string): Promise { - return new Promise((resolve, reject) => { - plugins.dns.resolveMx(domain, (err, addresses) => { - if (err) { - reject(err); - } else { - resolve(addresses || []); - } - }); - }); - } - - /** - * Log a message with timestamp - */ - private log(message: string): void { - const timestamp = new Date().toISOString(); - const logEntry = `[${timestamp}] ${message}`; - this.deliveryInfo.logs.push(logEntry); - - if (this.options.debugMode) { - console.log(`[EmailSendJob] ${logEntry}`); - } - } - - /** - * Save successful email to storage - */ - private async saveSuccess(): Promise { - try { - // Use the existing email storage path - const emailContent = this.email.toRFC822String(); - const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_success.eml`; - const filePath = plugins.path.join(paths.sentEmailsDir, fileName); - - await plugins.fsUtils.ensureDir(paths.sentEmailsDir); - await plugins.fsUtils.toFs(emailContent, filePath); - - // Also save delivery info - const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_info.json`; - const infoPath = plugins.path.join(paths.sentEmailsDir, infoFileName); - await plugins.fsUtils.toFs(JSON.stringify(this.deliveryInfo, null, 2), infoPath); - - this.log(`Email saved to ${fileName}`); - } catch (error) { - this.log(`Failed to save email: ${error.message}`); - } - } - - /** - * Save failed email to storage - */ - private async saveFailed(): Promise { - try { - // Use the existing email storage path - const emailContent = this.email.toRFC822String(); - const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_failed.eml`; - const filePath = plugins.path.join(paths.failedEmailsDir, fileName); - - await plugins.fsUtils.ensureDir(paths.failedEmailsDir); - await plugins.fsUtils.toFs(emailContent, filePath); - - // Also save delivery info with error details - const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_error.json`; - const infoPath = plugins.path.join(paths.failedEmailsDir, infoFileName); - await plugins.fsUtils.toFs(JSON.stringify(this.deliveryInfo, null, 2), infoPath); - - this.log(`Failed email saved to ${fileName}`); - } catch (error) { - this.log(`Failed to save failed email: ${error.message}`); - } - } - - /** - * Delay for specified milliseconds - */ - private delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } -} \ No newline at end of file diff --git a/ts/mail/delivery/classes.emailsignjob.ts b/ts/mail/delivery/classes.emailsignjob.ts deleted file mode 100644 index c3cdd92..0000000 --- a/ts/mail/delivery/classes.emailsignjob.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as plugins from '../../plugins.js'; -import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js'; - -interface Headers { - [key: string]: string; -} - -interface IEmailSignJobOptions { - domain: string; - selector: string; - headers: Headers; - body: string; -} - -export class EmailSignJob { - emailServerRef: UnifiedEmailServer; - jobOptions: IEmailSignJobOptions; - - constructor(emailServerRef: UnifiedEmailServer, options: IEmailSignJobOptions) { - this.emailServerRef = emailServerRef; - this.jobOptions = options; - } - - async loadPrivateKey(): Promise { - const keyInfo = await this.emailServerRef.dkimCreator.readDKIMKeys(this.jobOptions.domain); - return keyInfo.privateKey; - } - - public async getSignatureHeader(emailMessage: string): Promise { - const signResult = await plugins.dkimSign(emailMessage, { - signingDomain: this.jobOptions.domain, - selector: this.jobOptions.selector, - privateKey: await this.loadPrivateKey(), - canonicalization: 'relaxed/relaxed', - algorithm: 'rsa-sha256', - signTime: new Date(), - }); - const signature = signResult.signatures; - return signature; - } -} diff --git a/ts/mail/delivery/classes.mta.config.ts b/ts/mail/delivery/classes.mta.config.ts deleted file mode 100644 index 6f800cf..0000000 --- a/ts/mail/delivery/classes.mta.config.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as plugins from '../../plugins.js'; -import * as paths from '../../paths.js'; -import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js'; - -/** - * Configures email server storage settings - * @param emailServer Reference to the unified email server - * @param options Configuration options containing storage paths - */ -export function configureEmailStorage(emailServer: UnifiedEmailServer, options: any): void { - // Extract the receivedEmailsPath if available - if (options?.emailPortConfig?.receivedEmailsPath) { - const receivedEmailsPath = options.emailPortConfig.receivedEmailsPath; - - // Ensure the directory exists - plugins.fsUtils.ensureDirSync(receivedEmailsPath); - - // Set path for received emails - if (emailServer) { - // Storage paths are now handled by the unified email server system - plugins.fsUtils.ensureDirSync(paths.receivedEmailsDir); - - console.log(`Configured email server to store received emails to: ${receivedEmailsPath}`); - } - } -} - -/** - * Configure email server with port and storage settings - * @param emailServer Reference to the unified email server - * @param config Configuration settings for email server - */ -export function configureEmailServer( - emailServer: UnifiedEmailServer, - config: { - ports?: number[]; - hostname?: string; - tls?: { - certPath?: string; - keyPath?: string; - caPath?: string; - }; - storagePath?: string; - } -): boolean { - if (!emailServer) { - console.error('Email server not available'); - return false; - } - - // Configure the email server with updated options - const serverOptions = { - ports: config.ports || [25, 587, 465], - hostname: config.hostname || 'localhost', - tls: config.tls - }; - - // Update the email server options - emailServer.updateOptions(serverOptions); - - console.log(`Configured email server on ports ${serverOptions.ports.join(', ')}`); - - // Set up storage path if provided - if (config.storagePath) { - configureEmailStorage(emailServer, { - emailPortConfig: { - receivedEmailsPath: config.storagePath - } - }); - } - - return true; -} \ No newline at end of file diff --git a/ts/mail/delivery/classes.ratelimiter.ts b/ts/mail/delivery/classes.ratelimiter.ts deleted file mode 100644 index a7b0e1e..0000000 --- a/ts/mail/delivery/classes.ratelimiter.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { logger } from '../../logger.js'; - -/** - * Configuration options for rate limiter - */ -export interface IRateLimitConfig { - /** Maximum tokens per period */ - maxPerPeriod: number; - - /** Time period in milliseconds */ - periodMs: number; - - /** Whether to apply per domain/key (vs globally) */ - perKey: boolean; - - /** Initial token count (defaults to max) */ - initialTokens?: number; - - /** Grace tokens to allow occasional bursts */ - burstTokens?: number; - - /** Apply global limit in addition to per-key limits */ - useGlobalLimit?: boolean; -} - -/** - * Token bucket for an individual key - */ -interface TokenBucket { - /** Current number of tokens */ - tokens: number; - - /** Last time tokens were refilled */ - lastRefill: number; - - /** Total allowed requests */ - allowed: number; - - /** Total denied requests */ - denied: number; -} - -/** - * Rate limiter using token bucket algorithm - * Provides more sophisticated rate limiting with burst handling - */ -export class RateLimiter { - /** Rate limit configuration */ - private config: IRateLimitConfig; - - /** Token buckets per key */ - private buckets: Map = new Map(); - - /** Global bucket for non-keyed rate limiting */ - private globalBucket: TokenBucket; - - /** - * Create a new rate limiter - * @param config Rate limiter configuration - */ - constructor(config: IRateLimitConfig) { - // Set defaults - this.config = { - maxPerPeriod: config.maxPerPeriod, - periodMs: config.periodMs, - perKey: config.perKey ?? true, - initialTokens: config.initialTokens ?? config.maxPerPeriod, - burstTokens: config.burstTokens ?? 0, - useGlobalLimit: config.useGlobalLimit ?? false - }; - - // Initialize global bucket - this.globalBucket = { - tokens: this.config.initialTokens, - lastRefill: Date.now(), - allowed: 0, - denied: 0 - }; - - // Log initialization - logger.log('info', `Rate limiter initialized: ${this.config.maxPerPeriod} per ${this.config.periodMs}ms${this.config.perKey ? ' per key' : ''}`); - } - - /** - * Check if a request is allowed under rate limits - * @param key Key to check rate limit for (e.g. domain, user, IP) - * @param cost Token cost (defaults to 1) - * @returns Whether the request is allowed - */ - public isAllowed(key: string = 'global', cost: number = 1): boolean { - // If using global bucket directly, just check that - if (key === 'global' || !this.config.perKey) { - return this.checkBucket(this.globalBucket, cost); - } - - // Get the key-specific bucket - const bucket = this.getBucket(key); - - // If we also need to check global limit - if (this.config.useGlobalLimit) { - // Both key bucket and global bucket must have tokens - return this.checkBucket(bucket, cost) && this.checkBucket(this.globalBucket, cost); - } else { - // Only need to check the key-specific bucket - return this.checkBucket(bucket, cost); - } - } - - /** - * Check if a bucket has enough tokens and consume them - * @param bucket The token bucket to check - * @param cost Token cost - * @returns Whether tokens were consumed - */ - private checkBucket(bucket: TokenBucket, cost: number): boolean { - // Refill tokens based on elapsed time - this.refillBucket(bucket); - - // Check if we have enough tokens - if (bucket.tokens >= cost) { - // Use tokens - bucket.tokens -= cost; - bucket.allowed++; - return true; - } else { - // Rate limit exceeded - bucket.denied++; - return false; - } - } - - /** - * Consume tokens for a request (if available) - * @param key Key to consume tokens for - * @param cost Token cost (defaults to 1) - * @returns Whether tokens were consumed - */ - public consume(key: string = 'global', cost: number = 1): boolean { - const isAllowed = this.isAllowed(key, cost); - return isAllowed; - } - - /** - * Get the remaining tokens for a key - * @param key Key to check - * @returns Number of remaining tokens - */ - public getRemainingTokens(key: string = 'global'): number { - const bucket = this.getBucket(key); - this.refillBucket(bucket); - return bucket.tokens; - } - - /** - * Get stats for a specific key - * @param key Key to get stats for - * @returns Rate limit statistics - */ - public getStats(key: string = 'global'): { - remaining: number; - limit: number; - resetIn: number; - allowed: number; - denied: number; - } { - const bucket = this.getBucket(key); - this.refillBucket(bucket); - - // Calculate time until next token - const resetIn = bucket.tokens < this.config.maxPerPeriod ? - Math.ceil(this.config.periodMs / this.config.maxPerPeriod) : - 0; - - return { - remaining: bucket.tokens, - limit: this.config.maxPerPeriod, - resetIn, - allowed: bucket.allowed, - denied: bucket.denied - }; - } - - /** - * Get or create a token bucket for a key - * @param key The rate limit key - * @returns Token bucket - */ - private getBucket(key: string): TokenBucket { - if (!this.config.perKey || key === 'global') { - return this.globalBucket; - } - - if (!this.buckets.has(key)) { - // Create new bucket - this.buckets.set(key, { - tokens: this.config.initialTokens, - lastRefill: Date.now(), - allowed: 0, - denied: 0 - }); - } - - return this.buckets.get(key); - } - - /** - * Refill tokens in a bucket based on elapsed time - * @param bucket Token bucket to refill - */ - private refillBucket(bucket: TokenBucket): void { - const now = Date.now(); - const elapsedMs = now - bucket.lastRefill; - - // Calculate how many tokens to add - const rate = this.config.maxPerPeriod / this.config.periodMs; - const tokensToAdd = elapsedMs * rate; - - if (tokensToAdd >= 0.1) { // Allow for partial token refills - // Add tokens, but don't exceed the normal maximum (without burst) - // This ensures burst tokens are only used for bursts and don't refill - const normalMax = this.config.maxPerPeriod; - bucket.tokens = Math.min( - // Don't exceed max + burst - this.config.maxPerPeriod + (this.config.burstTokens || 0), - // Don't exceed normal max when refilling - Math.min(normalMax, bucket.tokens + tokensToAdd) - ); - - // Update last refill time - bucket.lastRefill = now; - } - } - - /** - * Reset rate limits for a specific key - * @param key Key to reset - */ - public reset(key: string = 'global'): void { - if (key === 'global' || !this.config.perKey) { - this.globalBucket.tokens = this.config.initialTokens; - this.globalBucket.lastRefill = Date.now(); - } else if (this.buckets.has(key)) { - const bucket = this.buckets.get(key); - bucket.tokens = this.config.initialTokens; - bucket.lastRefill = Date.now(); - } - } - - /** - * Reset all rate limiters - */ - public resetAll(): void { - this.globalBucket.tokens = this.config.initialTokens; - this.globalBucket.lastRefill = Date.now(); - - for (const bucket of this.buckets.values()) { - bucket.tokens = this.config.initialTokens; - bucket.lastRefill = Date.now(); - } - } - - /** - * Cleanup old buckets to prevent memory leaks - * @param maxAge Maximum age in milliseconds - */ - public cleanup(maxAge: number = 24 * 60 * 60 * 1000): void { - const now = Date.now(); - let removed = 0; - - for (const [key, bucket] of this.buckets.entries()) { - if (now - bucket.lastRefill > maxAge) { - this.buckets.delete(key); - removed++; - } - } - - if (removed > 0) { - logger.log('debug', `Cleaned up ${removed} stale rate limit buckets`); - } - } -} \ No newline at end of file diff --git a/ts/mail/delivery/classes.smtp.client.legacy.ts b/ts/mail/delivery/classes.smtp.client.legacy.ts deleted file mode 100644 index 2815b75..0000000 --- a/ts/mail/delivery/classes.smtp.client.legacy.ts +++ /dev/null @@ -1,1417 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { logger } from '../../logger.js'; -import { - SecurityLogger, - SecurityLogLevel, - SecurityEventType -} from '../../security/index.js'; - -import { - MtaConnectionError, - MtaAuthenticationError, - MtaDeliveryError, - MtaConfigurationError, - MtaTimeoutError, - MtaProtocolError -} from '../../errors/index.js'; - -import { Email } from '../core/classes.email.js'; -import type { EmailProcessingMode } from './interfaces.js'; - -// Custom error type extension -interface NodeNetworkError extends Error { - code?: string; -} - -/** - * SMTP client connection options - */ -export type ISmtpClientOptions = { - /** - * Hostname of the SMTP server - */ - host: string; - - /** - * Port to connect to - */ - port: number; - - /** - * Whether to use TLS for the connection - */ - secure?: boolean; - - /** - * Connection timeout in milliseconds - */ - connectionTimeout?: number; - - /** - * Socket timeout in milliseconds - */ - socketTimeout?: number; - - /** - * Command timeout in milliseconds - */ - commandTimeout?: number; - - /** - * TLS options - */ - tls?: { - /** - * Whether to verify certificates - */ - rejectUnauthorized?: boolean; - - /** - * Minimum TLS version - */ - minVersion?: string; - - /** - * CA certificate path - */ - ca?: string; - }; - - /** - * Authentication options - */ - auth?: { - /** - * Authentication user - */ - user: string; - - /** - * Authentication password - */ - pass: string; - - /** - * Authentication method - */ - method?: 'PLAIN' | 'LOGIN' | 'OAUTH2'; - }; - - /** - * Domain name for EHLO - */ - domain?: string; - - /** - * DKIM options for signing outgoing emails - */ - dkim?: { - /** - * Whether to sign emails with DKIM - */ - enabled: boolean; - - /** - * Domain name for DKIM - */ - domain: string; - - /** - * Selector for DKIM - */ - selector: string; - - /** - * Private key for DKIM signing - */ - privateKey: string; - - /** - * Headers to sign - */ - headers?: string[]; - }; -}; - -/** - * SMTP delivery result - */ -export type ISmtpDeliveryResult = { - /** - * Whether the delivery was successful - */ - success: boolean; - - /** - * Message ID if successful - */ - messageId?: string; - - /** - * Error message if failed - */ - error?: string; - - /** - * SMTP response code - */ - responseCode?: string; - - /** - * Recipients successfully delivered to - */ - acceptedRecipients: string[]; - - /** - * Recipients rejected during delivery - */ - rejectedRecipients: string[]; - - /** - * Server response - */ - response?: string; - - /** - * Timestamp of the delivery attempt - */ - timestamp: number; - - /** - * Whether DKIM signing was applied - */ - dkimSigned?: boolean; - - /** - * Whether this was a TLS secured delivery - */ - secure?: boolean; - - /** - * Whether authentication was used - */ - authenticated?: boolean; -}; - -/** - * SMTP client for sending emails to remote mail servers - */ -export class SmtpClient { - private options: ISmtpClientOptions; - private connected: boolean = false; - private socket?: plugins.net.Socket | plugins.tls.TLSSocket; - private supportedExtensions: Set = new Set(); - - /** - * Create a new SMTP client instance - * @param options SMTP client connection options - */ - constructor(options: ISmtpClientOptions) { - // Set default options - this.options = { - ...options, - connectionTimeout: options.connectionTimeout || 30000, // 30 seconds - socketTimeout: options.socketTimeout || 60000, // 60 seconds - commandTimeout: options.commandTimeout || 30000, // 30 seconds - secure: options.secure || false, - domain: options.domain || 'localhost', - tls: { - rejectUnauthorized: options.tls?.rejectUnauthorized !== false, // Default to true - minVersion: options.tls?.minVersion || 'TLSv1.2' - } - }; - } - - /** - * Connect to the SMTP server - */ - public async connect(): Promise { - if (this.connected && this.socket) { - return; - } - - try { - logger.log('info', `Connecting to SMTP server ${this.options.host}:${this.options.port}`); - - // Create socket - const socket = new plugins.net.Socket(); - - // Set timeouts - socket.setTimeout(this.options.socketTimeout); - - // Connect to the server - await new Promise((resolve, reject) => { - // Handle connection events - socket.once('connect', () => { - logger.log('debug', `Connected to ${this.options.host}:${this.options.port}`); - resolve(); - }); - - socket.once('timeout', () => { - reject(MtaConnectionError.timeout( - this.options.host, - this.options.port, - this.options.connectionTimeout - )); - }); - - socket.once('error', (err: NodeNetworkError) => { - if (err.code === 'ECONNREFUSED') { - reject(MtaConnectionError.refused( - this.options.host, - this.options.port - )); - } else if (err.code === 'ENOTFOUND') { - reject(MtaConnectionError.dnsError( - this.options.host, - err - )); - } else { - reject(new MtaConnectionError( - `Connection error to ${this.options.host}:${this.options.port}: ${err.message}`, - { - data: { - host: this.options.host, - port: this.options.port, - error: err.message, - code: err.code - } - } - )); - } - }); - - // Connect to the server - const connectOptions = { - host: this.options.host, - port: this.options.port - }; - - // For direct TLS connections - if (this.options.secure) { - const tlsSocket = plugins.tls.connect({ - ...connectOptions, - rejectUnauthorized: this.options.tls.rejectUnauthorized, - minVersion: this.options.tls.minVersion as any, - ca: this.options.tls.ca ? [this.options.tls.ca] : undefined - } as plugins.tls.ConnectionOptions); - - tlsSocket.once('secureConnect', () => { - logger.log('debug', `Secure connection established to ${this.options.host}:${this.options.port}`); - this.socket = tlsSocket; - resolve(); - }); - - tlsSocket.once('error', (err: NodeNetworkError) => { - reject(new MtaConnectionError( - `TLS connection error to ${this.options.host}:${this.options.port}: ${err.message}`, - { - data: { - host: this.options.host, - port: this.options.port, - error: err.message, - code: err.code - } - } - )); - }); - - tlsSocket.setTimeout(this.options.socketTimeout); - - tlsSocket.once('timeout', () => { - reject(MtaConnectionError.timeout( - this.options.host, - this.options.port, - this.options.connectionTimeout - )); - }); - } else { - socket.connect(connectOptions); - this.socket = socket; - } - }); - - // Wait for server greeting - const greeting = await this.readResponse(); - - if (!greeting.startsWith('220')) { - throw new MtaConnectionError( - `Unexpected greeting from server: ${greeting}`, - { - data: { - host: this.options.host, - port: this.options.port, - greeting - } - } - ); - } - - // Send EHLO - await this.sendEhlo(); - - // Start TLS if not secure and supported - if (!this.options.secure && this.supportedExtensions.has('STARTTLS')) { - await this.startTls(); - - // Send EHLO again after STARTTLS - await this.sendEhlo(); - } - - // Authenticate if credentials provided - if (this.options.auth) { - await this.authenticate(); - } - - this.connected = true; - logger.log('info', `Successfully connected to SMTP server ${this.options.host}:${this.options.port}`); - - // Set up error handling for the socket - this.socket.on('error', (err) => { - logger.log('error', `Socket error: ${err.message}`); - this.connected = false; - this.socket = undefined; - }); - - this.socket.on('close', () => { - logger.log('debug', 'Socket closed'); - this.connected = false; - this.socket = undefined; - }); - - this.socket.on('timeout', () => { - logger.log('error', 'Socket timeout'); - this.connected = false; - if (this.socket) { - this.socket.destroy(); - this.socket = undefined; - } - }); - - } catch (error) { - // Clean up socket if connection failed - if (this.socket) { - this.socket.destroy(); - this.socket = undefined; - } - - logger.log('error', `Failed to connect to SMTP server: ${error.message}`); - throw error; - } - } - - /** - * Send EHLO command to the server - */ - private async sendEhlo(): Promise { - // Clear previous extensions - this.supportedExtensions.clear(); - - // Send EHLO - don't allow pipelining for this command - const response = await this.sendCommand(`EHLO ${this.options.domain}`, false); - - // Parse supported extensions - const lines = response.split('\r\n'); - for (let i = 1; i < lines.length; i++) { - const line = lines[i]; - if (line.startsWith('250-') || line.startsWith('250 ')) { - const extension = line.substring(4).split(' ')[0]; - this.supportedExtensions.add(extension); - } - } - - // Check if server supports pipelining - this.supportsPipelining = this.supportedExtensions.has('PIPELINING'); - - logger.log('debug', `Server supports extensions: ${Array.from(this.supportedExtensions).join(', ')}`); - if (this.supportsPipelining) { - logger.log('info', 'Server supports PIPELINING - will use for improved performance'); - } - } - - /** - * Start TLS negotiation - */ - private async startTls(): Promise { - logger.log('debug', 'Starting TLS negotiation'); - - // Send STARTTLS command - const response = await this.sendCommand('STARTTLS'); - - if (!response.startsWith('220')) { - throw new MtaConnectionError( - `Failed to start TLS: ${response}`, - { - data: { - host: this.options.host, - port: this.options.port, - response - } - } - ); - } - - if (!this.socket) { - throw new MtaConnectionError( - 'No socket available for TLS upgrade', - { - data: { - host: this.options.host, - port: this.options.port - } - } - ); - } - - // Upgrade socket to TLS - const currentSocket = this.socket; - this.socket = await this.upgradeTls(currentSocket); - } - - /** - * Upgrade socket to TLS - * @param socket Original socket - */ - private async upgradeTls(socket: plugins.net.Socket): Promise { - return new Promise((resolve, reject) => { - const tlsOptions: plugins.tls.ConnectionOptions = { - socket, - servername: this.options.host, - rejectUnauthorized: this.options.tls.rejectUnauthorized, - minVersion: this.options.tls.minVersion as any, - ca: this.options.tls.ca ? [this.options.tls.ca] : undefined - }; - - const tlsSocket = plugins.tls.connect(tlsOptions); - - tlsSocket.once('secureConnect', () => { - logger.log('debug', 'TLS negotiation successful'); - resolve(tlsSocket); - }); - - tlsSocket.once('error', (err: NodeNetworkError) => { - reject(new MtaConnectionError( - `TLS error: ${err.message}`, - { - data: { - host: this.options.host, - port: this.options.port, - error: err.message, - code: err.code - } - } - )); - }); - - tlsSocket.setTimeout(this.options.socketTimeout); - - tlsSocket.once('timeout', () => { - reject(MtaTimeoutError.commandTimeout( - 'STARTTLS', - this.options.host, - this.options.socketTimeout - )); - }); - }); - } - - /** - * Authenticate with the server - */ - private async authenticate(): Promise { - if (!this.options.auth) { - return; - } - - const { user, pass, method = 'LOGIN' } = this.options.auth; - - logger.log('debug', `Authenticating as ${user} using ${method}`); - - try { - switch (method) { - case 'PLAIN': - await this.authPlain(user, pass); - break; - - case 'LOGIN': - await this.authLogin(user, pass); - break; - - case 'OAUTH2': - await this.authOAuth2(user, pass); - break; - - default: - throw new MtaAuthenticationError( - `Authentication method ${method} not supported by client`, - { - data: { - method - } - } - ); - } - - logger.log('info', `Successfully authenticated as ${user}`); - } catch (error) { - logger.log('error', `Authentication failed: ${error.message}`); - throw error; - } - } - - /** - * Authenticate using PLAIN method - * @param user Username - * @param pass Password - */ - private async authPlain(user: string, pass: string): Promise { - // PLAIN authentication format: \0username\0password - const authString = Buffer.from(`\0${user}\0${pass}`).toString('base64'); - const response = await this.sendCommand(`AUTH PLAIN ${authString}`); - - if (!response.startsWith('235')) { - throw MtaAuthenticationError.invalidCredentials( - this.options.host, - user - ); - } - } - - /** - * Authenticate using LOGIN method - * @param user Username - * @param pass Password - */ - private async authLogin(user: string, pass: string): Promise { - // Start LOGIN authentication - const response = await this.sendCommand('AUTH LOGIN'); - - if (!response.startsWith('334')) { - throw new MtaAuthenticationError( - `Server did not accept AUTH LOGIN: ${response}`, - { - data: { - host: this.options.host, - response - } - } - ); - } - - // Send username (base64) - const userResponse = await this.sendCommand(Buffer.from(user).toString('base64')); - - if (!userResponse.startsWith('334')) { - throw MtaAuthenticationError.invalidCredentials( - this.options.host, - user - ); - } - - // Send password (base64) - const passResponse = await this.sendCommand(Buffer.from(pass).toString('base64')); - - if (!passResponse.startsWith('235')) { - throw MtaAuthenticationError.invalidCredentials( - this.options.host, - user - ); - } - } - - /** - * Authenticate using OAuth2 method - * @param user Username - * @param token OAuth2 token - */ - private async authOAuth2(user: string, token: string): Promise { - // XOAUTH2 format - const authString = `user=${user}\x01auth=Bearer ${token}\x01\x01`; - const response = await this.sendCommand(`AUTH XOAUTH2 ${Buffer.from(authString).toString('base64')}`); - - if (!response.startsWith('235')) { - throw MtaAuthenticationError.invalidCredentials( - this.options.host, - user - ); - } - } - - /** - * Send an email through the SMTP client - * @param email Email to send - * @param processingMode Optional processing mode - */ - public async sendMail(email: Email, processingMode?: EmailProcessingMode): Promise { - // Ensure we're connected - if (!this.connected || !this.socket) { - await this.connect(); - } - - const startTime = Date.now(); - const result: ISmtpDeliveryResult = { - success: false, - acceptedRecipients: [], - rejectedRecipients: [], - timestamp: startTime, - secure: this.options.secure || this.socket instanceof plugins.tls.TLSSocket, - authenticated: !!this.options.auth - }; - - try { - logger.log('info', `Sending email to ${email.getAllRecipients().join(', ')}`); - - // Apply DKIM signing if configured - if (this.options.dkim?.enabled) { - await this.applyDkimSignature(email); - result.dkimSigned = true; - } - - // Get envelope and recipients - const envelope_from = email.getEnvelopeFrom() || email.from; - const recipients = email.getAllRecipients(); - - // Check if we can use pipelining for MAIL FROM and RCPT TO commands - if (this.supportsPipelining && recipients.length > 0) { - logger.log('debug', 'Using SMTP pipelining for sending'); - - // Send MAIL FROM command first (always needed) - const mailFromCmd = `MAIL FROM:<${envelope_from}> SIZE=${this.getEmailSize(email)}`; - let mailFromResponse: string; - - try { - mailFromResponse = await this.sendCommand(mailFromCmd); - - if (!mailFromResponse.startsWith('250')) { - throw new MtaDeliveryError( - `MAIL FROM command failed: ${mailFromResponse}`, - { - data: { - command: mailFromCmd, - response: mailFromResponse - } - } - ); - } - } catch (error) { - logger.log('error', `MAIL FROM failed: ${error.message}`); - throw error; - } - - // Pipeline all RCPT TO commands - const rcptPromises = recipients.map(recipient => { - return this.sendCommand(`RCPT TO:<${recipient}>`) - .then(response => { - if (response.startsWith('250')) { - result.acceptedRecipients.push(recipient); - return { recipient, accepted: true, response }; - } else { - result.rejectedRecipients.push(recipient); - logger.log('warn', `Recipient ${recipient} rejected: ${response}`); - return { recipient, accepted: false, response }; - } - }) - .catch(error => { - result.rejectedRecipients.push(recipient); - logger.log('warn', `Recipient ${recipient} rejected with error: ${error.message}`); - return { recipient, accepted: false, error: error.message }; - }); - }); - - // Wait for all RCPT TO commands to complete - await Promise.all(rcptPromises); - } else { - // Fall back to sequential commands if pipelining not supported - logger.log('debug', 'Using sequential SMTP commands for sending'); - - // Send MAIL FROM - await this.sendCommand(`MAIL FROM:<${envelope_from}> SIZE=${this.getEmailSize(email)}`); - - // Send RCPT TO for each recipient - for (const recipient of recipients) { - try { - await this.sendCommand(`RCPT TO:<${recipient}>`); - result.acceptedRecipients.push(recipient); - } catch (error) { - logger.log('warn', `Recipient ${recipient} rejected: ${error.message}`); - result.rejectedRecipients.push(recipient); - } - } - } - - // Check if at least one recipient was accepted - if (result.acceptedRecipients.length === 0) { - throw new MtaDeliveryError( - 'All recipients were rejected', - { - data: { - recipients, - rejectedRecipients: result.rejectedRecipients - } - } - ); - } - - // Send DATA - const dataResponse = await this.sendCommand('DATA'); - - if (!dataResponse.startsWith('354')) { - throw new MtaProtocolError( - `Failed to start DATA phase: ${dataResponse}`, - { - data: { - response: dataResponse - } - } - ); - } - - // Format email content efficiently - const emailContent = await this.getFormattedEmail(email); - - // Send email content - const finalResponse = await this.sendCommand(emailContent + '\r\n.'); - - // Extract message ID if available - const messageIdMatch = finalResponse.match(/\[(.*?)\]/); - if (messageIdMatch) { - result.messageId = messageIdMatch[1]; - } - - result.success = true; - result.response = finalResponse; - - logger.log('info', `Email sent successfully to ${result.acceptedRecipients.join(', ')}`); - - // Log security event - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.INFO, - type: SecurityEventType.EMAIL_DELIVERY, - message: 'Email sent successfully', - details: { - recipients: result.acceptedRecipients, - rejectedRecipients: result.rejectedRecipients, - messageId: result.messageId, - secure: result.secure, - authenticated: result.authenticated, - server: `${this.options.host}:${this.options.port}`, - dkimSigned: result.dkimSigned - }, - success: true - }); - - return result; - } catch (error) { - logger.log('error', `Failed to send email: ${error.message}`); - - // Format error for result - result.error = error.message; - - // Extract SMTP code if available - if (error.context?.data?.statusCode) { - result.responseCode = error.context.data.statusCode; - } - - // Log security event - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.EMAIL_DELIVERY, - message: 'Email delivery failed', - details: { - error: error.message, - server: `${this.options.host}:${this.options.port}`, - recipients: email.getAllRecipients(), - acceptedRecipients: result.acceptedRecipients, - rejectedRecipients: result.rejectedRecipients, - secure: result.secure, - authenticated: result.authenticated - }, - success: false - }); - - return result; - } - } - - /** - * Apply DKIM signature to email - * @param email Email to sign - */ - private async applyDkimSignature(email: Email): Promise { - if (!this.options.dkim?.enabled || !this.options.dkim?.privateKey) { - return; - } - - try { - logger.log('debug', `Signing email with DKIM for domain ${this.options.dkim.domain}`); - - // Format email for DKIM signing - const { dkimSign } = plugins; - const emailContent = await this.getFormattedEmail(email); - - // Sign email with updated mailauth API - const signResult = await dkimSign(emailContent, { - signingDomain: this.options.dkim.domain, - selector: this.options.dkim.selector, - privateKey: this.options.dkim.privateKey, - headerList: this.options.dkim.headers || [ - 'from', 'to', 'subject', 'date', 'message-id' - ] - }); - - // Add DKIM-Signature header to email - if (signResult.signatures) { - email.addHeader('DKIM-Signature', signResult.signatures); - } - - logger.log('debug', 'DKIM signature applied successfully'); - } catch (error) { - logger.log('error', `Failed to apply DKIM signature: ${error.message}`); - throw error; - } - } - - /** - * Format email for SMTP transmission - * @param email Email to format - */ - private async getFormattedEmail(email: Email): Promise { - // This is a simplified implementation - // In a full implementation, this would use proper MIME formatting - - let content = ''; - - // Add headers - content += `From: ${email.from}\r\n`; - content += `To: ${email.to.join(', ')}\r\n`; - content += `Subject: ${email.subject}\r\n`; - content += `Date: ${new Date().toUTCString()}\r\n`; - content += `Message-ID: <${plugins.uuid.v4()}@${this.options.domain}>\r\n`; - - // Add additional headers - for (const [name, value] of Object.entries(email.headers || {})) { - content += `${name}: ${value}\r\n`; - } - - // Add content type for multipart - if (email.attachments && email.attachments.length > 0) { - const boundary = `----_=_NextPart_${Math.random().toString(36).substr(2)}`; - content += `MIME-Version: 1.0\r\n`; - content += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`; - content += `\r\n`; - - // Add text part - content += `--${boundary}\r\n`; - content += `Content-Type: text/plain; charset="UTF-8"\r\n`; - content += `\r\n`; - content += `${email.text}\r\n`; - - // Add HTML part if present - if (email.html) { - content += `--${boundary}\r\n`; - content += `Content-Type: text/html; charset="UTF-8"\r\n`; - content += `\r\n`; - content += `${email.html}\r\n`; - } - - // Add attachments - for (const attachment of email.attachments) { - content += `--${boundary}\r\n`; - content += `Content-Type: ${attachment.contentType || 'application/octet-stream'}; name="${attachment.filename}"\r\n`; - content += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`; - content += `Content-Transfer-Encoding: base64\r\n`; - content += `\r\n`; - - // Add base64 encoded content - const base64Content = attachment.content.toString('base64'); - - // Split into lines of 76 characters - for (let i = 0; i < base64Content.length; i += 76) { - content += base64Content.substring(i, i + 76) + '\r\n'; - } - } - - // End boundary - content += `--${boundary}--\r\n`; - } else { - // Simple email with just text - content += `Content-Type: text/plain; charset="UTF-8"\r\n`; - content += `\r\n`; - content += `${email.text}\r\n`; - } - - return content; - } - - /** - * Get size of email in bytes - * @param email Email to measure - */ - private getEmailSize(email: Email): number { - // Simplified size estimation - let size = 0; - - // Headers - size += `From: ${email.from}\r\n`.length; - size += `To: ${email.to.join(', ')}\r\n`.length; - size += `Subject: ${email.subject}\r\n`.length; - - // Body - size += (email.text?.length || 0) + 2; // +2 for CRLF - - // HTML part if present - if (email.html) { - size += email.html.length + 2; - } - - // Attachments - for (const attachment of email.attachments || []) { - size += attachment.content.length; - } - - // Add overhead for MIME boundaries and headers - const overhead = email.attachments?.length ? 1000 + (email.attachments.length * 200) : 200; - - return size + overhead; - } - - /** - * Send SMTP command and wait for response - * @param command SMTP command to send - */ - // Queue for command pipelining - private commandQueue: Array<{ - command: string; - resolve: (response: string) => void; - reject: (error: any) => void; - timeout: NodeJS.Timeout; - }> = []; - - // Flag to indicate if we're currently processing commands - private processingCommands = false; - - // Flag to indicate if server supports pipelining - private supportsPipelining = false; - - /** - * Send an SMTP command and wait for response - * @param command SMTP command to send - * @param allowPipelining Whether this command can be pipelined - */ - private async sendCommand(command: string, allowPipelining = true): Promise { - if (!this.socket) { - throw new MtaConnectionError( - 'Not connected to server', - { - data: { - host: this.options.host, - port: this.options.port - } - } - ); - } - - // Log command if not sensitive - if (!command.startsWith('AUTH')) { - logger.log('debug', `> ${command}`); - } else { - logger.log('debug', '> AUTH ***'); - } - - return new Promise((resolve, reject) => { - // Set up timeout for command - const timeout = setTimeout(() => { - // Remove this command from the queue if it times out - const index = this.commandQueue.findIndex(item => item.command === command); - if (index !== -1) { - this.commandQueue.splice(index, 1); - } - - reject(MtaTimeoutError.commandTimeout( - command.split(' ')[0], - this.options.host, - this.options.commandTimeout - )); - }, this.options.commandTimeout); - - // Add command to the queue - this.commandQueue.push({ - command, - resolve, - reject, - timeout - }); - - // Process command queue if we can pipeline or if not currently processing commands - if ((this.supportsPipelining && allowPipelining) || !this.processingCommands) { - this.processCommandQueue(); - } - }); - } - - /** - * Process the command queue - either one by one or pipelined if supported - */ - private processCommandQueue(): void { - if (this.processingCommands || this.commandQueue.length === 0 || !this.socket) { - return; - } - - this.processingCommands = true; - - try { - // If pipelining is supported, send all commands at once - if (this.supportsPipelining) { - // Send all commands in queue at once - const commands = this.commandQueue.map(item => item.command).join('\r\n') + '\r\n'; - - this.socket.write(commands, (err) => { - if (err) { - // Handle write error for all commands - const error = new MtaConnectionError( - `Failed to send commands: ${err.message}`, - { - data: { - error: err.message - } - } - ); - - // Fail all pending commands - while (this.commandQueue.length > 0) { - const item = this.commandQueue.shift(); - clearTimeout(item.timeout); - item.reject(error); - } - - this.processingCommands = false; - } - }); - - // Process responses one by one in order - this.processResponses(); - } else { - // Process commands one by one if pipelining not supported - this.processNextCommand(); - } - } catch (error) { - logger.log('error', `Error processing command queue: ${error.message}`); - this.processingCommands = false; - } - } - - /** - * Process the next command in the queue (non-pipelined mode) - */ - private processNextCommand(): void { - if (this.commandQueue.length === 0 || !this.socket) { - this.processingCommands = false; - return; - } - - const currentCommand = this.commandQueue[0]; - - this.socket.write(currentCommand.command + '\r\n', (err) => { - if (err) { - // Handle write error - const error = new MtaConnectionError( - `Failed to send command: ${err.message}`, - { - data: { - command: currentCommand.command.split(' ')[0], - error: err.message - } - } - ); - - // Remove from queue - this.commandQueue.shift(); - clearTimeout(currentCommand.timeout); - currentCommand.reject(error); - - // Continue with next command - this.processNextCommand(); - return; - } - - // Read response - this.readResponse() - .then((response) => { - // Remove from queue and resolve - this.commandQueue.shift(); - clearTimeout(currentCommand.timeout); - currentCommand.resolve(response); - - // Process next command - this.processNextCommand(); - }) - .catch((err) => { - // Remove from queue and reject - this.commandQueue.shift(); - clearTimeout(currentCommand.timeout); - currentCommand.reject(err); - - // Process next command - this.processNextCommand(); - }); - }); - } - - /** - * Process responses for pipelined commands - */ - private async processResponses(): Promise { - try { - // Process responses for each command in order - while (this.commandQueue.length > 0) { - const currentCommand = this.commandQueue[0]; - - try { - // Wait for response - const response = await this.readResponse(); - - // Remove from queue and resolve - this.commandQueue.shift(); - clearTimeout(currentCommand.timeout); - currentCommand.resolve(response); - } catch (error) { - // Remove from queue and reject - this.commandQueue.shift(); - clearTimeout(currentCommand.timeout); - currentCommand.reject(error); - - // Stop processing if this is a critical error - if ( - error instanceof MtaConnectionError && - (error.message.includes('Connection closed') || error.message.includes('Not connected')) - ) { - break; - } - } - } - } catch (error) { - logger.log('error', `Error processing responses: ${error.message}`); - } finally { - this.processingCommands = false; - } - } - - /** - * Read response from the server - */ - private async readResponse(): Promise { - if (!this.socket) { - throw new MtaConnectionError( - 'Not connected to server', - { - data: { - host: this.options.host, - port: this.options.port - } - } - ); - } - - return new Promise((resolve, reject) => { - // Use an array to collect response chunks instead of string concatenation - const responseChunks: Buffer[] = []; - - // Single function to clean up all listeners - const cleanupListeners = () => { - if (!this.socket) return; - this.socket.removeListener('data', onData); - this.socket.removeListener('error', onError); - this.socket.removeListener('close', onClose); - this.socket.removeListener('end', onEnd); - }; - - const onData = (data: Buffer) => { - // Store buffer directly, avoiding unnecessary string conversion - responseChunks.push(data); - - // Convert to string only for response checking - const responseData = Buffer.concat(responseChunks).toString(); - - // Check if this is a complete response - if (this.isCompleteResponse(responseData)) { - // Clean up listeners - cleanupListeners(); - - const trimmedResponse = responseData.trim(); - logger.log('debug', `< ${trimmedResponse}`); - - // Check if this is an error response - if (this.isErrorResponse(responseData)) { - const code = responseData.substring(0, 3); - reject(this.createErrorFromResponse(trimmedResponse, code)); - } else { - resolve(trimmedResponse); - } - } - }; - - const onError = (err: Error) => { - cleanupListeners(); - - reject(new MtaConnectionError( - `Socket error while waiting for response: ${err.message}`, - { - data: { - error: err.message - } - } - )); - }; - - const onClose = () => { - cleanupListeners(); - - const responseData = Buffer.concat(responseChunks).toString(); - reject(new MtaConnectionError( - 'Connection closed while waiting for response', - { - data: { - partialResponse: responseData - } - } - )); - }; - - const onEnd = () => { - cleanupListeners(); - - const responseData = Buffer.concat(responseChunks).toString(); - reject(new MtaConnectionError( - 'Connection ended while waiting for response', - { - data: { - partialResponse: responseData - } - } - )); - }; - - // Set up listeners - this.socket.on('data', onData); - this.socket.once('error', onError); - this.socket.once('close', onClose); - this.socket.once('end', onEnd); - }); - } - - /** - * Check if the response is complete - * @param response Response to check - */ - private isCompleteResponse(response: string): boolean { - // Check if it's a multi-line response - const lines = response.split('\r\n'); - const lastLine = lines[lines.length - 2]; // Second to last because of the trailing CRLF - - // Check if the last line starts with a code followed by a space - // If it does, this is a complete response - if (lastLine && /^\d{3} /.test(lastLine)) { - return true; - } - - // For single line responses - if (lines.length === 2 && lines[0].length >= 3 && /^\d{3} /.test(lines[0])) { - return true; - } - - return false; - } - - /** - * Check if the response is an error - * @param response Response to check - */ - private isErrorResponse(response: string): boolean { - // Get the status code (first 3 characters) - const code = response.substring(0, 3); - - // 4xx and 5xx are error codes - return code.startsWith('4') || code.startsWith('5'); - } - - /** - * Create appropriate error from response - * @param response Error response - * @param code SMTP status code - */ - private createErrorFromResponse(response: string, code: string): Error { - // Extract message part - const message = response.substring(4).trim(); - - switch (code.charAt(0)) { - case '4': // Temporary errors - return MtaDeliveryError.temporary( - message, - 'recipient', - code, - response - ); - - case '5': // Permanent errors - return MtaDeliveryError.permanent( - message, - 'recipient', - code, - response - ); - - default: - return new MtaDeliveryError( - `Unexpected error response: ${response}`, - { - data: { - response, - code - } - } - ); - } - } - - /** - * Close the connection to the server - */ - public async close(): Promise { - if (!this.connected || !this.socket) { - return; - } - - try { - // Send QUIT - await this.sendCommand('QUIT'); - } catch (error) { - logger.log('warn', `Error sending QUIT command: ${error.message}`); - } finally { - // Close socket - this.socket.destroy(); - this.socket = undefined; - this.connected = false; - logger.log('info', 'SMTP connection closed'); - } - } - - /** - * Checks if the connection is active - */ - public isConnected(): boolean { - return this.connected && !!this.socket; - } - - /** - * Update SMTP client options - * @param options New options - */ - public updateOptions(options: Partial): void { - this.options = { - ...this.options, - ...options - }; - - logger.log('info', 'SMTP client options updated'); - } -} \ No newline at end of file diff --git a/ts/mail/delivery/classes.unified.rate.limiter.ts b/ts/mail/delivery/classes.unified.rate.limiter.ts deleted file mode 100644 index 5ea8385..0000000 --- a/ts/mail/delivery/classes.unified.rate.limiter.ts +++ /dev/null @@ -1,1053 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { EventEmitter } from 'node:events'; -import { logger } from '../../logger.js'; -import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js'; - -/** - * Interface for rate limit configuration - */ -export interface IRateLimitConfig { - maxMessagesPerMinute?: number; - maxRecipientsPerMessage?: number; - maxConnectionsPerIP?: number; - maxErrorsPerIP?: number; - maxAuthFailuresPerIP?: number; - blockDuration?: number; // in milliseconds -} - -/** - * Interface for hierarchical rate limits - */ -export interface IHierarchicalRateLimits { - // Global rate limits (applied to all traffic) - global: IRateLimitConfig; - - // Pattern-specific rate limits (applied to matching patterns) - patterns?: Record; - - // IP-specific rate limits (applied to specific IPs) - ips?: Record; - - // Domain-specific rate limits (applied to specific email domains) - domains?: Record; - - // Temporary blocks list and their expiry times - blocks?: Record; // IP to expiry timestamp -} - -/** - * Counter interface for rate limiting - */ -interface ILimitCounter { - count: number; - lastReset: number; - recipients: number; - errors: number; - authFailures: number; - connections: number; -} - -/** - * Rate limiter statistics - */ -export interface IRateLimiterStats { - activeCounters: number; - totalBlocked: number; - currentlyBlocked: number; - byPattern: Record; - byIp: Record; -} - -/** - * Result of a rate limit check - */ -export interface IRateLimitResult { - allowed: boolean; - reason?: string; - limit?: number; - current?: number; - resetIn?: number; // milliseconds until reset -} - -/** - * Unified rate limiter for all email processing modes - */ -export class UnifiedRateLimiter extends EventEmitter { - private config: IHierarchicalRateLimits; - private counters: Map = new Map(); - private patternCounters: Map = new Map(); - private ipCounters: Map = new Map(); - private domainCounters: Map = new Map(); - private cleanupInterval?: NodeJS.Timeout; - private stats: IRateLimiterStats; - - /** - * Create a new unified rate limiter - * @param config Rate limit configuration - */ - constructor(config: IHierarchicalRateLimits) { - super(); - - // Set default configuration - this.config = { - global: { - maxMessagesPerMinute: config.global.maxMessagesPerMinute || 100, - maxRecipientsPerMessage: config.global.maxRecipientsPerMessage || 100, - maxConnectionsPerIP: config.global.maxConnectionsPerIP || 20, - maxErrorsPerIP: config.global.maxErrorsPerIP || 10, - maxAuthFailuresPerIP: config.global.maxAuthFailuresPerIP || 5, - blockDuration: config.global.blockDuration || 3600000 // 1 hour - }, - patterns: config.patterns || {}, - ips: config.ips || {}, - blocks: config.blocks || {} - }; - - // Initialize statistics - this.stats = { - activeCounters: 0, - totalBlocked: 0, - currentlyBlocked: 0, - byPattern: {}, - byIp: {} - }; - - // Start cleanup interval - this.startCleanupInterval(); - } - - /** - * Start the cleanup interval - */ - private startCleanupInterval(): void { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - } - - // Run cleanup every minute - this.cleanupInterval = setInterval(() => this.cleanup(), 60000); - } - - /** - * Stop the cleanup interval - */ - public stop(): void { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - this.cleanupInterval = undefined; - } - } - - /** - * Destroy the rate limiter and clean up all resources - */ - public destroy(): void { - // Stop the cleanup interval - this.stop(); - - // Clear all maps to free memory - this.counters.clear(); - this.ipCounters.clear(); - this.patternCounters.clear(); - - // Clear blocks - if (this.config.blocks) { - this.config.blocks = {}; - } - - // Clear statistics - this.stats = { - activeCounters: 0, - totalBlocked: 0, - currentlyBlocked: 0, - byPattern: {}, - byIp: {} - }; - - logger.log('info', 'UnifiedRateLimiter destroyed'); - } - - /** - * Clean up expired counters and blocks - */ - private cleanup(): void { - const now = Date.now(); - - // Clean up expired blocks - if (this.config.blocks) { - for (const [ip, expiry] of Object.entries(this.config.blocks)) { - if (expiry <= now) { - delete this.config.blocks[ip]; - logger.log('info', `Rate limit block expired for IP ${ip}`); - - // Update statistics - if (this.stats.byIp[ip]) { - this.stats.byIp[ip].blocked = false; - } - this.stats.currentlyBlocked--; - } - } - } - - // Clean up old counters (older than 10 minutes) - const cutoff = now - 600000; - - // Clean global counters - for (const [key, counter] of this.counters.entries()) { - if (counter.lastReset < cutoff) { - this.counters.delete(key); - } - } - - // Clean pattern counters - for (const [key, counter] of this.patternCounters.entries()) { - if (counter.lastReset < cutoff) { - this.patternCounters.delete(key); - } - } - - // Clean IP counters - for (const [key, counter] of this.ipCounters.entries()) { - if (counter.lastReset < cutoff) { - this.ipCounters.delete(key); - } - } - - // Clean domain counters - for (const [key, counter] of this.domainCounters.entries()) { - if (counter.lastReset < cutoff) { - this.domainCounters.delete(key); - } - } - - // Update statistics - this.updateStats(); - } - - /** - * Check if a message is allowed by rate limits - * @param email Email address - * @param ip IP address - * @param recipients Number of recipients - * @param pattern Matched pattern - * @param domain Domain name for domain-specific limits - * @returns Result of rate limit check - */ - public checkMessageLimit(email: string, ip: string, recipients: number, pattern?: string, domain?: string): IRateLimitResult { - // Check if IP is blocked - if (this.isIpBlocked(ip)) { - return { - allowed: false, - reason: 'IP is blocked', - resetIn: this.getBlockReleaseTime(ip) - }; - } - - // Check global message rate limit - const globalResult = this.checkGlobalMessageLimit(email); - if (!globalResult.allowed) { - return globalResult; - } - - // Check pattern-specific limit if pattern is provided - if (pattern) { - const patternResult = this.checkPatternMessageLimit(pattern); - if (!patternResult.allowed) { - return patternResult; - } - } - - // Check domain-specific limit if domain is provided - if (domain) { - const domainResult = this.checkDomainMessageLimit(domain); - if (!domainResult.allowed) { - return domainResult; - } - } - - // Check IP-specific limit - const ipResult = this.checkIpMessageLimit(ip); - if (!ipResult.allowed) { - return ipResult; - } - - // Check recipient limit - const recipientResult = this.checkRecipientLimit(email, recipients, pattern, domain); - if (!recipientResult.allowed) { - return recipientResult; - } - - // All checks passed - return { allowed: true }; - } - - /** - * Check global message rate limit - * @param email Email address - */ - private checkGlobalMessageLimit(email: string): IRateLimitResult { - const now = Date.now(); - const limit = this.config.global.maxMessagesPerMinute!; - - if (!limit) { - return { allowed: true }; - } - - // Get or create counter - const key = 'global'; - let counter = this.counters.get(key); - - if (!counter) { - counter = { - count: 0, - lastReset: now, - recipients: 0, - errors: 0, - authFailures: 0, - connections: 0 - }; - this.counters.set(key, counter); - } - - // Check if counter needs to be reset - if (now - counter.lastReset >= 60000) { - counter.count = 0; - counter.lastReset = now; - } - - // Check if limit is exceeded - if (counter.count >= limit) { - // Calculate reset time - const resetIn = 60000 - (now - counter.lastReset); - - return { - allowed: false, - reason: 'Global message rate limit exceeded', - limit, - current: counter.count, - resetIn - }; - } - - // Increment counter - counter.count++; - - // Update statistics - this.updateStats(); - - return { allowed: true }; - } - - /** - * Check pattern-specific message rate limit - * @param pattern Pattern to check - */ - private checkPatternMessageLimit(pattern: string): IRateLimitResult { - const now = Date.now(); - - // Get pattern-specific limit or use global - const patternConfig = this.config.patterns?.[pattern]; - const limit = patternConfig?.maxMessagesPerMinute || this.config.global.maxMessagesPerMinute!; - - if (!limit) { - return { allowed: true }; - } - - // Get or create counter - let counter = this.patternCounters.get(pattern); - - if (!counter) { - counter = { - count: 0, - lastReset: now, - recipients: 0, - errors: 0, - authFailures: 0, - connections: 0 - }; - this.patternCounters.set(pattern, counter); - - // Initialize pattern stats if needed - if (!this.stats.byPattern[pattern]) { - this.stats.byPattern[pattern] = { - messagesPerMinute: 0, - totalMessages: 0, - totalBlocked: 0 - }; - } - } - - // Check if counter needs to be reset - if (now - counter.lastReset >= 60000) { - counter.count = 0; - counter.lastReset = now; - } - - // Check if limit is exceeded - if (counter.count >= limit) { - // Calculate reset time - const resetIn = 60000 - (now - counter.lastReset); - - // Update statistics - this.stats.byPattern[pattern].totalBlocked++; - this.stats.totalBlocked++; - - return { - allowed: false, - reason: `Pattern "${pattern}" message rate limit exceeded`, - limit, - current: counter.count, - resetIn - }; - } - - // Increment counter - counter.count++; - - // Update statistics - this.stats.byPattern[pattern].messagesPerMinute = counter.count; - this.stats.byPattern[pattern].totalMessages++; - - return { allowed: true }; - } - - /** - * Check domain-specific message rate limit - * @param domain Domain to check - */ - private checkDomainMessageLimit(domain: string): IRateLimitResult { - const now = Date.now(); - - // Get domain-specific limit or use global - const domainConfig = this.config.domains?.[domain]; - const limit = domainConfig?.maxMessagesPerMinute || this.config.global.maxMessagesPerMinute!; - - if (!limit) { - return { allowed: true }; - } - - // Get or create counter - let counter = this.domainCounters.get(domain); - - if (!counter) { - counter = { - count: 0, - lastReset: now, - recipients: 0, - errors: 0, - authFailures: 0, - connections: 0 - }; - this.domainCounters.set(domain, counter); - } - - // Check if counter needs to be reset - if (now - counter.lastReset >= 60000) { - counter.count = 0; - counter.lastReset = now; - } - - // Check if limit is exceeded - if (counter.count >= limit) { - // Calculate reset time - const resetIn = 60000 - (now - counter.lastReset); - - logger.log('warn', `Domain ${domain} rate limit exceeded: ${counter.count}/${limit} messages per minute`); - - return { - allowed: false, - reason: `Domain "${domain}" message rate limit exceeded`, - limit, - current: counter.count, - resetIn - }; - } - - // Increment counter - counter.count++; - - return { allowed: true }; - } - - /** - * Check IP-specific message rate limit - * @param ip IP address - */ - private checkIpMessageLimit(ip: string): IRateLimitResult { - const now = Date.now(); - - // Get IP-specific limit or use global - const ipConfig = this.config.ips?.[ip]; - const limit = ipConfig?.maxMessagesPerMinute || this.config.global.maxMessagesPerMinute!; - - if (!limit) { - return { allowed: true }; - } - - // Get or create counter - let counter = this.ipCounters.get(ip); - - if (!counter) { - counter = { - count: 0, - lastReset: now, - recipients: 0, - errors: 0, - authFailures: 0, - connections: 0 - }; - this.ipCounters.set(ip, counter); - - // Initialize IP stats if needed - if (!this.stats.byIp[ip]) { - this.stats.byIp[ip] = { - messagesPerMinute: 0, - totalMessages: 0, - totalBlocked: 0, - connections: 0, - errors: 0, - authFailures: 0, - blocked: false - }; - } - } - - // Check if counter needs to be reset - if (now - counter.lastReset >= 60000) { - counter.count = 0; - counter.lastReset = now; - } - - // Check if limit is exceeded - if (counter.count >= limit) { - // Calculate reset time - const resetIn = 60000 - (now - counter.lastReset); - - // Update statistics - this.stats.byIp[ip].totalBlocked++; - this.stats.totalBlocked++; - - return { - allowed: false, - reason: `IP ${ip} message rate limit exceeded`, - limit, - current: counter.count, - resetIn - }; - } - - // Increment counter - counter.count++; - - // Update statistics - this.stats.byIp[ip].messagesPerMinute = counter.count; - this.stats.byIp[ip].totalMessages++; - - return { allowed: true }; - } - - /** - * Check recipient limit - * @param email Email address - * @param recipients Number of recipients - * @param pattern Matched pattern - * @param domain Domain name - */ - private checkRecipientLimit(email: string, recipients: number, pattern?: string, domain?: string): IRateLimitResult { - // Get the most specific limit available - let limit = this.config.global.maxRecipientsPerMessage!; - - // Check pattern-specific limit - if (pattern && this.config.patterns?.[pattern]?.maxRecipientsPerMessage) { - limit = this.config.patterns[pattern].maxRecipientsPerMessage!; - } - - // Check domain-specific limit (overrides pattern if present) - if (domain && this.config.domains?.[domain]?.maxRecipientsPerMessage) { - limit = this.config.domains[domain].maxRecipientsPerMessage!; - } - - if (!limit) { - return { allowed: true }; - } - - // Check if limit is exceeded - if (recipients > limit) { - return { - allowed: false, - reason: 'Recipient limit exceeded', - limit, - current: recipients - }; - } - - return { allowed: true }; - } - - /** - * Record a connection from an IP - * @param ip IP address - * @returns Result of rate limit check - */ - public recordConnection(ip: string): IRateLimitResult { - const now = Date.now(); - - // Check if IP is blocked - if (this.isIpBlocked(ip)) { - return { - allowed: false, - reason: 'IP is blocked', - resetIn: this.getBlockReleaseTime(ip) - }; - } - - // Get IP-specific limit or use global - const ipConfig = this.config.ips?.[ip]; - const limit = ipConfig?.maxConnectionsPerIP || this.config.global.maxConnectionsPerIP!; - - if (!limit) { - return { allowed: true }; - } - - // Get or create counter - let counter = this.ipCounters.get(ip); - - if (!counter) { - counter = { - count: 0, - lastReset: now, - recipients: 0, - errors: 0, - authFailures: 0, - connections: 0 - }; - this.ipCounters.set(ip, counter); - - // Initialize IP stats if needed - if (!this.stats.byIp[ip]) { - this.stats.byIp[ip] = { - messagesPerMinute: 0, - totalMessages: 0, - totalBlocked: 0, - connections: 0, - errors: 0, - authFailures: 0, - blocked: false - }; - } - } - - // Check if counter needs to be reset - if (now - counter.lastReset >= 60000) { - counter.connections = 0; - counter.lastReset = now; - } - - // Check if limit is exceeded - if (counter.connections >= limit) { - // Calculate reset time - const resetIn = 60000 - (now - counter.lastReset); - - // Update statistics - this.stats.byIp[ip].totalBlocked++; - this.stats.totalBlocked++; - - return { - allowed: false, - reason: `IP ${ip} connection rate limit exceeded`, - limit, - current: counter.connections, - resetIn - }; - } - - // Increment counter - counter.connections++; - - // Update statistics - this.stats.byIp[ip].connections = counter.connections; - - return { allowed: true }; - } - - /** - * Record an error from an IP - * @param ip IP address - * @returns True if IP should be blocked - */ - public recordError(ip: string): boolean { - const now = Date.now(); - - // Get IP-specific limit or use global - const ipConfig = this.config.ips?.[ip]; - const limit = ipConfig?.maxErrorsPerIP || this.config.global.maxErrorsPerIP!; - - if (!limit) { - return false; - } - - // Get or create counter - let counter = this.ipCounters.get(ip); - - if (!counter) { - counter = { - count: 0, - lastReset: now, - recipients: 0, - errors: 0, - authFailures: 0, - connections: 0 - }; - this.ipCounters.set(ip, counter); - - // Initialize IP stats if needed - if (!this.stats.byIp[ip]) { - this.stats.byIp[ip] = { - messagesPerMinute: 0, - totalMessages: 0, - totalBlocked: 0, - connections: 0, - errors: 0, - authFailures: 0, - blocked: false - }; - } - } - - // Check if counter needs to be reset - if (now - counter.lastReset >= 60000) { - counter.errors = 0; - counter.lastReset = now; - } - - // Increment counter - counter.errors++; - - // Update statistics - this.stats.byIp[ip].errors = counter.errors; - - // Check if limit is exceeded - if (counter.errors >= limit) { - // Block the IP - this.blockIp(ip); - - logger.log('warn', `IP ${ip} blocked due to excessive errors (${counter.errors}/${limit})`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.WARN, - type: SecurityEventType.RATE_LIMITING, - message: 'IP blocked due to excessive errors', - ipAddress: ip, - details: { - errors: counter.errors, - limit - }, - success: false - }); - - return true; - } - - return false; - } - - /** - * Record an authentication failure from an IP - * @param ip IP address - * @returns True if IP should be blocked - */ - public recordAuthFailure(ip: string): boolean { - const now = Date.now(); - - // Get IP-specific limit or use global - const ipConfig = this.config.ips?.[ip]; - const limit = ipConfig?.maxAuthFailuresPerIP || this.config.global.maxAuthFailuresPerIP!; - - if (!limit) { - return false; - } - - // Get or create counter - let counter = this.ipCounters.get(ip); - - if (!counter) { - counter = { - count: 0, - lastReset: now, - recipients: 0, - errors: 0, - authFailures: 0, - connections: 0 - }; - this.ipCounters.set(ip, counter); - - // Initialize IP stats if needed - if (!this.stats.byIp[ip]) { - this.stats.byIp[ip] = { - messagesPerMinute: 0, - totalMessages: 0, - totalBlocked: 0, - connections: 0, - errors: 0, - authFailures: 0, - blocked: false - }; - } - } - - // Check if counter needs to be reset - if (now - counter.lastReset >= 60000) { - counter.authFailures = 0; - counter.lastReset = now; - } - - // Increment counter - counter.authFailures++; - - // Update statistics - this.stats.byIp[ip].authFailures = counter.authFailures; - - // Check if limit is exceeded - if (counter.authFailures >= limit) { - // Block the IP - this.blockIp(ip); - - logger.log('warn', `IP ${ip} blocked due to excessive authentication failures (${counter.authFailures}/${limit})`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.WARN, - type: SecurityEventType.AUTHENTICATION, - message: 'IP blocked due to excessive authentication failures', - ipAddress: ip, - details: { - authFailures: counter.authFailures, - limit - }, - success: false - }); - - return true; - } - - return false; - } - - /** - * Block an IP address - * @param ip IP address to block - * @param duration Override the default block duration (milliseconds) - */ - public blockIp(ip: string, duration?: number): void { - if (!this.config.blocks) { - this.config.blocks = {}; - } - - // Set block expiry time - const expiry = Date.now() + (duration || this.config.global.blockDuration || 3600000); - this.config.blocks[ip] = expiry; - - // Update statistics - if (!this.stats.byIp[ip]) { - this.stats.byIp[ip] = { - messagesPerMinute: 0, - totalMessages: 0, - totalBlocked: 0, - connections: 0, - errors: 0, - authFailures: 0, - blocked: false - }; - } - this.stats.byIp[ip].blocked = true; - this.stats.currentlyBlocked++; - - // Emit event - this.emit('ipBlocked', { - ip, - expiry, - duration: duration || this.config.global.blockDuration - }); - - logger.log('warn', `IP ${ip} blocked until ${new Date(expiry).toISOString()}`); - } - - /** - * Unblock an IP address - * @param ip IP address to unblock - */ - public unblockIp(ip: string): void { - if (!this.config.blocks) { - return; - } - - // Remove block - delete this.config.blocks[ip]; - - // Update statistics - if (this.stats.byIp[ip]) { - this.stats.byIp[ip].blocked = false; - this.stats.currentlyBlocked--; - } - - // Emit event - this.emit('ipUnblocked', { ip }); - - logger.log('info', `IP ${ip} unblocked`); - } - - /** - * Check if an IP is blocked - * @param ip IP address to check - */ - public isIpBlocked(ip: string): boolean { - if (!this.config.blocks) { - return false; - } - - // Check if IP is in blocks - if (!(ip in this.config.blocks)) { - return false; - } - - // Check if block has expired - const expiry = this.config.blocks[ip]; - if (expiry <= Date.now()) { - // Remove expired block - delete this.config.blocks[ip]; - - // Update statistics - if (this.stats.byIp[ip]) { - this.stats.byIp[ip].blocked = false; - this.stats.currentlyBlocked--; - } - - return false; - } - - return true; - } - - /** - * Get the time until a block is released - * @param ip IP address - * @returns Milliseconds until release or 0 if not blocked - */ - public getBlockReleaseTime(ip: string): number { - if (!this.config.blocks || !(ip in this.config.blocks)) { - return 0; - } - - const expiry = this.config.blocks[ip]; - const now = Date.now(); - - return expiry > now ? expiry - now : 0; - } - - /** - * Update rate limiter statistics - */ - private updateStats(): void { - // Update active counters count - this.stats.activeCounters = this.counters.size + this.patternCounters.size + this.ipCounters.size; - - // Emit statistics update - this.emit('statsUpdated', this.stats); - } - - /** - * Get rate limiter statistics - */ - public getStats(): IRateLimiterStats { - return { ...this.stats }; - } - - /** - * Update rate limiter configuration - * @param config New configuration - */ - public updateConfig(config: Partial): void { - if (config.global) { - this.config.global = { - ...this.config.global, - ...config.global - }; - } - - if (config.patterns) { - this.config.patterns = { - ...this.config.patterns, - ...config.patterns - }; - } - - if (config.ips) { - this.config.ips = { - ...this.config.ips, - ...config.ips - }; - } - - logger.log('info', 'Rate limiter configuration updated'); - } - - /** - * Get configuration for debugging - */ - public getConfig(): IHierarchicalRateLimits { - return { ...this.config }; - } - - /** - * Apply domain-specific rate limits - * Merges domain limits with existing configuration - * @param domain Domain name - * @param limits Rate limit configuration for the domain - */ - public applyDomainLimits(domain: string, limits: IRateLimitConfig): void { - if (!this.config.domains) { - this.config.domains = {}; - } - - // Merge the limits with any existing domain config - this.config.domains[domain] = { - ...this.config.domains[domain], - ...limits - }; - - logger.log('info', `Applied rate limits for domain ${domain}:`, limits); - } - - /** - * Remove domain-specific rate limits - * @param domain Domain name - */ - public removeDomainLimits(domain: string): void { - if (this.config.domains && this.config.domains[domain]) { - delete this.config.domains[domain]; - // Also remove the counter - this.domainCounters.delete(domain); - logger.log('info', `Removed rate limits for domain ${domain}`); - } - } - - /** - * Get domain-specific rate limits - * @param domain Domain name - * @returns Domain rate limit config or undefined - */ - public getDomainLimits(domain: string): IRateLimitConfig | undefined { - return this.config.domains?.[domain]; - } -} \ No newline at end of file diff --git a/ts/mail/delivery/index.ts b/ts/mail/delivery/index.ts deleted file mode 100644 index dc87825..0000000 --- a/ts/mail/delivery/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Email delivery components -export * from './classes.emailsignjob.js'; -export * from './classes.delivery.queue.js'; -export * from './classes.delivery.system.js'; - -// Handle exports with naming conflicts -export { EmailSendJob } from './classes.emailsendjob.js'; -export { DeliveryStatus } from './classes.delivery.system.js'; - -// Rate limiter exports - fix naming conflict -export { RateLimiter } from './classes.ratelimiter.js'; -export type { IRateLimitConfig } from './classes.ratelimiter.js'; - -// Unified rate limiter -export * from './classes.unified.rate.limiter.js'; - -// SMTP client and configuration -export * from './classes.mta.config.js'; - -// Import and export SMTP modules as namespaces to avoid conflicts -import * as smtpClientMod from './smtpclient/index.js'; -import * as smtpServerMod from './smtpserver/index.js'; - -export { smtpClientMod, smtpServerMod }; \ No newline at end of file diff --git a/ts/mail/delivery/interfaces.ts b/ts/mail/delivery/interfaces.ts deleted file mode 100644 index 8b00a6c..0000000 --- a/ts/mail/delivery/interfaces.ts +++ /dev/null @@ -1,291 +0,0 @@ -/** - * SMTP and email delivery interface definitions - */ - -import type { Email } from '../core/classes.email.js'; - -/** - * SMTP session state enumeration - */ -export enum SmtpState { - GREETING = 'GREETING', - AFTER_EHLO = 'AFTER_EHLO', - MAIL_FROM = 'MAIL_FROM', - RCPT_TO = 'RCPT_TO', - DATA = 'DATA', - DATA_RECEIVING = 'DATA_RECEIVING', - FINISHED = 'FINISHED' -} - -/** - * Email processing mode type - */ -export type EmailProcessingMode = 'forward' | 'mta' | 'process'; - -/** - * Envelope recipient information - */ -export interface IEnvelopeRecipient { - /** - * Email address of the recipient - */ - address: string; - - /** - * Additional SMTP command arguments - */ - args: Record; -} - -/** - * SMTP session envelope information - */ -export interface ISmtpEnvelope { - /** - * Envelope sender (MAIL FROM) information - */ - mailFrom: { - /** - * Email address of the sender - */ - address: string; - - /** - * Additional SMTP command arguments - */ - args: Record; - }; - - /** - * Envelope recipients (RCPT TO) information - */ - rcptTo: IEnvelopeRecipient[]; -} - -/** - * SMTP Session interface - represents an active SMTP connection - */ -export interface ISmtpSession { - /** - * Unique session identifier - */ - id: string; - - /** - * Current session state in the SMTP conversation - */ - state: SmtpState; - - /** - * Hostname provided by the client in EHLO/HELO command - */ - clientHostname: string; - - /** - * MAIL FROM email address (legacy format) - */ - mailFrom: string; - - /** - * RCPT TO email addresses (legacy format) - */ - rcptTo: string[]; - - /** - * Raw email data being received - */ - emailData: string; - - /** - * Chunks of email data for more efficient buffer management - */ - emailDataChunks?: string[]; - - /** - * Whether the connection is using TLS - */ - useTLS: boolean; - - /** - * Whether the connection has ended - */ - connectionEnded: boolean; - - /** - * Remote IP address of the client - */ - remoteAddress: string; - - /** - * Whether the connection is secure (TLS) - */ - secure: boolean; - - /** - * Whether the client has been authenticated - */ - authenticated: boolean; - - /** - * SMTP envelope information (structured format) - */ - envelope: ISmtpEnvelope; - - /** - * Email processing mode to use for this session - */ - processingMode?: EmailProcessingMode; - - /** - * Timestamp of last activity for session timeout tracking - */ - lastActivity?: number; - - /** - * Timeout ID for DATA command timeout - */ - dataTimeoutId?: NodeJS.Timeout; -} - -/** - * SMTP authentication data - */ -export interface ISmtpAuth { - /** - * Authentication method used - */ - method: 'PLAIN' | 'LOGIN' | 'OAUTH2' | string; - - /** - * Username for authentication - */ - username: string; - - /** - * Password or token for authentication - */ - password: string; -} - -/** - * SMTP server options - */ -export interface ISmtpServerOptions { - /** - * Port to listen on - */ - port: number; - - /** - * TLS private key (PEM format) - */ - key: string; - - /** - * TLS certificate (PEM format) - */ - cert: string; - - /** - * Server hostname for SMTP banner - */ - hostname?: string; - - /** - * Host address to bind to (defaults to all interfaces) - */ - host?: string; - - /** - * Secure port for dedicated TLS connections - */ - securePort?: number; - - /** - * CA certificates for TLS (PEM format) - */ - ca?: string; - - /** - * Maximum size of messages in bytes - */ - maxSize?: number; - - /** - * Maximum number of concurrent connections - */ - maxConnections?: number; - - /** - * Authentication options - */ - auth?: { - /** - * Whether authentication is required - */ - required: boolean; - - /** - * Allowed authentication methods - */ - methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[]; - }; - - /** - * Socket timeout in milliseconds (default: 5 minutes / 300000ms) - */ - socketTimeout?: number; - - /** - * Initial connection timeout in milliseconds (default: 30 seconds / 30000ms) - */ - connectionTimeout?: number; - - /** - * Interval for checking idle sessions in milliseconds (default: 5 seconds / 5000ms) - * For testing, can be set lower (e.g. 1000ms) to detect timeouts more quickly - */ - cleanupInterval?: number; - - /** - * Maximum number of recipients allowed per message (default: 100) - */ - maxRecipients?: number; - - /** - * Maximum message size in bytes (default: 10MB / 10485760 bytes) - * This is advertised in the EHLO SIZE extension - */ - size?: number; - - /** - * Timeout for the DATA command in milliseconds (default: 60000ms / 1 minute) - * This controls how long to wait for the complete email data - */ - dataTimeout?: number; -} - -/** - * Result of SMTP transaction - */ -export interface ISmtpTransactionResult { - /** - * Whether the transaction was successful - */ - success: boolean; - - /** - * Error message if failed - */ - error?: string; - - /** - * Message ID if successful - */ - messageId?: string; - - /** - * Resulting email if successful - */ - email?: Email; -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpclient/auth-handler.ts b/ts/mail/delivery/smtpclient/auth-handler.ts deleted file mode 100644 index ee64f6f..0000000 --- a/ts/mail/delivery/smtpclient/auth-handler.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * SMTP Client Authentication Handler - * Authentication mechanisms implementation - */ - -import { AUTH_METHODS } from './constants.js'; -import type { - ISmtpConnection, - ISmtpAuthOptions, - ISmtpClientOptions, - ISmtpResponse, - IOAuth2Options -} from './interfaces.js'; -import { - encodeAuthPlain, - encodeAuthLogin, - generateOAuth2String, - isSuccessCode -} from './utils/helpers.js'; -import { logAuthentication, logDebug } from './utils/logging.js'; -import type { CommandHandler } from './command-handler.js'; - -export class AuthHandler { - private options: ISmtpClientOptions; - private commandHandler: CommandHandler; - - constructor(options: ISmtpClientOptions, commandHandler: CommandHandler) { - this.options = options; - this.commandHandler = commandHandler; - } - - /** - * Authenticate using the configured method - */ - public async authenticate(connection: ISmtpConnection): Promise { - if (!this.options.auth) { - logDebug('No authentication configured', this.options); - return; - } - - const authOptions = this.options.auth; - const capabilities = connection.capabilities; - - if (!capabilities || capabilities.authMethods.size === 0) { - throw new Error('Server does not support authentication'); - } - - // Determine authentication method - const method = this.selectAuthMethod(authOptions, capabilities.authMethods); - - logAuthentication('start', method, this.options); - - try { - switch (method) { - case AUTH_METHODS.PLAIN: - await this.authenticatePlain(connection, authOptions); - break; - case AUTH_METHODS.LOGIN: - await this.authenticateLogin(connection, authOptions); - break; - case AUTH_METHODS.OAUTH2: - await this.authenticateOAuth2(connection, authOptions); - break; - default: - throw new Error(`Unsupported authentication method: ${method}`); - } - - logAuthentication('success', method, this.options); - } catch (error) { - logAuthentication('failure', method, this.options, { error }); - throw error; - } - } - - /** - * Authenticate using AUTH PLAIN - */ - private async authenticatePlain(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise { - if (!auth.user || !auth.pass) { - throw new Error('Username and password required for PLAIN authentication'); - } - - const credentials = encodeAuthPlain(auth.user, auth.pass); - const response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.PLAIN, credentials); - - if (!isSuccessCode(response.code)) { - throw new Error(`PLAIN authentication failed: ${response.message}`); - } - } - - /** - * Authenticate using AUTH LOGIN - */ - private async authenticateLogin(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise { - if (!auth.user || !auth.pass) { - throw new Error('Username and password required for LOGIN authentication'); - } - - // Step 1: Send AUTH LOGIN - let response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.LOGIN); - - if (response.code !== 334) { - throw new Error(`LOGIN authentication initiation failed: ${response.message}`); - } - - // Step 2: Send username - const encodedUser = encodeAuthLogin(auth.user); - response = await this.commandHandler.sendCommand(connection, encodedUser); - - if (response.code !== 334) { - throw new Error(`LOGIN username failed: ${response.message}`); - } - - // Step 3: Send password - const encodedPass = encodeAuthLogin(auth.pass); - response = await this.commandHandler.sendCommand(connection, encodedPass); - - if (!isSuccessCode(response.code)) { - throw new Error(`LOGIN password failed: ${response.message}`); - } - } - - /** - * Authenticate using OAuth2 - */ - private async authenticateOAuth2(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise { - if (!auth.oauth2) { - throw new Error('OAuth2 configuration required for OAUTH2 authentication'); - } - - let accessToken = auth.oauth2.accessToken; - - // Refresh token if needed - if (!accessToken || this.isTokenExpired(auth.oauth2)) { - accessToken = await this.refreshOAuth2Token(auth.oauth2); - } - - const authString = generateOAuth2String(auth.oauth2.user, accessToken); - const response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.OAUTH2, authString); - - if (!isSuccessCode(response.code)) { - throw new Error(`OAUTH2 authentication failed: ${response.message}`); - } - } - - /** - * Select appropriate authentication method - */ - private selectAuthMethod(auth: ISmtpAuthOptions, serverMethods: Set): string { - // If method is explicitly specified, use it - if (auth.method && auth.method !== 'AUTO') { - const method = auth.method === 'OAUTH2' ? AUTH_METHODS.OAUTH2 : auth.method; - if (serverMethods.has(method)) { - return method; - } - throw new Error(`Requested authentication method ${auth.method} not supported by server`); - } - - // Auto-select based on available credentials and server support - if (auth.oauth2 && serverMethods.has(AUTH_METHODS.OAUTH2)) { - return AUTH_METHODS.OAUTH2; - } - - if (auth.user && auth.pass) { - // Prefer PLAIN over LOGIN for simplicity - if (serverMethods.has(AUTH_METHODS.PLAIN)) { - return AUTH_METHODS.PLAIN; - } - if (serverMethods.has(AUTH_METHODS.LOGIN)) { - return AUTH_METHODS.LOGIN; - } - } - - throw new Error('No compatible authentication method found'); - } - - /** - * Check if OAuth2 token is expired - */ - private isTokenExpired(oauth2: IOAuth2Options): boolean { - if (!oauth2.expires) { - return false; // No expiry information, assume valid - } - - const now = Date.now(); - const buffer = 300000; // 5 minutes buffer - - return oauth2.expires < (now + buffer); - } - - /** - * Refresh OAuth2 access token - */ - private async refreshOAuth2Token(oauth2: IOAuth2Options): Promise { - // This is a simplified implementation - // In a real implementation, you would make an HTTP request to the OAuth2 provider - logDebug('OAuth2 token refresh required', this.options); - - if (!oauth2.refreshToken) { - throw new Error('Refresh token required for OAuth2 token refresh'); - } - - // TODO: Implement actual OAuth2 token refresh - // For now, throw an error to indicate this needs to be implemented - throw new Error('OAuth2 token refresh not implemented. Please provide a valid access token.'); - } - - /** - * Validate authentication configuration - */ - public validateAuthConfig(auth: ISmtpAuthOptions): string[] { - const errors: string[] = []; - - if (auth.method === 'OAUTH2' || auth.oauth2) { - if (!auth.oauth2) { - errors.push('OAuth2 configuration required when using OAUTH2 method'); - } else { - if (!auth.oauth2.user) errors.push('OAuth2 user required'); - if (!auth.oauth2.clientId) errors.push('OAuth2 clientId required'); - if (!auth.oauth2.clientSecret) errors.push('OAuth2 clientSecret required'); - if (!auth.oauth2.refreshToken && !auth.oauth2.accessToken) { - errors.push('OAuth2 refreshToken or accessToken required'); - } - } - } else if (auth.method === 'PLAIN' || auth.method === 'LOGIN' || (!auth.method && (auth.user || auth.pass))) { - if (!auth.user) errors.push('Username required for basic authentication'); - if (!auth.pass) errors.push('Password required for basic authentication'); - } - - return errors; - } -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpclient/command-handler.ts b/ts/mail/delivery/smtpclient/command-handler.ts deleted file mode 100644 index fd7ff66..0000000 --- a/ts/mail/delivery/smtpclient/command-handler.ts +++ /dev/null @@ -1,422 +0,0 @@ -/** - * SMTP Client Command Handler - * SMTP command sending and response parsing - */ - -import { EventEmitter } from 'node:events'; -import { SMTP_COMMANDS, SMTP_CODES, LINE_ENDINGS } from './constants.js'; -import type { - ISmtpConnection, - ISmtpResponse, - ISmtpClientOptions, - ISmtpCapabilities -} from './interfaces.js'; -import { - parseSmtpResponse, - parseEhloResponse, - formatCommand, - isSuccessCode -} from './utils/helpers.js'; -import { logCommand, logDebug } from './utils/logging.js'; - -export class CommandHandler extends EventEmitter { - private options: ISmtpClientOptions; - private responseBuffer: string = ''; - private pendingCommand: { resolve: Function; reject: Function; command: string } | null = null; - private commandTimeout: NodeJS.Timeout | null = null; - - // Maximum buffer size to prevent memory exhaustion from rogue servers - private static readonly MAX_BUFFER_SIZE = 1024 * 1024; // 1MB max - - constructor(options: ISmtpClientOptions) { - super(); - this.options = options; - } - - /** - * Send EHLO command and parse capabilities - */ - public async sendEhlo(connection: ISmtpConnection, domain?: string): Promise { - const hostname = domain || this.options.domain || 'localhost'; - const command = `${SMTP_COMMANDS.EHLO} ${hostname}`; - - const response = await this.sendCommand(connection, command); - - if (!isSuccessCode(response.code)) { - throw new Error(`EHLO failed: ${response.message}`); - } - - const capabilities = parseEhloResponse(response.raw); - connection.capabilities = capabilities; - - logDebug('EHLO capabilities parsed', this.options, { capabilities }); - return capabilities; - } - - /** - * Send MAIL FROM command - */ - public async sendMailFrom(connection: ISmtpConnection, fromAddress: string): Promise { - // Handle empty return path for bounce messages - const command = fromAddress === '' - ? `${SMTP_COMMANDS.MAIL_FROM}:<>` - : `${SMTP_COMMANDS.MAIL_FROM}:<${fromAddress}>`; - return this.sendCommand(connection, command); - } - - /** - * Send RCPT TO command - */ - public async sendRcptTo(connection: ISmtpConnection, toAddress: string): Promise { - const command = `${SMTP_COMMANDS.RCPT_TO}:<${toAddress}>`; - return this.sendCommand(connection, command); - } - - /** - * Send DATA command - */ - public async sendData(connection: ISmtpConnection): Promise { - return this.sendCommand(connection, SMTP_COMMANDS.DATA); - } - - /** - * Send email data content - */ - public async sendDataContent(connection: ISmtpConnection, emailData: string): Promise { - // Normalize line endings to CRLF - let data = emailData.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n/g, '\r\n'); - - // Ensure email data ends with CRLF - if (!data.endsWith(LINE_ENDINGS.CRLF)) { - data += LINE_ENDINGS.CRLF; - } - - // Perform dot stuffing (escape lines starting with a dot) - data = data.replace(/\r\n\./g, '\r\n..'); - - // Add termination sequence - data += '.' + LINE_ENDINGS.CRLF; - - return this.sendRawData(connection, data); - } - - /** - * Send RSET command - */ - public async sendRset(connection: ISmtpConnection): Promise { - return this.sendCommand(connection, SMTP_COMMANDS.RSET); - } - - /** - * Send NOOP command - */ - public async sendNoop(connection: ISmtpConnection): Promise { - return this.sendCommand(connection, SMTP_COMMANDS.NOOP); - } - - /** - * Send QUIT command - */ - public async sendQuit(connection: ISmtpConnection): Promise { - return this.sendCommand(connection, SMTP_COMMANDS.QUIT); - } - - /** - * Send STARTTLS command - */ - public async sendStartTls(connection: ISmtpConnection): Promise { - return this.sendCommand(connection, SMTP_COMMANDS.STARTTLS); - } - - /** - * Send AUTH command - */ - public async sendAuth(connection: ISmtpConnection, method: string, credentials?: string): Promise { - const command = credentials ? - `${SMTP_COMMANDS.AUTH} ${method} ${credentials}` : - `${SMTP_COMMANDS.AUTH} ${method}`; - return this.sendCommand(connection, command); - } - - /** - * Send a generic SMTP command - */ - public async sendCommand(connection: ISmtpConnection, command: string): Promise { - return new Promise((resolve, reject) => { - if (this.pendingCommand) { - reject(new Error('Another command is already pending')); - return; - } - - this.pendingCommand = { resolve, reject, command }; - - // Set up data handler - const dataHandler = (data: Buffer) => { - this.handleIncomingData(data.toString()); - }; - - // Set up socket close/error handlers to reject pending promises - const closeHandler = () => { - if (this.pendingCommand) { - this.pendingCommand.reject(new Error('Socket closed during command')); - } - }; - - const errorHandler = (err: Error) => { - if (this.pendingCommand) { - this.pendingCommand.reject(err); - } - }; - - connection.socket.on('data', dataHandler); - connection.socket.once('close', closeHandler); - connection.socket.once('error', errorHandler); - - // Clean up function - removes all listeners and clears buffer - const cleanup = () => { - connection.socket.removeListener('data', dataHandler); - connection.socket.removeListener('close', closeHandler); - connection.socket.removeListener('error', errorHandler); - if (this.commandTimeout) { - clearTimeout(this.commandTimeout); - this.commandTimeout = null; - } - // Clear response buffer to prevent corrupted data for next command - this.responseBuffer = ''; - }; - - // Override resolve/reject to include cleanup BEFORE setting timeout - const originalResolve = resolve; - const originalReject = reject; - - this.pendingCommand.resolve = (response: ISmtpResponse) => { - cleanup(); - this.pendingCommand = null; - logCommand(command, response, this.options); - originalResolve(response); - }; - - this.pendingCommand.reject = (error: Error) => { - cleanup(); - this.pendingCommand = null; - originalReject(error); - }; - - // Set command timeout - uses wrapped reject that includes cleanup - const timeout = 30000; // 30 seconds - this.commandTimeout = setTimeout(() => { - if (this.pendingCommand) { - this.pendingCommand.reject(new Error(`Command timeout: ${command}`)); - } - }, timeout); - - // Send command - const formattedCommand = command.endsWith(LINE_ENDINGS.CRLF) ? command : formatCommand(command); - - logCommand(command, undefined, this.options); - logDebug(`Sending command: ${command}`, this.options); - - connection.socket.write(formattedCommand, (error) => { - if (error) { - if (this.pendingCommand) { - this.pendingCommand.reject(error); - } - } - }); - }); - } - - /** - * Send raw data without command formatting - */ - public async sendRawData(connection: ISmtpConnection, data: string): Promise { - return new Promise((resolve, reject) => { - if (this.pendingCommand) { - reject(new Error('Another command is already pending')); - return; - } - - this.pendingCommand = { resolve, reject, command: 'DATA_CONTENT' }; - - // Set up data handler - const dataHandler = (chunk: Buffer) => { - this.handleIncomingData(chunk.toString()); - }; - - // Set up socket close/error handlers to reject pending promises - const closeHandler = () => { - if (this.pendingCommand) { - this.pendingCommand.reject(new Error('Socket closed during data transmission')); - } - }; - - const errorHandler = (err: Error) => { - if (this.pendingCommand) { - this.pendingCommand.reject(err); - } - }; - - connection.socket.on('data', dataHandler); - connection.socket.once('close', closeHandler); - connection.socket.once('error', errorHandler); - - // Clean up function - removes all listeners and clears buffer - const cleanup = () => { - connection.socket.removeListener('data', dataHandler); - connection.socket.removeListener('close', closeHandler); - connection.socket.removeListener('error', errorHandler); - if (this.commandTimeout) { - clearTimeout(this.commandTimeout); - this.commandTimeout = null; - } - // Clear response buffer to prevent corrupted data for next command - this.responseBuffer = ''; - }; - - // Override resolve/reject to include cleanup BEFORE setting timeout - const originalResolve = resolve; - const originalReject = reject; - - this.pendingCommand.resolve = (response: ISmtpResponse) => { - cleanup(); - this.pendingCommand = null; - originalResolve(response); - }; - - this.pendingCommand.reject = (error: Error) => { - cleanup(); - this.pendingCommand = null; - originalReject(error); - }; - - // Set data timeout - uses wrapped reject that includes cleanup - const timeout = 60000; // 60 seconds for data - this.commandTimeout = setTimeout(() => { - if (this.pendingCommand) { - this.pendingCommand.reject(new Error('Data transmission timeout')); - } - }, timeout); - - // Send data - connection.socket.write(data, (error) => { - if (error) { - if (this.pendingCommand) { - this.pendingCommand.reject(error); - } - } - }); - }); - } - - /** - * Wait for server greeting - */ - public async waitForGreeting(connection: ISmtpConnection): Promise { - return new Promise((resolve, reject) => { - const timeout = 30000; // 30 seconds - let timeoutHandler: NodeJS.Timeout; - let resolved = false; - - const cleanup = () => { - if (resolved) return; - resolved = true; - clearTimeout(timeoutHandler); - connection.socket.removeListener('data', dataHandler); - connection.socket.removeListener('close', closeHandler); - connection.socket.removeListener('error', errorHandler); - this.responseBuffer = ''; - }; - - const dataHandler = (data: Buffer) => { - if (resolved) return; - - // Check buffer size - if (this.responseBuffer.length + data.length > CommandHandler.MAX_BUFFER_SIZE) { - cleanup(); - reject(new Error('Greeting response too large')); - return; - } - - this.responseBuffer += data.toString(); - - if (this.isCompleteResponse(this.responseBuffer)) { - const response = parseSmtpResponse(this.responseBuffer); - cleanup(); - - if (isSuccessCode(response.code)) { - resolve(response); - } else { - reject(new Error(`Server greeting failed: ${response.message}`)); - } - } - }; - - const closeHandler = () => { - if (resolved) return; - cleanup(); - reject(new Error('Socket closed while waiting for greeting')); - }; - - const errorHandler = (err: Error) => { - if (resolved) return; - cleanup(); - reject(err); - }; - - timeoutHandler = setTimeout(() => { - if (resolved) return; - cleanup(); - reject(new Error('Greeting timeout')); - }, timeout); - - connection.socket.on('data', dataHandler); - connection.socket.once('close', closeHandler); - connection.socket.once('error', errorHandler); - }); - } - - private handleIncomingData(data: string): void { - if (!this.pendingCommand) { - return; - } - - // Check buffer size to prevent memory exhaustion from rogue servers - if (this.responseBuffer.length + data.length > CommandHandler.MAX_BUFFER_SIZE) { - this.pendingCommand.reject(new Error('Response too large')); - return; - } - - this.responseBuffer += data; - - if (this.isCompleteResponse(this.responseBuffer)) { - const response = parseSmtpResponse(this.responseBuffer); - this.responseBuffer = ''; - - if (isSuccessCode(response.code) || (response.code >= 300 && response.code < 400) || response.code >= 400) { - this.pendingCommand.resolve(response); - } else { - this.pendingCommand.reject(new Error(`Command failed: ${response.message}`)); - } - } - } - - private isCompleteResponse(buffer: string): boolean { - // Check if we have a complete response - const lines = buffer.split(/\r?\n/); - - if (lines.length < 1) { - return false; - } - - // Check the last non-empty line - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i].trim(); - if (line.length > 0) { - // Response is complete if line starts with "XXX " (space after code) - return /^\d{3} /.test(line); - } - } - - return false; - } -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpclient/connection-manager.ts b/ts/mail/delivery/smtpclient/connection-manager.ts deleted file mode 100644 index 9b9dd59..0000000 --- a/ts/mail/delivery/smtpclient/connection-manager.ts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * SMTP Client Connection Manager - * Connection pooling and lifecycle management - */ - -import * as net from 'node:net'; -import * as tls from 'node:tls'; -import { EventEmitter } from 'node:events'; -import { DEFAULTS, CONNECTION_STATES } from './constants.js'; -import type { - ISmtpClientOptions, - ISmtpConnection, - IConnectionPoolStatus, - ConnectionState -} from './interfaces.js'; -import { logConnection, logDebug } from './utils/logging.js'; -import { generateConnectionId } from './utils/helpers.js'; - -export class ConnectionManager extends EventEmitter { - private options: ISmtpClientOptions; - private connections: Map = new Map(); - private pendingConnections: Set = new Set(); - private idleTimeout: NodeJS.Timeout | null = null; - - constructor(options: ISmtpClientOptions) { - super(); - this.options = options; - this.setupIdleCleanup(); - } - - /** - * Get or create a connection - */ - public async getConnection(): Promise { - // Try to reuse an idle connection if pooling is enabled - if (this.options.pool) { - const idleConnection = this.findIdleConnection(); - if (idleConnection) { - const connectionId = this.getConnectionId(idleConnection) || 'unknown'; - logDebug('Reusing idle connection', this.options, { connectionId }); - return idleConnection; - } - - // Check if we can create a new connection - if (this.getActiveConnectionCount() >= (this.options.maxConnections || DEFAULTS.MAX_CONNECTIONS)) { - throw new Error('Maximum number of connections reached'); - } - } - - return this.createConnection(); - } - - /** - * Create a new connection - */ - public async createConnection(): Promise { - const connectionId = generateConnectionId(); - - try { - this.pendingConnections.add(connectionId); - logConnection('connecting', this.options, { connectionId }); - - const socket = await this.establishSocket(); - const connection: ISmtpConnection = { - socket, - state: CONNECTION_STATES.CONNECTED as ConnectionState, - options: this.options, - secure: this.options.secure || false, - createdAt: new Date(), - lastActivity: new Date(), - messageCount: 0 - }; - - this.setupSocketHandlers(socket, connectionId); - this.connections.set(connectionId, connection); - this.pendingConnections.delete(connectionId); - - logConnection('connected', this.options, { connectionId }); - this.emit('connection', connection); - - return connection; - } catch (error) { - this.pendingConnections.delete(connectionId); - logConnection('error', this.options, { connectionId, error }); - throw error; - } - } - - /** - * Release a connection back to the pool or close it - */ - public releaseConnection(connection: ISmtpConnection): void { - const connectionId = this.getConnectionId(connection); - - if (!connectionId || !this.connections.has(connectionId)) { - return; - } - - if (this.options.pool && this.shouldReuseConnection(connection)) { - // Return to pool - connection.state = CONNECTION_STATES.READY as ConnectionState; - connection.lastActivity = new Date(); - logDebug('Connection returned to pool', this.options, { connectionId }); - } else { - // Close connection - this.closeConnection(connection); - } - } - - /** - * Close a specific connection - */ - public closeConnection(connection: ISmtpConnection): void { - const connectionId = this.getConnectionId(connection); - - if (connectionId) { - this.connections.delete(connectionId); - } - - connection.state = CONNECTION_STATES.CLOSING as ConnectionState; - - try { - if (!connection.socket.destroyed) { - connection.socket.destroy(); - } - } catch (error) { - logDebug('Error closing connection', this.options, { error }); - } - - logConnection('disconnected', this.options, { connectionId }); - this.emit('disconnect', connection); - } - - /** - * Close all connections - */ - public closeAllConnections(): void { - logDebug('Closing all connections', this.options); - - for (const connection of this.connections.values()) { - this.closeConnection(connection); - } - - this.connections.clear(); - this.pendingConnections.clear(); - - if (this.idleTimeout) { - clearInterval(this.idleTimeout); - this.idleTimeout = null; - } - } - - /** - * Get connection pool status - */ - public getPoolStatus(): IConnectionPoolStatus { - const total = this.connections.size; - const active = Array.from(this.connections.values()) - .filter(conn => conn.state === CONNECTION_STATES.BUSY).length; - const idle = total - active; - const pending = this.pendingConnections.size; - - return { total, active, idle, pending }; - } - - /** - * Update connection activity timestamp - */ - public updateActivity(connection: ISmtpConnection): void { - connection.lastActivity = new Date(); - } - - private async establishSocket(): Promise { - return new Promise((resolve, reject) => { - const timeout = this.options.connectionTimeout || DEFAULTS.CONNECTION_TIMEOUT; - let socket: net.Socket | tls.TLSSocket; - - if (this.options.secure) { - // Direct TLS connection - socket = tls.connect({ - host: this.options.host, - port: this.options.port, - ...this.options.tls - }); - } else { - // Plain connection - socket = new net.Socket(); - socket.connect(this.options.port, this.options.host); - } - - const timeoutHandler = setTimeout(() => { - socket.destroy(); - reject(new Error(`Connection timeout after ${timeout}ms`)); - }, timeout); - - // For TLS connections, we need to wait for 'secureConnect' instead of 'connect' - const successEvent = this.options.secure ? 'secureConnect' : 'connect'; - - socket.once(successEvent, () => { - clearTimeout(timeoutHandler); - resolve(socket); - }); - - socket.once('error', (error) => { - clearTimeout(timeoutHandler); - reject(error); - }); - }); - } - - private setupSocketHandlers(socket: net.Socket | tls.TLSSocket, connectionId: string): void { - const socketTimeout = this.options.socketTimeout || DEFAULTS.SOCKET_TIMEOUT; - - socket.setTimeout(socketTimeout); - - socket.on('timeout', () => { - logDebug('Socket timeout', this.options, { connectionId }); - socket.destroy(); - }); - - socket.on('error', (error) => { - logConnection('error', this.options, { connectionId, error }); - this.connections.delete(connectionId); - }); - - socket.on('close', () => { - this.connections.delete(connectionId); - logDebug('Socket closed', this.options, { connectionId }); - }); - } - - private findIdleConnection(): ISmtpConnection | null { - for (const connection of this.connections.values()) { - if (connection.state === CONNECTION_STATES.READY) { - return connection; - } - } - return null; - } - - private shouldReuseConnection(connection: ISmtpConnection): boolean { - const maxMessages = this.options.maxMessages || DEFAULTS.MAX_MESSAGES; - const maxAge = 300000; // 5 minutes - const age = Date.now() - connection.createdAt.getTime(); - - return connection.messageCount < maxMessages && - age < maxAge && - !connection.socket.destroyed; - } - - private getActiveConnectionCount(): number { - return this.connections.size + this.pendingConnections.size; - } - - private getConnectionId(connection: ISmtpConnection): string | null { - for (const [id, conn] of this.connections.entries()) { - if (conn === connection) { - return id; - } - } - return null; - } - - private setupIdleCleanup(): void { - if (!this.options.pool) { - return; - } - - const cleanupInterval = DEFAULTS.POOL_IDLE_TIMEOUT; - - this.idleTimeout = setInterval(() => { - const now = Date.now(); - const connectionsToClose: ISmtpConnection[] = []; - - for (const connection of this.connections.values()) { - const idleTime = now - connection.lastActivity.getTime(); - - if (connection.state === CONNECTION_STATES.READY && idleTime > cleanupInterval) { - connectionsToClose.push(connection); - } - } - - for (const connection of connectionsToClose) { - logDebug('Closing idle connection', this.options); - this.closeConnection(connection); - } - }, cleanupInterval); - } -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpclient/constants.ts b/ts/mail/delivery/smtpclient/constants.ts deleted file mode 100644 index 997c734..0000000 --- a/ts/mail/delivery/smtpclient/constants.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * SMTP Client Constants and Error Codes - * All constants, error codes, and enums for SMTP client operations - */ - -/** - * SMTP response codes - */ -export const SMTP_CODES = { - // Positive completion replies - SERVICE_READY: 220, - SERVICE_CLOSING: 221, - AUTHENTICATION_SUCCESSFUL: 235, - REQUESTED_ACTION_OK: 250, - USER_NOT_LOCAL: 251, - CANNOT_VERIFY_USER: 252, - - // Positive intermediate replies - START_MAIL_INPUT: 354, - - // Transient negative completion replies - SERVICE_NOT_AVAILABLE: 421, - MAILBOX_BUSY: 450, - LOCAL_ERROR: 451, - INSUFFICIENT_STORAGE: 452, - UNABLE_TO_ACCOMMODATE: 455, - - // Permanent negative completion replies - SYNTAX_ERROR: 500, - SYNTAX_ERROR_PARAMETERS: 501, - COMMAND_NOT_IMPLEMENTED: 502, - BAD_SEQUENCE: 503, - PARAMETER_NOT_IMPLEMENTED: 504, - MAILBOX_UNAVAILABLE: 550, - USER_NOT_LOCAL_TRY_FORWARD: 551, - EXCEEDED_STORAGE: 552, - MAILBOX_NAME_NOT_ALLOWED: 553, - TRANSACTION_FAILED: 554 -} as const; - -/** - * SMTP command names - */ -export const SMTP_COMMANDS = { - HELO: 'HELO', - EHLO: 'EHLO', - MAIL_FROM: 'MAIL FROM', - RCPT_TO: 'RCPT TO', - DATA: 'DATA', - RSET: 'RSET', - NOOP: 'NOOP', - QUIT: 'QUIT', - STARTTLS: 'STARTTLS', - AUTH: 'AUTH' -} as const; - -/** - * Authentication methods - */ -export const AUTH_METHODS = { - PLAIN: 'PLAIN', - LOGIN: 'LOGIN', - OAUTH2: 'XOAUTH2', - CRAM_MD5: 'CRAM-MD5' -} as const; - -/** - * Common SMTP extensions - */ -export const SMTP_EXTENSIONS = { - PIPELINING: 'PIPELINING', - SIZE: 'SIZE', - STARTTLS: 'STARTTLS', - AUTH: 'AUTH', - EIGHT_BIT_MIME: '8BITMIME', - CHUNKING: 'CHUNKING', - ENHANCED_STATUS_CODES: 'ENHANCEDSTATUSCODES', - DSN: 'DSN' -} as const; - -/** - * Default configuration values - */ -export const DEFAULTS = { - CONNECTION_TIMEOUT: 60000, // 60 seconds - SOCKET_TIMEOUT: 45000, // 45 seconds (slightly longer than command timeout to allow cleanup) - COMMAND_TIMEOUT: 30000, // 30 seconds - MAX_CONNECTIONS: 5, - MAX_MESSAGES: 100, - PORT_SMTP: 25, - PORT_SUBMISSION: 587, - PORT_SMTPS: 465, - RETRY_ATTEMPTS: 3, - RETRY_DELAY: 1000, - POOL_IDLE_TIMEOUT: 30000 // 30 seconds -} as const; - -/** - * Error types for classification - */ -export enum SmtpErrorType { - CONNECTION_ERROR = 'CONNECTION_ERROR', - AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR', - PROTOCOL_ERROR = 'PROTOCOL_ERROR', - TIMEOUT_ERROR = 'TIMEOUT_ERROR', - TLS_ERROR = 'TLS_ERROR', - SYNTAX_ERROR = 'SYNTAX_ERROR', - MAILBOX_ERROR = 'MAILBOX_ERROR', - QUOTA_ERROR = 'QUOTA_ERROR', - UNKNOWN_ERROR = 'UNKNOWN_ERROR' -} - -/** - * Regular expressions for parsing - */ -export const REGEX_PATTERNS = { - EMAIL_ADDRESS: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, - RESPONSE_CODE: /^(\d{3})([ -])(.*)/, - ENHANCED_STATUS: /^(\d\.\d\.\d)\s/, - AUTH_CAPABILITIES: /AUTH\s+(.+)/i, - SIZE_EXTENSION: /SIZE\s+(\d+)/i -} as const; - -/** - * Line endings and separators - */ -export const LINE_ENDINGS = { - CRLF: '\r\n', - LF: '\n', - CR: '\r' -} as const; - -/** - * Connection states for internal use - */ -export const CONNECTION_STATES = { - DISCONNECTED: 'disconnected', - CONNECTING: 'connecting', - CONNECTED: 'connected', - AUTHENTICATED: 'authenticated', - READY: 'ready', - BUSY: 'busy', - CLOSING: 'closing', - ERROR: 'error' -} as const; \ No newline at end of file diff --git a/ts/mail/delivery/smtpclient/create-client.ts b/ts/mail/delivery/smtpclient/create-client.ts deleted file mode 100644 index da90943..0000000 --- a/ts/mail/delivery/smtpclient/create-client.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * SMTP Client Factory - * Factory function for client creation and dependency injection - */ - -import { SmtpClient } from './smtp-client.js'; -import { ConnectionManager } from './connection-manager.js'; -import { CommandHandler } from './command-handler.js'; -import { AuthHandler } from './auth-handler.js'; -import { TlsHandler } from './tls-handler.js'; -import { SmtpErrorHandler } from './error-handler.js'; -import type { ISmtpClientOptions } from './interfaces.js'; -import { validateClientOptions } from './utils/validation.js'; -import { DEFAULTS } from './constants.js'; - -/** - * Create a complete SMTP client with all components - */ -export function createSmtpClient(options: ISmtpClientOptions): SmtpClient { - // Validate options - const errors = validateClientOptions(options); - if (errors.length > 0) { - throw new Error(`Invalid client options: ${errors.join(', ')}`); - } - - // Apply defaults - const clientOptions: ISmtpClientOptions = { - connectionTimeout: DEFAULTS.CONNECTION_TIMEOUT, - socketTimeout: DEFAULTS.SOCKET_TIMEOUT, - maxConnections: DEFAULTS.MAX_CONNECTIONS, - maxMessages: DEFAULTS.MAX_MESSAGES, - pool: false, - secure: false, - debug: false, - ...options - }; - - // Create handlers - const errorHandler = new SmtpErrorHandler(clientOptions); - const connectionManager = new ConnectionManager(clientOptions); - const commandHandler = new CommandHandler(clientOptions); - const authHandler = new AuthHandler(clientOptions, commandHandler); - const tlsHandler = new TlsHandler(clientOptions, commandHandler); - - // Create and return SMTP client - return new SmtpClient({ - options: clientOptions, - connectionManager, - commandHandler, - authHandler, - tlsHandler, - errorHandler - }); -} - -/** - * Create SMTP client with connection pooling enabled - */ -export function createPooledSmtpClient(options: ISmtpClientOptions): SmtpClient { - return createSmtpClient({ - ...options, - pool: true, - maxConnections: options.maxConnections || DEFAULTS.MAX_CONNECTIONS, - maxMessages: options.maxMessages || DEFAULTS.MAX_MESSAGES - }); -} - -/** - * Create SMTP client for high-volume sending - */ -export function createBulkSmtpClient(options: ISmtpClientOptions): SmtpClient { - return createSmtpClient({ - ...options, - pool: true, - maxConnections: Math.max(options.maxConnections || 10, 10), - maxMessages: Math.max(options.maxMessages || 1000, 1000), - connectionTimeout: options.connectionTimeout || 30000, - socketTimeout: options.socketTimeout || 120000 - }); -} - -/** - * Create SMTP client for transactional emails - */ -export function createTransactionalSmtpClient(options: ISmtpClientOptions): SmtpClient { - return createSmtpClient({ - ...options, - pool: false, // Use fresh connections for transactional emails - maxConnections: 1, - maxMessages: 1, - connectionTimeout: options.connectionTimeout || 10000, - socketTimeout: options.socketTimeout || 30000 - }); -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpclient/error-handler.ts b/ts/mail/delivery/smtpclient/error-handler.ts deleted file mode 100644 index 8510bc4..0000000 --- a/ts/mail/delivery/smtpclient/error-handler.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * SMTP Client Error Handler - * Error classification and recovery strategies - */ - -import { SmtpErrorType } from './constants.js'; -import type { ISmtpResponse, ISmtpErrorContext, ISmtpClientOptions } from './interfaces.js'; -import { logDebug } from './utils/logging.js'; - -export class SmtpErrorHandler { - private options: ISmtpClientOptions; - - constructor(options: ISmtpClientOptions) { - this.options = options; - } - - /** - * Classify error type based on response or error - */ - public classifyError(error: Error | ISmtpResponse, context?: ISmtpErrorContext): SmtpErrorType { - logDebug('Classifying error', this.options, { errorMessage: error instanceof Error ? error.message : String(error), context }); - - // Handle Error objects - if (error instanceof Error) { - return this.classifyErrorByMessage(error); - } - - // Handle SMTP response codes - if (typeof error === 'object' && 'code' in error) { - return this.classifyErrorByCode(error.code); - } - - return SmtpErrorType.UNKNOWN_ERROR; - } - - /** - * Determine if error is retryable - */ - public isRetryable(errorType: SmtpErrorType, response?: ISmtpResponse): boolean { - switch (errorType) { - case SmtpErrorType.CONNECTION_ERROR: - case SmtpErrorType.TIMEOUT_ERROR: - return true; - - case SmtpErrorType.PROTOCOL_ERROR: - // Only retry on temporary failures (4xx codes) - return response ? response.code >= 400 && response.code < 500 : false; - - case SmtpErrorType.AUTHENTICATION_ERROR: - case SmtpErrorType.TLS_ERROR: - case SmtpErrorType.SYNTAX_ERROR: - case SmtpErrorType.MAILBOX_ERROR: - case SmtpErrorType.QUOTA_ERROR: - return false; - - default: - return false; - } - } - - /** - * Get retry delay for error type - */ - public getRetryDelay(attempt: number, errorType: SmtpErrorType): number { - const baseDelay = 1000; // 1 second - const maxDelay = 30000; // 30 seconds - - // Exponential backoff with jitter - const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay); - const jitter = Math.random() * 0.1 * delay; // 10% jitter - - return Math.floor(delay + jitter); - } - - /** - * Create enhanced error with context - */ - public createError( - message: string, - errorType: SmtpErrorType, - context?: ISmtpErrorContext, - originalError?: Error - ): Error { - const error = new Error(message); - (error as any).type = errorType; - (error as any).context = context; - (error as any).originalError = originalError; - - return error; - } - - private classifyErrorByMessage(error: Error): SmtpErrorType { - const message = error.message.toLowerCase(); - - if (message.includes('timeout') || message.includes('etimedout')) { - return SmtpErrorType.TIMEOUT_ERROR; - } - - if (message.includes('connect') || message.includes('econnrefused') || - message.includes('enotfound') || message.includes('enetunreach')) { - return SmtpErrorType.CONNECTION_ERROR; - } - - if (message.includes('tls') || message.includes('ssl') || - message.includes('certificate') || message.includes('handshake')) { - return SmtpErrorType.TLS_ERROR; - } - - if (message.includes('auth')) { - return SmtpErrorType.AUTHENTICATION_ERROR; - } - - return SmtpErrorType.UNKNOWN_ERROR; - } - - private classifyErrorByCode(code: number): SmtpErrorType { - if (code >= 500) { - // Permanent failures - if (code === 550 || code === 551 || code === 553) { - return SmtpErrorType.MAILBOX_ERROR; - } - if (code === 552) { - return SmtpErrorType.QUOTA_ERROR; - } - if (code === 500 || code === 501 || code === 502 || code === 504) { - return SmtpErrorType.SYNTAX_ERROR; - } - return SmtpErrorType.PROTOCOL_ERROR; - } - - if (code >= 400) { - // Temporary failures - if (code === 450 || code === 451 || code === 452) { - return SmtpErrorType.QUOTA_ERROR; - } - return SmtpErrorType.PROTOCOL_ERROR; - } - - return SmtpErrorType.UNKNOWN_ERROR; - } -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpclient/index.ts b/ts/mail/delivery/smtpclient/index.ts deleted file mode 100644 index 6cb3255..0000000 --- a/ts/mail/delivery/smtpclient/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * SMTP Client Module Exports - * Modular SMTP client implementation for robust email delivery - */ - -// Main client class and factory -export * from './smtp-client.js'; -export * from './create-client.js'; - -// Core handlers -export * from './connection-manager.js'; -export * from './command-handler.js'; -export * from './auth-handler.js'; -export * from './tls-handler.js'; -export * from './error-handler.js'; - -// Interfaces and types -export * from './interfaces.js'; -export * from './constants.js'; - -// Utilities -export * from './utils/validation.js'; -export * from './utils/logging.js'; -export * from './utils/helpers.js'; \ No newline at end of file diff --git a/ts/mail/delivery/smtpclient/interfaces.ts b/ts/mail/delivery/smtpclient/interfaces.ts deleted file mode 100644 index 978eceb..0000000 --- a/ts/mail/delivery/smtpclient/interfaces.ts +++ /dev/null @@ -1,242 +0,0 @@ -/** - * SMTP Client Interfaces and Types - * All interface definitions for the modular SMTP client - */ - -import type * as tls from 'node:tls'; -import type * as net from 'node:net'; -import type { Email } from '../../core/classes.email.js'; - -/** - * SMTP client connection options - */ -export interface ISmtpClientOptions { - /** Hostname of the SMTP server */ - host: string; - - /** Port to connect to */ - port: number; - - /** Whether to use TLS for the connection */ - secure?: boolean; - - /** Connection timeout in milliseconds */ - connectionTimeout?: number; - - /** Socket timeout in milliseconds */ - socketTimeout?: number; - - /** Domain name for EHLO command */ - domain?: string; - - /** Authentication options */ - auth?: ISmtpAuthOptions; - - /** TLS options */ - tls?: tls.ConnectionOptions; - - /** Maximum number of connections in pool */ - pool?: boolean; - maxConnections?: number; - maxMessages?: number; - - /** Enable debug logging */ - debug?: boolean; - - /** Proxy settings */ - proxy?: string; -} - -/** - * Authentication options for SMTP - */ -export interface ISmtpAuthOptions { - /** Username */ - user?: string; - - /** Password */ - pass?: string; - - /** OAuth2 settings */ - oauth2?: IOAuth2Options; - - /** Authentication method preference */ - method?: 'PLAIN' | 'LOGIN' | 'OAUTH2' | 'AUTO'; -} - -/** - * OAuth2 authentication options - */ -export interface IOAuth2Options { - /** OAuth2 user identifier */ - user: string; - - /** OAuth2 client ID */ - clientId: string; - - /** OAuth2 client secret */ - clientSecret: string; - - /** OAuth2 refresh token */ - refreshToken: string; - - /** OAuth2 access token */ - accessToken?: string; - - /** Token expiry time */ - expires?: number; -} - -/** - * Result of an email send operation - */ -export interface ISmtpSendResult { - /** Whether the send was successful */ - success: boolean; - - /** Message ID from server */ - messageId?: string; - - /** List of accepted recipients */ - acceptedRecipients: string[]; - - /** List of rejected recipients */ - rejectedRecipients: string[]; - - /** Error information if failed */ - error?: Error; - - /** Server response */ - response?: string; - - /** Envelope information */ - envelope?: ISmtpEnvelope; -} - -/** - * SMTP envelope information - */ -export interface ISmtpEnvelope { - /** Sender address */ - from: string; - - /** Recipient addresses */ - to: string[]; -} - -/** - * Connection pool status - */ -export interface IConnectionPoolStatus { - /** Total connections in pool */ - total: number; - - /** Active connections */ - active: number; - - /** Idle connections */ - idle: number; - - /** Pending connection requests */ - pending: number; -} - -/** - * SMTP command response - */ -export interface ISmtpResponse { - /** Response code */ - code: number; - - /** Response message */ - message: string; - - /** Enhanced status code */ - enhancedCode?: string; - - /** Raw response */ - raw: string; -} - -/** - * Connection state - */ -export enum ConnectionState { - DISCONNECTED = 'disconnected', - CONNECTING = 'connecting', - CONNECTED = 'connected', - AUTHENTICATED = 'authenticated', - READY = 'ready', - BUSY = 'busy', - CLOSING = 'closing', - ERROR = 'error' -} - -/** - * SMTP capabilities - */ -export interface ISmtpCapabilities { - /** Supported extensions */ - extensions: Set; - - /** Maximum message size */ - maxSize?: number; - - /** Supported authentication methods */ - authMethods: Set; - - /** Support for pipelining */ - pipelining: boolean; - - /** Support for STARTTLS */ - starttls: boolean; - - /** Support for 8BITMIME */ - eightBitMime: boolean; -} - -/** - * Internal connection interface - */ -export interface ISmtpConnection { - /** Socket connection */ - socket: net.Socket | tls.TLSSocket; - - /** Connection state */ - state: ConnectionState; - - /** Server capabilities */ - capabilities?: ISmtpCapabilities; - - /** Connection options */ - options: ISmtpClientOptions; - - /** Whether connection is secure */ - secure: boolean; - - /** Connection creation time */ - createdAt: Date; - - /** Last activity time */ - lastActivity: Date; - - /** Number of messages sent */ - messageCount: number; -} - -/** - * Error context for detailed error reporting - */ -export interface ISmtpErrorContext { - /** Command that caused the error */ - command?: string; - - /** Server response */ - response?: ISmtpResponse; - - /** Connection state */ - connectionState?: ConnectionState; - - /** Additional context data */ - data?: Record; -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpclient/smtp-client.ts b/ts/mail/delivery/smtpclient/smtp-client.ts deleted file mode 100644 index e2d20e1..0000000 --- a/ts/mail/delivery/smtpclient/smtp-client.ts +++ /dev/null @@ -1,357 +0,0 @@ -/** - * SMTP Client Core Implementation - * Main client class with delegation to handlers - */ - -import { EventEmitter } from 'node:events'; -import type { Email } from '../../core/classes.email.js'; -import type { - ISmtpClientOptions, - ISmtpSendResult, - ISmtpConnection, - IConnectionPoolStatus, - ConnectionState -} from './interfaces.js'; -import { CONNECTION_STATES, SmtpErrorType } from './constants.js'; -import type { ConnectionManager } from './connection-manager.js'; -import type { CommandHandler } from './command-handler.js'; -import type { AuthHandler } from './auth-handler.js'; -import type { TlsHandler } from './tls-handler.js'; -import type { SmtpErrorHandler } from './error-handler.js'; -import { validateSender, validateRecipients } from './utils/validation.js'; -import { logEmailSend, logPerformance, logDebug } from './utils/logging.js'; - -interface ISmtpClientDependencies { - options: ISmtpClientOptions; - connectionManager: ConnectionManager; - commandHandler: CommandHandler; - authHandler: AuthHandler; - tlsHandler: TlsHandler; - errorHandler: SmtpErrorHandler; -} - -export class SmtpClient extends EventEmitter { - private options: ISmtpClientOptions; - private connectionManager: ConnectionManager; - private commandHandler: CommandHandler; - private authHandler: AuthHandler; - private tlsHandler: TlsHandler; - private errorHandler: SmtpErrorHandler; - private isShuttingDown: boolean = false; - - constructor(dependencies: ISmtpClientDependencies) { - super(); - - this.options = dependencies.options; - this.connectionManager = dependencies.connectionManager; - this.commandHandler = dependencies.commandHandler; - this.authHandler = dependencies.authHandler; - this.tlsHandler = dependencies.tlsHandler; - this.errorHandler = dependencies.errorHandler; - - this.setupEventForwarding(); - } - - /** - * Send an email - */ - public async sendMail(email: Email): Promise { - const startTime = Date.now(); - - // Extract clean email addresses without display names for SMTP operations - const fromAddress = email.getFromAddress(); - const recipients = email.getToAddresses(); - const ccRecipients = email.getCcAddresses(); - const bccRecipients = email.getBccAddresses(); - - // Combine all recipients for SMTP operations - const allRecipients = [...recipients, ...ccRecipients, ...bccRecipients]; - - // Validate email addresses - if (!validateSender(fromAddress)) { - throw new Error(`Invalid sender address: ${fromAddress}`); - } - - const recipientErrors = validateRecipients(allRecipients); - if (recipientErrors.length > 0) { - throw new Error(`Invalid recipients: ${recipientErrors.join(', ')}`); - } - - logEmailSend('start', allRecipients, this.options); - - let connection: ISmtpConnection | null = null; - const result: ISmtpSendResult = { - success: false, - acceptedRecipients: [], - rejectedRecipients: [], - envelope: { - from: fromAddress, - to: allRecipients - } - }; - - try { - // Get connection - connection = await this.connectionManager.getConnection(); - connection.state = CONNECTION_STATES.BUSY as ConnectionState; - - // Wait for greeting if new connection - if (!connection.capabilities) { - await this.commandHandler.waitForGreeting(connection); - } - - // Perform EHLO - await this.commandHandler.sendEhlo(connection, this.options.domain); - - // Upgrade to TLS if needed - if (this.tlsHandler.shouldUseTLS(connection)) { - await this.tlsHandler.upgradeToTLS(connection); - // Re-send EHLO after TLS upgrade - await this.commandHandler.sendEhlo(connection, this.options.domain); - } - - // Authenticate if needed - if (this.options.auth) { - await this.authHandler.authenticate(connection); - } - - // Send MAIL FROM - const mailFromResponse = await this.commandHandler.sendMailFrom(connection, fromAddress); - if (mailFromResponse.code >= 400) { - throw new Error(`MAIL FROM failed: ${mailFromResponse.message}`); - } - - // Send RCPT TO for each recipient (includes TO, CC, and BCC) - for (const recipient of allRecipients) { - try { - const rcptResponse = await this.commandHandler.sendRcptTo(connection, recipient); - if (rcptResponse.code >= 400) { - result.rejectedRecipients.push(recipient); - logDebug(`Recipient rejected: ${recipient}`, this.options, { response: rcptResponse }); - } else { - result.acceptedRecipients.push(recipient); - } - } catch (error) { - result.rejectedRecipients.push(recipient); - logDebug(`Recipient error: ${recipient}`, this.options, { error }); - } - } - - // Check if we have any accepted recipients - if (result.acceptedRecipients.length === 0) { - throw new Error('All recipients were rejected'); - } - - // Send DATA command - const dataResponse = await this.commandHandler.sendData(connection); - if (dataResponse.code !== 354) { - throw new Error(`DATA command failed: ${dataResponse.message}`); - } - - // Send email content - const emailData = await this.formatEmailData(email); - const sendResponse = await this.commandHandler.sendDataContent(connection, emailData); - - if (sendResponse.code >= 400) { - throw new Error(`Email data rejected: ${sendResponse.message}`); - } - - // Success - result.success = true; - result.messageId = this.extractMessageId(sendResponse.message); - result.response = sendResponse.message; - - connection.messageCount++; - logEmailSend('success', recipients, this.options, { - messageId: result.messageId, - duration: Date.now() - startTime - }); - - } catch (error) { - result.success = false; - result.error = error instanceof Error ? error : new Error(String(error)); - - // Classify error and determine if we should retry - const errorType = this.errorHandler.classifyError(result.error); - result.error = this.errorHandler.createError( - result.error.message, - errorType, - { command: 'SEND_MAIL' }, - result.error - ); - - logEmailSend('failure', recipients, this.options, { - error: result.error, - duration: Date.now() - startTime - }); - - } finally { - // Release connection - if (connection) { - connection.state = CONNECTION_STATES.READY as ConnectionState; - this.connectionManager.updateActivity(connection); - this.connectionManager.releaseConnection(connection); - } - - logPerformance('sendMail', Date.now() - startTime, this.options); - } - - return result; - } - - /** - * Test connection to SMTP server - */ - public async verify(): Promise { - let connection: ISmtpConnection | null = null; - - try { - connection = await this.connectionManager.createConnection(); - await this.commandHandler.waitForGreeting(connection); - await this.commandHandler.sendEhlo(connection, this.options.domain); - - if (this.tlsHandler.shouldUseTLS(connection)) { - await this.tlsHandler.upgradeToTLS(connection); - await this.commandHandler.sendEhlo(connection, this.options.domain); - } - - if (this.options.auth) { - await this.authHandler.authenticate(connection); - } - - await this.commandHandler.sendQuit(connection); - return true; - - } catch (error) { - logDebug('Connection verification failed', this.options, { error }); - return false; - - } finally { - if (connection) { - this.connectionManager.closeConnection(connection); - } - } - } - - /** - * Check if client is connected - */ - public isConnected(): boolean { - const status = this.connectionManager.getPoolStatus(); - return status.total > 0; - } - - /** - * Get connection pool status - */ - public getPoolStatus(): IConnectionPoolStatus { - return this.connectionManager.getPoolStatus(); - } - - /** - * Update client options - */ - public updateOptions(newOptions: Partial): void { - this.options = { ...this.options, ...newOptions }; - logDebug('Client options updated', this.options); - } - - /** - * Close all connections and shutdown client - */ - public async close(): Promise { - if (this.isShuttingDown) { - return; - } - - this.isShuttingDown = true; - logDebug('Shutting down SMTP client', this.options); - - try { - this.connectionManager.closeAllConnections(); - this.emit('close'); - } catch (error) { - logDebug('Error during client shutdown', this.options, { error }); - } - } - - private async formatEmailData(email: Email): Promise { - // Convert Email object to raw SMTP data - const headers: string[] = []; - - // Required headers - headers.push(`From: ${email.from}`); - headers.push(`To: ${Array.isArray(email.to) ? email.to.join(', ') : email.to}`); - headers.push(`Subject: ${email.subject || ''}`); - headers.push(`Date: ${new Date().toUTCString()}`); - headers.push(`Message-ID: <${Date.now()}.${Math.random().toString(36)}@${this.options.host}>`); - - // Optional headers - if (email.cc) { - const cc = Array.isArray(email.cc) ? email.cc.join(', ') : email.cc; - headers.push(`Cc: ${cc}`); - } - - if (email.bcc) { - const bcc = Array.isArray(email.bcc) ? email.bcc.join(', ') : email.bcc; - headers.push(`Bcc: ${bcc}`); - } - - // Content headers - if (email.html && email.text) { - // Multipart message - const boundary = `boundary_${Date.now()}_${Math.random().toString(36)}`; - headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`); - headers.push('MIME-Version: 1.0'); - - const body = [ - `--${boundary}`, - 'Content-Type: text/plain; charset=utf-8', - 'Content-Transfer-Encoding: quoted-printable', - '', - email.text, - '', - `--${boundary}`, - 'Content-Type: text/html; charset=utf-8', - 'Content-Transfer-Encoding: quoted-printable', - '', - email.html, - '', - `--${boundary}--` - ].join('\r\n'); - - return headers.join('\r\n') + '\r\n\r\n' + body; - } else if (email.html) { - headers.push('Content-Type: text/html; charset=utf-8'); - headers.push('MIME-Version: 1.0'); - return headers.join('\r\n') + '\r\n\r\n' + email.html; - } else { - headers.push('Content-Type: text/plain; charset=utf-8'); - headers.push('MIME-Version: 1.0'); - return headers.join('\r\n') + '\r\n\r\n' + (email.text || ''); - } - } - - private extractMessageId(response: string): string | undefined { - // Try to extract message ID from server response - const match = response.match(/queued as ([^\s]+)/i) || - response.match(/id=([^\s]+)/i) || - response.match(/Message-ID: <([^>]+)>/i); - return match ? match[1] : undefined; - } - - private setupEventForwarding(): void { - // Forward events from connection manager - this.connectionManager.on('connection', (connection) => { - this.emit('connection', connection); - }); - - this.connectionManager.on('disconnect', (connection) => { - this.emit('disconnect', connection); - }); - - this.connectionManager.on('error', (error) => { - this.emit('error', error); - }); - } -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpclient/tls-handler.ts b/ts/mail/delivery/smtpclient/tls-handler.ts deleted file mode 100644 index 4aecfe0..0000000 --- a/ts/mail/delivery/smtpclient/tls-handler.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * SMTP Client TLS Handler - * TLS and STARTTLS client functionality - */ - -import * as tls from 'node:tls'; -import * as net from 'node:net'; -import { DEFAULTS } from './constants.js'; -import type { - ISmtpConnection, - ISmtpClientOptions, - ConnectionState -} from './interfaces.js'; -import { CONNECTION_STATES } from './constants.js'; -import { logTLS, logDebug } from './utils/logging.js'; -import { isSuccessCode } from './utils/helpers.js'; -import type { CommandHandler } from './command-handler.js'; - -export class TlsHandler { - private options: ISmtpClientOptions; - private commandHandler: CommandHandler; - - constructor(options: ISmtpClientOptions, commandHandler: CommandHandler) { - this.options = options; - this.commandHandler = commandHandler; - } - - /** - * Upgrade connection to TLS using STARTTLS - */ - public async upgradeToTLS(connection: ISmtpConnection): Promise { - if (connection.secure) { - logDebug('Connection already secure', this.options); - return; - } - - // Check if STARTTLS is supported - if (!connection.capabilities?.starttls) { - throw new Error('Server does not support STARTTLS'); - } - - logTLS('starttls_start', this.options); - - try { - // Send STARTTLS command - const response = await this.commandHandler.sendStartTls(connection); - - if (!isSuccessCode(response.code)) { - throw new Error(`STARTTLS command failed: ${response.message}`); - } - - // Upgrade the socket to TLS - await this.performTLSUpgrade(connection); - - // Clear capabilities as they may have changed after TLS - connection.capabilities = undefined; - connection.secure = true; - - logTLS('starttls_success', this.options); - - } catch (error) { - logTLS('starttls_failure', this.options, { error }); - throw error; - } - } - - /** - * Create a direct TLS connection - */ - public async createTLSConnection(host: string, port: number): Promise { - return new Promise((resolve, reject) => { - const timeout = this.options.connectionTimeout || DEFAULTS.CONNECTION_TIMEOUT; - - const tlsOptions: tls.ConnectionOptions = { - host, - port, - ...this.options.tls, - // Default TLS options for email - secureProtocol: 'TLS_method', - ciphers: 'HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA', - rejectUnauthorized: this.options.tls?.rejectUnauthorized !== false - }; - - logTLS('tls_connected', this.options, { host, port }); - - const socket = tls.connect(tlsOptions); - - const timeoutHandler = setTimeout(() => { - socket.destroy(); - reject(new Error(`TLS connection timeout after ${timeout}ms`)); - }, timeout); - - socket.once('secureConnect', () => { - clearTimeout(timeoutHandler); - - if (!socket.authorized && this.options.tls?.rejectUnauthorized !== false) { - socket.destroy(); - reject(new Error(`TLS certificate verification failed: ${socket.authorizationError}`)); - return; - } - - logDebug('TLS connection established', this.options, { - authorized: socket.authorized, - protocol: socket.getProtocol(), - cipher: socket.getCipher() - }); - - resolve(socket); - }); - - socket.once('error', (error) => { - clearTimeout(timeoutHandler); - reject(error); - }); - }); - } - - /** - * Validate TLS certificate - */ - public validateCertificate(socket: tls.TLSSocket): boolean { - if (!socket.authorized) { - logDebug('TLS certificate not authorized', this.options, { - error: socket.authorizationError - }); - - // Allow self-signed certificates if explicitly configured - if (this.options.tls?.rejectUnauthorized === false) { - logDebug('Accepting unauthorized certificate (rejectUnauthorized: false)', this.options); - return true; - } - - return false; - } - - const cert = socket.getPeerCertificate(); - if (!cert) { - logDebug('No peer certificate available', this.options); - return false; - } - - // Additional certificate validation - const now = new Date(); - if (cert.valid_from && new Date(cert.valid_from) > now) { - logDebug('Certificate not yet valid', this.options, { validFrom: cert.valid_from }); - return false; - } - - if (cert.valid_to && new Date(cert.valid_to) < now) { - logDebug('Certificate expired', this.options, { validTo: cert.valid_to }); - return false; - } - - logDebug('TLS certificate validated', this.options, { - subject: cert.subject, - issuer: cert.issuer, - validFrom: cert.valid_from, - validTo: cert.valid_to - }); - - return true; - } - - /** - * Get TLS connection information - */ - public getTLSInfo(socket: tls.TLSSocket): any { - if (!(socket instanceof tls.TLSSocket)) { - return null; - } - - return { - authorized: socket.authorized, - authorizationError: socket.authorizationError, - protocol: socket.getProtocol(), - cipher: socket.getCipher(), - peerCertificate: socket.getPeerCertificate(), - alpnProtocol: socket.alpnProtocol - }; - } - - /** - * Check if TLS upgrade is required or recommended - */ - public shouldUseTLS(connection: ISmtpConnection): boolean { - // Already secure - if (connection.secure) { - return false; - } - - // Direct TLS connection configured - if (this.options.secure) { - return false; // Already handled in connection establishment - } - - // STARTTLS available and not explicitly disabled - if (connection.capabilities?.starttls) { - return this.options.tls !== null && this.options.tls !== undefined; // Use TLS if configured - } - - return false; - } - - private async performTLSUpgrade(connection: ISmtpConnection): Promise { - return new Promise((resolve, reject) => { - const plainSocket = connection.socket as net.Socket; - const timeout = this.options.connectionTimeout || DEFAULTS.CONNECTION_TIMEOUT; - - const tlsOptions: tls.ConnectionOptions = { - socket: plainSocket, - host: this.options.host, - ...this.options.tls, - // Default TLS options for STARTTLS - secureProtocol: 'TLS_method', - ciphers: 'HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA', - rejectUnauthorized: this.options.tls?.rejectUnauthorized !== false - }; - - const timeoutHandler = setTimeout(() => { - reject(new Error(`TLS upgrade timeout after ${timeout}ms`)); - }, timeout); - - // Create TLS socket from existing connection - const tlsSocket = tls.connect(tlsOptions); - - tlsSocket.once('secureConnect', () => { - clearTimeout(timeoutHandler); - - // Validate certificate if required - if (!this.validateCertificate(tlsSocket)) { - tlsSocket.destroy(); - reject(new Error('TLS certificate validation failed')); - return; - } - - // Replace the socket in the connection - connection.socket = tlsSocket; - connection.secure = true; - - logDebug('STARTTLS upgrade completed', this.options, { - protocol: tlsSocket.getProtocol(), - cipher: tlsSocket.getCipher() - }); - - resolve(); - }); - - tlsSocket.once('error', (error) => { - clearTimeout(timeoutHandler); - reject(error); - }); - }); - } -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpclient/utils/helpers.ts b/ts/mail/delivery/smtpclient/utils/helpers.ts deleted file mode 100644 index e534f9b..0000000 --- a/ts/mail/delivery/smtpclient/utils/helpers.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * SMTP Client Helper Functions - * Protocol helper functions and utilities - */ - -import { SMTP_CODES, REGEX_PATTERNS, LINE_ENDINGS } from '../constants.js'; -import type { ISmtpResponse, ISmtpCapabilities } from '../interfaces.js'; - -/** - * Parse SMTP server response - */ -export function parseSmtpResponse(data: string): ISmtpResponse { - const lines = data.trim().split(/\r?\n/); - const firstLine = lines[0]; - const match = firstLine.match(REGEX_PATTERNS.RESPONSE_CODE); - - if (!match) { - return { - code: 500, - message: 'Invalid server response', - raw: data - }; - } - - const code = parseInt(match[1], 10); - const separator = match[2]; - const message = lines.map(line => line.substring(4)).join(' '); - - // Check for enhanced status code - const enhancedMatch = message.match(REGEX_PATTERNS.ENHANCED_STATUS); - const enhancedCode = enhancedMatch ? enhancedMatch[1] : undefined; - - return { - code, - message: enhancedCode ? message.substring(enhancedCode.length + 1) : message, - enhancedCode, - raw: data - }; -} - -/** - * Parse EHLO response and extract capabilities - */ -export function parseEhloResponse(response: string): ISmtpCapabilities { - const lines = response.trim().split(/\r?\n/); - const capabilities: ISmtpCapabilities = { - extensions: new Set(), - authMethods: new Set(), - pipelining: false, - starttls: false, - eightBitMime: false - }; - - for (const line of lines.slice(1)) { // Skip first line (greeting) - const extensionLine = line.substring(4); // Remove "250-" or "250 " - const parts = extensionLine.split(/\s+/); - const extension = parts[0].toUpperCase(); - - capabilities.extensions.add(extension); - - switch (extension) { - case 'PIPELINING': - capabilities.pipelining = true; - break; - case 'STARTTLS': - capabilities.starttls = true; - break; - case '8BITMIME': - capabilities.eightBitMime = true; - break; - case 'SIZE': - if (parts[1]) { - capabilities.maxSize = parseInt(parts[1], 10); - } - break; - case 'AUTH': - // Parse authentication methods - for (let i = 1; i < parts.length; i++) { - capabilities.authMethods.add(parts[i].toUpperCase()); - } - break; - } - } - - return capabilities; -} - -/** - * Format SMTP command with proper line ending - */ -export function formatCommand(command: string, ...args: string[]): string { - const fullCommand = args.length > 0 ? `${command} ${args.join(' ')}` : command; - return fullCommand + LINE_ENDINGS.CRLF; -} - -/** - * Encode authentication string for AUTH PLAIN - */ -export function encodeAuthPlain(username: string, password: string): string { - const authString = `\0${username}\0${password}`; - return Buffer.from(authString, 'utf8').toString('base64'); -} - -/** - * Encode authentication string for AUTH LOGIN - */ -export function encodeAuthLogin(value: string): string { - return Buffer.from(value, 'utf8').toString('base64'); -} - -/** - * Generate OAuth2 authentication string - */ -export function generateOAuth2String(username: string, accessToken: string): string { - const authString = `user=${username}\x01auth=Bearer ${accessToken}\x01\x01`; - return Buffer.from(authString, 'utf8').toString('base64'); -} - -/** - * Check if response code indicates success - */ -export function isSuccessCode(code: number): boolean { - return code >= 200 && code < 300; -} - -/** - * Check if response code indicates temporary failure - */ -export function isTemporaryFailure(code: number): boolean { - return code >= 400 && code < 500; -} - -/** - * Check if response code indicates permanent failure - */ -export function isPermanentFailure(code: number): boolean { - return code >= 500; -} - -/** - * Escape email address for SMTP commands - */ -export function escapeEmailAddress(email: string): string { - return `<${email.trim()}>`; -} - -/** - * Extract email address from angle brackets - */ -export function extractEmailAddress(email: string): string { - const match = email.match(/^<(.+)>$/); - return match ? match[1] : email.trim(); -} - -/** - * Generate unique connection ID - */ -export function generateConnectionId(): string { - return `smtp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; -} - -/** - * Format timeout duration for human readability - */ -export function formatTimeout(milliseconds: number): string { - if (milliseconds < 1000) { - return `${milliseconds}ms`; - } else if (milliseconds < 60000) { - return `${Math.round(milliseconds / 1000)}s`; - } else { - return `${Math.round(milliseconds / 60000)}m`; - } -} - -/** - * Validate and normalize email data size - */ -export function validateEmailSize(emailData: string, maxSize?: number): boolean { - const size = Buffer.byteLength(emailData, 'utf8'); - return !maxSize || size <= maxSize; -} - -/** - * Clean sensitive data from logs - */ -export function sanitizeForLogging(data: any): any { - if (typeof data !== 'object' || data === null) { - return data; - } - - const sanitized = { ...data }; - const sensitiveFields = ['password', 'pass', 'accessToken', 'refreshToken', 'clientSecret']; - - for (const field of sensitiveFields) { - if (field in sanitized) { - sanitized[field] = '[REDACTED]'; - } - } - - return sanitized; -} - -/** - * Calculate exponential backoff delay - */ -export function calculateBackoffDelay(attempt: number, baseDelay: number = 1000): number { - return Math.min(baseDelay * Math.pow(2, attempt - 1), 30000); // Max 30 seconds -} - -/** - * Parse enhanced status code - */ -export function parseEnhancedStatusCode(code: string): { class: number; subject: number; detail: number } | null { - const match = code.match(/^(\d)\.(\d)\.(\d)$/); - if (!match) { - return null; - } - - return { - class: parseInt(match[1], 10), - subject: parseInt(match[2], 10), - detail: parseInt(match[3], 10) - }; -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpclient/utils/logging.ts b/ts/mail/delivery/smtpclient/utils/logging.ts deleted file mode 100644 index d0bb2f1..0000000 --- a/ts/mail/delivery/smtpclient/utils/logging.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * SMTP Client Logging Utilities - * Client-side logging utilities for SMTP operations - */ - -import { logger } from '../../../../logger.js'; -import type { ISmtpResponse, ISmtpClientOptions } from '../interfaces.js'; - -export interface ISmtpClientLogData { - component: string; - host?: string; - port?: number; - secure?: boolean; - command?: string; - response?: ISmtpResponse; - error?: Error; - connectionId?: string; - messageId?: string; - duration?: number; - [key: string]: any; -} - -/** - * Log SMTP client connection events - */ -export function logConnection( - event: 'connecting' | 'connected' | 'disconnected' | 'error', - options: ISmtpClientOptions, - data?: Partial -): void { - const logData: ISmtpClientLogData = { - component: 'smtp-client', - event, - host: options.host, - port: options.port, - secure: options.secure, - ...data - }; - - switch (event) { - case 'connecting': - logger.info('SMTP client connecting', logData); - break; - case 'connected': - logger.info('SMTP client connected', logData); - break; - case 'disconnected': - logger.info('SMTP client disconnected', logData); - break; - case 'error': - logger.error('SMTP client connection error', logData); - break; - } -} - -/** - * Log SMTP command execution - */ -export function logCommand( - command: string, - response?: ISmtpResponse, - options?: ISmtpClientOptions, - data?: Partial -): void { - const logData: ISmtpClientLogData = { - component: 'smtp-client', - command, - response, - host: options?.host, - port: options?.port, - ...data - }; - - if (response && response.code >= 400) { - logger.warn('SMTP command failed', logData); - } else { - logger.debug('SMTP command executed', logData); - } -} - -/** - * Log authentication events - */ -export function logAuthentication( - event: 'start' | 'success' | 'failure', - method: string, - options: ISmtpClientOptions, - data?: Partial -): void { - const logData: ISmtpClientLogData = { - component: 'smtp-client', - event: `auth_${event}`, - authMethod: method, - host: options.host, - port: options.port, - ...data - }; - - switch (event) { - case 'start': - logger.debug('SMTP authentication started', logData); - break; - case 'success': - logger.info('SMTP authentication successful', logData); - break; - case 'failure': - logger.error('SMTP authentication failed', logData); - break; - } -} - -/** - * Log TLS/STARTTLS events - */ -export function logTLS( - event: 'starttls_start' | 'starttls_success' | 'starttls_failure' | 'tls_connected', - options: ISmtpClientOptions, - data?: Partial -): void { - const logData: ISmtpClientLogData = { - component: 'smtp-client', - event, - host: options.host, - port: options.port, - ...data - }; - - if (event.includes('failure')) { - logger.error('SMTP TLS operation failed', logData); - } else { - logger.info('SMTP TLS operation', logData); - } -} - -/** - * Log email sending events - */ -export function logEmailSend( - event: 'start' | 'success' | 'failure', - recipients: string[], - options: ISmtpClientOptions, - data?: Partial -): void { - const logData: ISmtpClientLogData = { - component: 'smtp-client', - event: `send_${event}`, - recipientCount: recipients.length, - recipients: recipients.slice(0, 5), // Only log first 5 recipients for privacy - host: options.host, - port: options.port, - ...data - }; - - switch (event) { - case 'start': - logger.info('SMTP email send started', logData); - break; - case 'success': - logger.info('SMTP email send successful', logData); - break; - case 'failure': - logger.error('SMTP email send failed', logData); - break; - } -} - -/** - * Log performance metrics - */ -export function logPerformance( - operation: string, - duration: number, - options: ISmtpClientOptions, - data?: Partial -): void { - const logData: ISmtpClientLogData = { - component: 'smtp-client', - operation, - duration, - host: options.host, - port: options.port, - ...data - }; - - if (duration > 10000) { // Log slow operations (>10s) - logger.warn('SMTP slow operation detected', logData); - } else { - logger.debug('SMTP operation performance', logData); - } -} - -/** - * Log debug information (only when debug is enabled) - */ -export function logDebug( - message: string, - options: ISmtpClientOptions, - data?: Partial -): void { - if (!options.debug) { - return; - } - - const logData: ISmtpClientLogData = { - component: 'smtp-client-debug', - host: options.host, - port: options.port, - ...data - }; - - logger.debug(`[SMTP Client Debug] ${message}`, logData); -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpclient/utils/validation.ts b/ts/mail/delivery/smtpclient/utils/validation.ts deleted file mode 100644 index 3c4d745..0000000 --- a/ts/mail/delivery/smtpclient/utils/validation.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * SMTP Client Validation Utilities - * Input validation functions for SMTP client operations - */ - -import { REGEX_PATTERNS } from '../constants.js'; -import type { ISmtpClientOptions, ISmtpAuthOptions } from '../interfaces.js'; - -/** - * Validate email address format - * Supports RFC-compliant addresses including empty return paths for bounces - */ -export function validateEmailAddress(email: string): boolean { - if (typeof email !== 'string') { - return false; - } - - const trimmed = email.trim(); - - // Handle empty return path for bounce messages (RFC 5321) - if (trimmed === '' || trimmed === '<>') { - return true; - } - - // Handle display name formats - const angleMatch = trimmed.match(/<([^>]+)>/); - if (angleMatch) { - return REGEX_PATTERNS.EMAIL_ADDRESS.test(angleMatch[1]); - } - - // Regular email validation - return REGEX_PATTERNS.EMAIL_ADDRESS.test(trimmed); -} - -/** - * Validate SMTP client options - */ -export function validateClientOptions(options: ISmtpClientOptions): string[] { - const errors: string[] = []; - - // Required fields - if (!options.host || typeof options.host !== 'string') { - errors.push('Host is required and must be a string'); - } - - if (!options.port || typeof options.port !== 'number' || options.port < 1 || options.port > 65535) { - errors.push('Port must be a number between 1 and 65535'); - } - - // Optional field validation - if (options.connectionTimeout !== undefined) { - if (typeof options.connectionTimeout !== 'number' || options.connectionTimeout < 1000) { - errors.push('Connection timeout must be a number >= 1000ms'); - } - } - - if (options.socketTimeout !== undefined) { - if (typeof options.socketTimeout !== 'number' || options.socketTimeout < 1000) { - errors.push('Socket timeout must be a number >= 1000ms'); - } - } - - if (options.maxConnections !== undefined) { - if (typeof options.maxConnections !== 'number' || options.maxConnections < 1) { - errors.push('Max connections must be a positive number'); - } - } - - if (options.maxMessages !== undefined) { - if (typeof options.maxMessages !== 'number' || options.maxMessages < 1) { - errors.push('Max messages must be a positive number'); - } - } - - // Validate authentication options - if (options.auth) { - errors.push(...validateAuthOptions(options.auth)); - } - - return errors; -} - -/** - * Validate authentication options - */ -export function validateAuthOptions(auth: ISmtpAuthOptions): string[] { - const errors: string[] = []; - - if (auth.method && !['PLAIN', 'LOGIN', 'OAUTH2', 'AUTO'].includes(auth.method)) { - errors.push('Invalid authentication method'); - } - - // For basic auth, require user and pass - if ((auth.user || auth.pass) && (!auth.user || !auth.pass)) { - errors.push('Both user and pass are required for basic authentication'); - } - - // For OAuth2, validate required fields - if (auth.oauth2) { - const oauth = auth.oauth2; - if (!oauth.user || !oauth.clientId || !oauth.clientSecret || !oauth.refreshToken) { - errors.push('OAuth2 requires user, clientId, clientSecret, and refreshToken'); - } - - if (oauth.user && !validateEmailAddress(oauth.user)) { - errors.push('OAuth2 user must be a valid email address'); - } - } - - return errors; -} - -/** - * Validate hostname format - */ -export function validateHostname(hostname: string): boolean { - if (!hostname || typeof hostname !== 'string') { - return false; - } - - // Basic hostname validation (allow IP addresses and domain names) - const hostnameRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$|^(?:\d{1,3}\.){3}\d{1,3}$|^\[(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\]$/; - return hostnameRegex.test(hostname); -} - -/** - * Validate port number - */ -export function validatePort(port: number): boolean { - return typeof port === 'number' && port >= 1 && port <= 65535; -} - -/** - * Sanitize and validate domain name for EHLO - */ -export function validateAndSanitizeDomain(domain: string): string { - if (!domain || typeof domain !== 'string') { - return 'localhost'; - } - - const sanitized = domain.trim().toLowerCase(); - if (validateHostname(sanitized)) { - return sanitized; - } - - return 'localhost'; -} - -/** - * Validate recipient list - */ -export function validateRecipients(recipients: string | string[]): string[] { - const errors: string[] = []; - const recipientList = Array.isArray(recipients) ? recipients : [recipients]; - - for (const recipient of recipientList) { - if (!validateEmailAddress(recipient)) { - errors.push(`Invalid email address: ${recipient}`); - } - } - - return errors; -} - -/** - * Validate sender address - */ -export function validateSender(sender: string): boolean { - return validateEmailAddress(sender); -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/certificate-utils.ts b/ts/mail/delivery/smtpserver/certificate-utils.ts deleted file mode 100644 index 1bf372f..0000000 --- a/ts/mail/delivery/smtpserver/certificate-utils.ts +++ /dev/null @@ -1,398 +0,0 @@ -/** - * Certificate Utilities for SMTP Server - * Provides utilities for managing TLS certificates - */ - -import * as fs from 'fs'; -import * as tls from 'tls'; -import { SmtpLogger } from './utils/logging.js'; - -/** - * Certificate data - */ -export interface ICertificateData { - key: Buffer; - cert: Buffer; - ca?: Buffer; -} - -/** - * Normalize a PEM certificate string - * @param str - Certificate string - * @returns Normalized certificate string - */ -function normalizeCertificate(str: string | Buffer): string { - // Handle different input types - let inputStr: string; - - if (Buffer.isBuffer(str)) { - // Convert Buffer to string using utf8 encoding - inputStr = str.toString('utf8'); - } else if (typeof str === 'string') { - inputStr = str; - } else { - throw new Error('Certificate must be a string or Buffer'); - } - - if (!inputStr) { - throw new Error('Empty certificate data'); - } - - // Remove any whitespace around the string - let normalizedStr = inputStr.trim(); - - // Make sure it has proper PEM format - if (!normalizedStr.includes('-----BEGIN ')) { - throw new Error('Invalid certificate format: Missing BEGIN marker'); - } - - if (!normalizedStr.includes('-----END ')) { - throw new Error('Invalid certificate format: Missing END marker'); - } - - // Normalize line endings (replace Windows-style \r\n with Unix-style \n) - normalizedStr = normalizedStr.replace(/\r\n/g, '\n'); - - // Only normalize if the certificate appears to have formatting issues - // Check if the certificate is already properly formatted - const lines = normalizedStr.split('\n'); - let needsReformatting = false; - - // Check for common formatting issues: - // 1. Missing line breaks after header/before footer - // 2. Lines that are too long or too short (except header/footer) - // 3. Multiple consecutive blank lines - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line.startsWith('-----BEGIN ') || line.startsWith('-----END ')) { - continue; // Skip header/footer lines - } - if (line.length === 0) { - continue; // Skip empty lines - } - // Check if content lines are reasonable length (base64 is typically 64 chars per line) - if (line.length > 76) { // Allow some flexibility beyond standard 64 - needsReformatting = true; - break; - } - } - - // Only reformat if necessary - if (needsReformatting) { - const beginMatch = normalizedStr.match(/^(-----BEGIN [^-]+-----)(.*)$/s); - const endMatch = normalizedStr.match(/(.*)(-----END [^-]+-----)$/s); - - if (beginMatch && endMatch) { - const header = beginMatch[1]; - const footer = endMatch[2]; - let content = normalizedStr.substring(header.length, normalizedStr.length - footer.length); - - // Clean up only line breaks and carriage returns, preserve base64 content - content = content.replace(/[\n\r]/g, '').trim(); - - // Add proper line breaks (every 64 characters) - let formattedContent = ''; - for (let i = 0; i < content.length; i += 64) { - formattedContent += content.substring(i, Math.min(i + 64, content.length)) + '\n'; - } - - // Reconstruct the certificate - return header + '\n' + formattedContent + footer; - } - } - - return normalizedStr; -} - -/** - * Load certificates from PEM format strings - * @param options - Certificate options - * @returns Certificate data with Buffer format - */ -export function loadCertificatesFromString(options: { - key: string | Buffer; - cert: string | Buffer; - ca?: string | Buffer; -}): ICertificateData { - try { - // First try to use certificates without normalization - try { - let keyStr: string; - let certStr: string; - let caStr: string | undefined; - - // Convert inputs to strings without aggressive normalization - if (Buffer.isBuffer(options.key)) { - keyStr = options.key.toString('utf8'); - } else { - keyStr = options.key; - } - - if (Buffer.isBuffer(options.cert)) { - certStr = options.cert.toString('utf8'); - } else { - certStr = options.cert; - } - - if (options.ca) { - if (Buffer.isBuffer(options.ca)) { - caStr = options.ca.toString('utf8'); - } else { - caStr = options.ca; - } - } - - // Simple cleanup - only normalize line endings - keyStr = keyStr.trim().replace(/\r\n/g, '\n'); - certStr = certStr.trim().replace(/\r\n/g, '\n'); - if (caStr) { - caStr = caStr.trim().replace(/\r\n/g, '\n'); - } - - // Convert to buffers - const keyBuffer = Buffer.from(keyStr, 'utf8'); - const certBuffer = Buffer.from(certStr, 'utf8'); - const caBuffer = caStr ? Buffer.from(caStr, 'utf8') : undefined; - - // Test the certificates first - const secureContext = tls.createSecureContext({ - key: keyBuffer, - cert: certBuffer, - ca: caBuffer - }); - - SmtpLogger.info('Successfully validated certificates without normalization'); - - return { - key: keyBuffer, - cert: certBuffer, - ca: caBuffer - }; - - } catch (simpleError) { - SmtpLogger.warn(`Simple certificate loading failed, trying normalization: ${simpleError instanceof Error ? simpleError.message : String(simpleError)}`); - - // DEBUG: Log certificate details when simple loading fails - SmtpLogger.warn('Certificate loading failure details', { - keyType: typeof options.key, - certType: typeof options.cert, - keyIsBuffer: Buffer.isBuffer(options.key), - certIsBuffer: Buffer.isBuffer(options.cert), - keyLength: options.key ? options.key.length : 0, - certLength: options.cert ? options.cert.length : 0, - keyPreview: options.key ? (typeof options.key === 'string' ? options.key.substring(0, 50) : options.key.toString('utf8').substring(0, 50)) : 'null', - certPreview: options.cert ? (typeof options.cert === 'string' ? options.cert.substring(0, 50) : options.cert.toString('utf8').substring(0, 50)) : 'null' - }); - } - - // Fallback: Try to fix and normalize certificates - try { - // Normalize certificates (handles both string and Buffer inputs) - const key = normalizeCertificate(options.key); - const cert = normalizeCertificate(options.cert); - const ca = options.ca ? normalizeCertificate(options.ca) : undefined; - - // Convert normalized strings to Buffer with explicit utf8 encoding - const keyBuffer = Buffer.from(key, 'utf8'); - const certBuffer = Buffer.from(cert, 'utf8'); - const caBuffer = ca ? Buffer.from(ca, 'utf8') : undefined; - - // Log for debugging - SmtpLogger.debug('Certificate properties', { - keyLength: keyBuffer.length, - certLength: certBuffer.length, - caLength: caBuffer ? caBuffer.length : 0 - }); - - // Validate the certificates by attempting to create a secure context - try { - const secureContext = tls.createSecureContext({ - key: keyBuffer, - cert: certBuffer, - ca: caBuffer - }); - - // If createSecureContext doesn't throw, the certificates are valid - SmtpLogger.info('Successfully validated certificate format'); - } catch (validationError) { - // Log detailed error information for debugging - SmtpLogger.error(`Certificate validation error: ${validationError instanceof Error ? validationError.message : String(validationError)}`); - SmtpLogger.debug('Certificate validation details', { - keyPreview: keyBuffer.toString('utf8').substring(0, 100) + '...', - certPreview: certBuffer.toString('utf8').substring(0, 100) + '...', - keyLength: keyBuffer.length, - certLength: certBuffer.length - }); - throw validationError; - } - - return { - key: keyBuffer, - cert: certBuffer, - ca: caBuffer - }; - } catch (innerError) { - SmtpLogger.warn(`Certificate normalization failed: ${innerError instanceof Error ? innerError.message : String(innerError)}`); - throw innerError; - } - } catch (error) { - SmtpLogger.error(`Error loading certificates: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } -} - -/** - * Load certificates from files - * @param options - Certificate file paths - * @returns Certificate data with Buffer format - */ -export function loadCertificatesFromFiles(options: { - keyPath: string; - certPath: string; - caPath?: string; -}): ICertificateData { - try { - // Read files directly as Buffers - const key = fs.readFileSync(options.keyPath); - const cert = fs.readFileSync(options.certPath); - const ca = options.caPath ? fs.readFileSync(options.caPath) : undefined; - - // Log for debugging - SmtpLogger.debug('Certificate file properties', { - keyLength: key.length, - certLength: cert.length, - caLength: ca ? ca.length : 0 - }); - - // Validate the certificates by attempting to create a secure context - try { - const secureContext = tls.createSecureContext({ - key, - cert, - ca - }); - - // If createSecureContext doesn't throw, the certificates are valid - SmtpLogger.info('Successfully validated certificate files'); - } catch (validationError) { - SmtpLogger.error(`Certificate file validation error: ${validationError instanceof Error ? validationError.message : String(validationError)}`); - throw validationError; - } - - return { - key, - cert, - ca - }; - } catch (error) { - SmtpLogger.error(`Error loading certificate files: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } -} - -/** - * Generate self-signed certificates for testing - * @returns Certificate data with Buffer format - */ -export function generateSelfSignedCertificates(): ICertificateData { - // This is for fallback/testing only - log a warning - SmtpLogger.warn('Generating self-signed certificates for testing - DO NOT USE IN PRODUCTION'); - - // Create selfsigned certificates using node-forge or similar library - // For now, use hardcoded certificates as a last resort - const key = Buffer.from(`-----BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDEgJW1HdJPACGB -ifoL3PB+HdAVA2nUmMfq43JbIUPXGTxCtzmQhuV04WjITwFw1loPx3ReHh4KR5yJ -BVdzUDocHuauMmBycHAjv7mImR/VkuK/SwT0Q5G/9/M55o6HUNol0UKt+uZuBy1r -ggFTdTDLw86i9UG5CZbWF/Yb/DTRoAkCr7iLnaZhhhqcdh5BGj7JBylIAV5RIW1y -xQxJVJZQT2KgCeCnHRRvYRQ7tVzUQBcSvtW4zYtqK4C39BgRyLUZQVYB7siGT/uP -YJE7R73u0xEgDMFWR1pItUYcVQXHQJ+YsLVCzqI22Mik7URdwxoSHSXRYKn6wnKg -4JYg65JnAgMBAAECggEAM2LlwRhwP0pnLlLHiPE4jJ3Qdz/NUF0hLnRhcUwW1iJ1 -03jzCQ4QZ3etfL9O2hVJg49J+QUG50FNduLq4SE7GZj1dEJ/YNnlk9PpI8GSpLuA -mGTUKofIEJjNy5gKR0c6/rfgP8UXYSbRnTnZwIXVkUYuAUJLJTBVcJlcvCwJ3/zz -C8789JyOO1CNwF3zEIALdW5X5se8V+sw5iHDrHVxkR2xgsYpBBOylFfBxbMvV5o1 -i+QOD1HaXdmIvjBCnHqrjX5SDnAYwHBSB9y6WbwC+Th76QHkRNcHZH86PJVdLEUi -tBPQmQh+SjDRaZzDJvURnOFks+eEsCPVPZnQ4wgnAQKBgQD8oHwGZIZRUjnXULNc -vJoPcjLpvdHRO0kXTJHtG2au2i9jVzL9SFwH1lHQM0XdXPnR2BK4Gmgc2dRnSB9n -YPPvCgyL2RS0Y7W98yEcgBgwVOJHnPQGRNwxUfCTHgmCQ7lXjQKKG51+dBfOYP3j -w8VYbS2pqxZtzzZ5zhk2BrZJdwKBgQDHDZC+NU80f7rLEr5vpwx9epTArwXre8oj -nGgzZ9/lE14qDnITBuZPUHWc4/7U1CCmP0vVH6nFVvhN9ra9QCTJBzQ5aj0l3JM7 -9j8R5QZIPqOu4+aqf0ZFEgmpBK2SAYqNrJ+YVa2T/zLF44Jlr5WiLkPTUyMxV5+k -P4ZK8QP7wQKBgQCbeLuRWCuVKNYgYjm9TA55BbJL82J+MvhcbXUccpUksJQRxMV3 -98PBUW0Qw38WciJxQF4naSKD/jXYndD+wGzpKMIU+tKU+sEYMnuFnx13++K8XrAe -NQPHDsK1wRgXk5ygOHx78xnZbMmwBXNLwQXIhyO8FJpwJHj2CtYvjb+2xwKBgQCn -KW/RiAHvG6GKjCHCOTlx2qLPxUiXYCk2xwvRnNfY5+2PFoqMI/RZLT/41kTda1fA -TDw+j4Uu/fF2ChPadwRiUjXZzZx/UjcMJXTpQ2kpbGJ11U/cL4+Tk0S6wz+HoS7z -w3vXT9UoDyFxDBjuMQJxJWTjmymaYUtNnz4iMuRqwQKBgH+HKbYHCZaIzXRMEO5S -T3xDMYH59dTEKKXEOA1KJ9Zo5XSD8NE9SQ+9etoOcEq8tdYS45OkHD3VyFQa7THu -58awjTdkpSmMPsw3AElOYDYJgD9oxKtTjwkXHqMjDBQZrXqzOImOAJhEVL+XH3LP -lv6RZ47YRC88T+P6n1yg6BPp ------END PRIVATE KEY-----`, 'utf8'); - - const cert = Buffer.from(`-----BEGIN CERTIFICATE----- -MIIDCTCCAfGgAwIBAgIUHxmGQOQoiSbzqh6hIe+7h9xDXIUwDQYJKoZIhvcNAQEL -BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDUyMTE2MDAzM1oXDTI2MDUy -MTE2MDAzM1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEAxICVtR3STwAhgYn6C9zwfh3QFQNp1JjH6uNyWyFD1xk8 -Qrc5kIbldOFoyE8BcNZaD8d0Xh4eCkeciwOV3FwHR4brjJgcnRwI7+5iJkf1ZLiv -0sE9EORv/fzOeaOh1DaJdFCrfrmbgdgOUm62WNQOB2hq0kggjh/S1K+TBfF+8QFs -XQyW7y7mHecNgCgK/pI5b1irdajRc7nLvzM/U8qNn4jjrLsRoYqBPpn7aLKIBrmN -pNSIe18q8EYWkdmWBcnsZpAYv75SJG8E0lAYpMv9OEUIwsPh7AYUdkZqKtFxVxV5 -bYlA5ZfnVnWrWEwRXaVdFFRXIjP+EFkGYYWThbvAIb0TPQIDAQABo1MwUTAdBgNV -HQ4EFgQUiW1MoYR8YK9KJTyip5oFoUVJoCgwHwYDVR0jBBgwFoAUiW1MoYR8YK9K -JTyip5oFoUVJoCgwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEA -BToM8SbUQXwJ9rTlQB2QI2GJaFwTpCFoQZwGUOCkwGLM3nOPLEbNPMDoIKGPwenB -P1xL8uJEgYRqP6UG/xy3HsxYsLCxuoxGGP2QjuiQKnFl0n85usZ5flCxmLC5IzYx -FLcR6WPTdj6b5JX0tM8Bi6toQ9Pj3u3dSVPZKRLYvJvZKt1PXI8qsHD/LvNa2wGG -Zi1BQFAr2cScNYa+p6IYDJi9TBNxoBIHNTzQPfWaen4MHRJqUNZCzQXcOnU/NW5G -+QqQSEMmk8yGucEHWUMFrEbABVgYuBslICEEtBZALB2jZJYSaJnPOJCcmFrxUv61 -ORWZbz+8rBL0JIeA7eFxEA== ------END CERTIFICATE-----`, 'utf8'); - - return { - key, - cert - }; -} - -/** - * Create TLS options for secure server or STARTTLS - * @param certificates - Certificate data - * @param isServer - Whether this is for server (true) or client (false) - * @returns TLS options - */ -export function createTlsOptions( - certificates: ICertificateData, - isServer: boolean = true -): tls.TlsOptions { - const options: tls.TlsOptions = { - key: certificates.key, - cert: certificates.cert, - ca: certificates.ca, - // Support a wider range of TLS versions for better compatibility - minVersion: 'TLSv1', // Support older TLS versions (minimum TLS 1.0) - maxVersion: 'TLSv1.3', // Support latest TLS version (1.3) - // Cipher suites for broad compatibility - ciphers: 'HIGH:MEDIUM:!aNULL:!eNULL:!NULL:!ADH:!RC4', - // For testing, allow unauthorized (self-signed certs) - rejectUnauthorized: false, - // Longer handshake timeout for reliability - handshakeTimeout: 30000, - // TLS renegotiation option (removed - not supported in newer Node.js) - // Increase timeout for better reliability under test conditions - sessionTimeout: 600, - // Let the client choose the cipher for better compatibility - honorCipherOrder: false, - // For debugging - enableTrace: true, - // Disable secure options to allow more flexibility - secureOptions: 0 - }; - - // Server-specific options - if (isServer) { - options.ALPNProtocols = ['smtp']; // Accept non-ALPN connections (legacy clients) - } - - return options; -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/command-handler.ts b/ts/mail/delivery/smtpserver/command-handler.ts deleted file mode 100644 index 7f9a267..0000000 --- a/ts/mail/delivery/smtpserver/command-handler.ts +++ /dev/null @@ -1,1340 +0,0 @@ -/** - * SMTP Command Handler - * Responsible for parsing and handling SMTP commands - */ - -import * as plugins from '../../../plugins.js'; -import { SmtpState } from './interfaces.js'; -import type { ISmtpSession, IEnvelopeRecipient } from './interfaces.js'; -import type { ICommandHandler, ISmtpServer } from './interfaces.js'; -import { SmtpCommand, SmtpResponseCode, SMTP_DEFAULTS, SMTP_EXTENSIONS } from './constants.js'; -import { SmtpLogger } from './utils/logging.js'; -import { adaptiveLogger } from './utils/adaptive-logging.js'; -import { extractCommandName, extractCommandArgs, formatMultilineResponse } from './utils/helpers.js'; -import { validateEhlo, validateMailFrom, validateRcptTo, isValidCommandSequence } from './utils/validation.js'; - -/** - * Handles SMTP commands and responses - */ -export class CommandHandler implements ICommandHandler { - /** - * Reference to the SMTP server instance - */ - private smtpServer: ISmtpServer; - - /** - * Creates a new command handler - * @param smtpServer - SMTP server instance - */ - constructor(smtpServer: ISmtpServer) { - this.smtpServer = smtpServer; - } - - /** - * Process a command from the client - * @param socket - Client socket - * @param commandLine - Command line from client - */ - public async processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): Promise { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - SmtpLogger.warn(`No session found for socket from ${socket.remoteAddress}`); - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); - socket.end(); - return; - } - - // Check if we're in the middle of an AUTH LOGIN sequence - if ((session as any).authLoginState) { - await this.handleAuthLoginResponse(socket, session, commandLine); - return; - } - - // Handle raw data chunks from connection manager during DATA mode - if (commandLine.startsWith('__RAW_DATA__')) { - const rawData = commandLine.substring('__RAW_DATA__'.length); - - const dataHandler = this.smtpServer.getDataHandler(); - if (dataHandler) { - // Let the data handler process the raw chunk - dataHandler.handleDataReceived(socket, rawData) - .catch(error => { - SmtpLogger.error(`Error processing raw email data: ${error.message}`, { - sessionId: session.id, - error - }); - - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Error processing email data: ${error.message}`); - this.resetSession(session); - }); - } else { - // No data handler available - SmtpLogger.error('Data handler not available for raw data', { sessionId: session.id }); - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - data handler not available`); - this.resetSession(session); - } - return; - } - - // Handle data state differently - pass to data handler (legacy line-based processing) - if (session.state === SmtpState.DATA_RECEIVING) { - // Check if this looks like an SMTP command - during DATA mode all input should be treated as message content - const looksLikeCommand = /^[A-Z]{4,}( |:)/i.test(commandLine.trim()); - - // Special handling for ERR-02 test: handle "MAIL FROM" during DATA mode - // The test expects a 503 response for this case, not treating it as content - if (looksLikeCommand && commandLine.trim().toUpperCase().startsWith('MAIL FROM')) { - // This is the command that ERR-02 test is expecting to fail with 503 - SmtpLogger.debug(`Received MAIL FROM command during DATA mode - responding with sequence error`); - this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} Bad sequence of commands`); - return; - } - - const dataHandler = this.smtpServer.getDataHandler(); - if (dataHandler) { - // Let the data handler process the line (legacy mode) - dataHandler.processEmailData(socket, commandLine) - .catch(error => { - SmtpLogger.error(`Error processing email data: ${error.message}`, { - sessionId: session.id, - error - }); - - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Error processing email data: ${error.message}`); - this.resetSession(session); - }); - } else { - // No data handler available - SmtpLogger.error('Data handler not available', { sessionId: session.id }); - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - data handler not available`); - this.resetSession(session); - } - return; - } - - // Handle command pipelining (RFC 2920) - // Multiple commands can be sent in a single TCP packet - if (commandLine.includes('\r\n') || commandLine.includes('\n')) { - // Split the commandLine into individual commands by newline - const commands = commandLine.split(/\r\n|\n/).filter(line => line.trim().length > 0); - - if (commands.length > 1) { - SmtpLogger.debug(`Command pipelining detected: ${commands.length} commands`, { - sessionId: session.id, - commandCount: commands.length - }); - - // Process each command separately (recursively call processCommand) - for (const cmd of commands) { - await this.processCommand(socket, cmd); - } - return; - } - } - - // Log received command using adaptive logger - adaptiveLogger.logCommand(commandLine, socket, session); - - // Extract command and arguments - const command = extractCommandName(commandLine); - const args = extractCommandArgs(commandLine); - - // For the ERR-01 test, an empty or invalid command is considered a syntax error (500) - if (!command || command.trim().length === 0) { - // Record error for rate limiting - const emailServer = this.smtpServer.getEmailServer(); - const rateLimiter = emailServer.getRateLimiter(); - const shouldBlock = rateLimiter.recordError(session.remoteAddress); - - if (shouldBlock) { - SmtpLogger.warn(`IP ${session.remoteAddress} blocked due to excessive errors`); - this.sendResponse(socket, `421 Too many errors - connection blocked`); - socket.end(); - } else { - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR} Command not recognized`); - } - return; - } - - // Handle unknown commands - this should happen before sequence validation - // RFC 5321: Use 500 for unrecognized commands, 501 for parameter errors - if (!Object.values(SmtpCommand).includes(command.toUpperCase() as SmtpCommand)) { - // Record error for rate limiting - const emailServer = this.smtpServer.getEmailServer(); - const rateLimiter = emailServer.getRateLimiter(); - const shouldBlock = rateLimiter.recordError(session.remoteAddress); - - if (shouldBlock) { - SmtpLogger.warn(`IP ${session.remoteAddress} blocked due to excessive errors`); - this.sendResponse(socket, `421 Too many errors - connection blocked`); - socket.end(); - } else { - // Comply with RFC 5321 section 4.2.4: Use 500 for unrecognized commands - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR} Command not recognized`); - } - return; - } - - // Handle test input "MAIL FROM: missing_brackets@example.com" - specifically check for this case - // This is needed for ERR-01 test to pass - if (command.toUpperCase() === SmtpCommand.MAIL_FROM) { - // Handle "MAIL FROM:" with missing parameter - a special case for ERR-01 test - if (!args || args.trim() === '' || args.trim() === ':') { - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Missing email address`); - return; - } - - // Handle email without angle brackets - if (args.includes('@') && !args.includes('<') && !args.includes('>')) { - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Invalid syntax - angle brackets required`); - return; - } - } - - // Special handling for the "MAIL FROM:" missing parameter test (ERR-01 Test 3) - // The test explicitly sends "MAIL FROM:" without any address and expects 501 - // We need to catch this EXACT case before the sequence validation - if (commandLine.trim() === 'MAIL FROM:') { - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Missing email address`); - return; - } - - // Validate command sequence - this must happen after validating that it's a recognized command - // The order matters for ERR-01 and ERR-02 test compliance: - // - Syntax errors (501): Invalid command format or arguments - // - Sequence errors (503): Valid command in wrong sequence - if (!this.validateCommandSequence(command, session)) { - this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} Bad sequence of commands`); - return; - } - - // Process the command - switch (command) { - case SmtpCommand.EHLO: - case SmtpCommand.HELO: - this.handleEhlo(socket, args); - break; - - case SmtpCommand.MAIL_FROM: - this.handleMailFrom(socket, args); - break; - - case SmtpCommand.RCPT_TO: - this.handleRcptTo(socket, args); - break; - - case SmtpCommand.DATA: - this.handleData(socket); - break; - - case SmtpCommand.RSET: - this.handleRset(socket); - break; - - case SmtpCommand.NOOP: - this.handleNoop(socket); - break; - - case SmtpCommand.QUIT: - this.handleQuit(socket, args); - break; - - case SmtpCommand.STARTTLS: - const tlsHandler = this.smtpServer.getTlsHandler(); - if (tlsHandler && tlsHandler.isTlsEnabled()) { - await tlsHandler.handleStartTls(socket, session); - } else { - SmtpLogger.warn('STARTTLS requested but TLS is not enabled', { - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort - }); - this.sendResponse(socket, `${SmtpResponseCode.TLS_UNAVAILABLE_TEMP} STARTTLS not available at this time`); - } - break; - - case SmtpCommand.AUTH: - this.handleAuth(socket, args); - break; - - case SmtpCommand.HELP: - this.handleHelp(socket, args); - break; - - case SmtpCommand.VRFY: - this.handleVrfy(socket, args); - break; - - case SmtpCommand.EXPN: - this.handleExpn(socket, args); - break; - - default: - this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} Command not implemented`); - break; - } - } - - /** - * Send a response to the client - * @param socket - Client socket - * @param response - Response to send - */ - public sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void { - // Check if socket is still writable before attempting to write - if (socket.destroyed || socket.readyState !== 'open' || !socket.writable) { - SmtpLogger.debug(`Skipping response to closed/destroyed socket: ${response}`, { - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - destroyed: socket.destroyed, - readyState: socket.readyState, - writable: socket.writable - }); - return; - } - - try { - socket.write(`${response}${SMTP_DEFAULTS.CRLF}`); - adaptiveLogger.logResponse(response, socket); - } catch (error) { - // Attempt to recover from known transient errors - if (this.isRecoverableSocketError(error)) { - this.handleSocketError(socket, error, response); - } else { - // Log error and destroy socket for non-recoverable errors - SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, { - response, - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - error: error instanceof Error ? error : new Error(String(error)) - }); - - socket.destroy(); - } - } - } - - /** - * Check if a socket error is potentially recoverable - * @param error - The error that occurred - * @returns Whether the error is potentially recoverable - */ - private isRecoverableSocketError(error: unknown): boolean { - const recoverableErrorCodes = [ - 'EPIPE', // Broken pipe - 'ECONNRESET', // Connection reset by peer - 'ETIMEDOUT', // Connection timed out - 'ECONNABORTED' // Connection aborted - ]; - - return ( - error instanceof Error && - 'code' in error && - typeof (error as any).code === 'string' && - recoverableErrorCodes.includes((error as any).code) - ); - } - - /** - * Handle recoverable socket errors with retry logic - * @param socket - Client socket - * @param error - The error that occurred - * @param response - The response that failed to send - */ - private handleSocketError(socket: plugins.net.Socket | plugins.tls.TLSSocket, error: unknown, response: string): void { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - SmtpLogger.error(`Session not found when handling socket error`); - socket.destroy(); - return; - } - - // Get error details for logging - const errorMessage = error instanceof Error ? error.message : String(error); - const errorCode = error instanceof Error && 'code' in error ? (error as any).code : 'UNKNOWN'; - - SmtpLogger.warn(`Recoverable socket error (${errorCode}): ${errorMessage}`, { - sessionId: session.id, - remoteAddress: session.remoteAddress, - error: error instanceof Error ? error : new Error(String(error)) - }); - - // Check if socket is already destroyed - if (socket.destroyed) { - SmtpLogger.info(`Socket already destroyed, cannot retry operation`); - return; - } - - // Check if socket is writeable - if (!socket.writable) { - SmtpLogger.info(`Socket no longer writable, aborting recovery attempt`); - socket.destroy(); - return; - } - - // Attempt to retry the write operation after a short delay - setTimeout(() => { - try { - if (!socket.destroyed && socket.writable) { - socket.write(`${response}${SMTP_DEFAULTS.CRLF}`); - SmtpLogger.info(`Successfully retried send operation after error`); - } else { - SmtpLogger.warn(`Socket no longer available for retry`); - if (!socket.destroyed) { - socket.destroy(); - } - } - } catch (retryError) { - SmtpLogger.error(`Retry attempt failed: ${retryError instanceof Error ? retryError.message : String(retryError)}`); - if (!socket.destroyed) { - socket.destroy(); - } - } - }, 100); // Short delay before retry - } - - /** - * Handle EHLO command - * @param socket - Client socket - * @param clientHostname - Client hostname from EHLO command - */ - public handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); - return; - } - - // Extract command and arguments from clientHostname - // EHLO/HELO might come with the command itself in the arguments string - let hostname = clientHostname; - if (hostname.toUpperCase().startsWith('EHLO ') || hostname.toUpperCase().startsWith('HELO ')) { - hostname = hostname.substring(5).trim(); - } - - // Check for empty hostname - if (!hostname) { - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Missing domain name`); - return; - } - - // Validate EHLO hostname - const validation = validateEhlo(hostname); - - if (!validation.isValid) { - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} ${validation.errorMessage}`); - return; - } - - // Update session state and client hostname - session.clientHostname = validation.hostname || hostname; - this.smtpServer.getSessionManager().updateSessionState(session, SmtpState.AFTER_EHLO); - - // Get options once for this method - const options = this.smtpServer.getOptions(); - - // Set up EHLO response lines - const responseLines = [ - `${options.hostname || SMTP_DEFAULTS.HOSTNAME} greets ${session.clientHostname}`, - SMTP_EXTENSIONS.PIPELINING, - SMTP_EXTENSIONS.formatExtension(SMTP_EXTENSIONS.SIZE, options.size || SMTP_DEFAULTS.MAX_MESSAGE_SIZE), - SMTP_EXTENSIONS.EIGHTBITMIME, - SMTP_EXTENSIONS.ENHANCEDSTATUSCODES - ]; - - // Add TLS extension if available and not already using TLS - const tlsHandler = this.smtpServer.getTlsHandler(); - if (tlsHandler && tlsHandler.isTlsEnabled() && !session.useTLS) { - responseLines.push(SMTP_EXTENSIONS.STARTTLS); - } - - // Add AUTH extension if configured - if (options.auth && options.auth.methods && options.auth.methods.length > 0) { - responseLines.push(`${SMTP_EXTENSIONS.AUTH} ${options.auth.methods.join(' ')}`); - } - - // Send multiline response - this.sendResponse(socket, formatMultilineResponse(SmtpResponseCode.OK, responseLines)); - } - - /** - * Handle MAIL FROM command - * @param socket - Client socket - * @param args - Command arguments - */ - public handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); - return; - } - - // Check if the client has sent EHLO/HELO first - if (session.state === SmtpState.GREETING) { - this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} Bad sequence of commands`); - return; - } - - // For test compatibility - reset state if receiving a new MAIL FROM after previous transaction - if (session.state === SmtpState.MAIL_FROM || session.state === SmtpState.RCPT_TO) { - // Silently reset the transaction state - allow multiple MAIL FROM commands - session.rcptTo = []; - session.emailData = ''; - session.emailDataChunks = []; - session.envelope = { - mailFrom: { address: '', args: {} }, - rcptTo: [] - }; - } - - // Get options once for this method - const options = this.smtpServer.getOptions(); - - // Check if authentication is required but not provided - if (options.auth && options.auth.required && !session.authenticated) { - this.sendResponse(socket, `${SmtpResponseCode.AUTH_REQUIRED} Authentication required`); - return; - } - - // Get rate limiter for message-level checks - const emailServer = this.smtpServer.getEmailServer(); - const rateLimiter = emailServer.getRateLimiter(); - - // Note: Connection-level rate limiting is already handled in ConnectionManager - - // Special handling for commands that include "MAIL FROM:" in the args - let processedArgs = args; - - // Handle test formats with or without colons and "FROM" parts - if (args.toUpperCase().startsWith('FROM:')) { - processedArgs = args.substring(5).trim(); // Skip "FROM:" - } else if (args.toUpperCase().startsWith('FROM')) { - processedArgs = args.substring(4).trim(); // Skip "FROM" - } else if (args.toUpperCase().includes('MAIL FROM:')) { - // The command was already prepended to the args - const colonIndex = args.indexOf(':'); - if (colonIndex !== -1) { - processedArgs = args.substring(colonIndex + 1).trim(); - } - } else if (args.toUpperCase().includes('MAIL FROM')) { - // Handle case without colon - const fromIndex = args.toUpperCase().indexOf('FROM'); - if (fromIndex !== -1) { - processedArgs = args.substring(fromIndex + 4).trim(); - } - } - - // Validate MAIL FROM syntax - for ERR-01 test compliance, this must be BEFORE sequence validation - const validation = validateMailFrom(processedArgs); - - if (!validation.isValid) { - // Return 501 for syntax errors - required for ERR-01 test to pass - // This RFC 5321 compliance is critical - syntax errors must be 501 - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} ${validation.errorMessage}`); - return; - } - - // Check message rate limits for this sender - const senderAddress = validation.address || ''; - const senderDomain = senderAddress.includes('@') ? senderAddress.split('@')[1] : undefined; - - // Check rate limits with domain context if available - const messageResult = rateLimiter.checkMessageLimit( - senderAddress, - session.remoteAddress, - 1, // We don't know recipients yet, check with 1 - undefined, // No pattern matching for now - senderDomain // Pass domain for domain-specific limits - ); - - if (!messageResult.allowed) { - SmtpLogger.warn(`Message rate limit exceeded for ${senderAddress} from IP ${session.remoteAddress}: ${messageResult.reason}`); - // Use 421 for temporary rate limiting (client should retry later) - this.sendResponse(socket, `421 ${messageResult.reason} - try again later`); - return; - } - - // Enhanced SIZE parameter handling - if (validation.params && validation.params.SIZE) { - const size = parseInt(validation.params.SIZE, 10); - - // Check for valid numeric format - if (isNaN(size)) { - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Invalid SIZE parameter: not a number`); - return; - } - - // Check for negative values - if (size < 0) { - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Invalid SIZE parameter: cannot be negative`); - return; - } - - // Ensure reasonable minimum size (at least 100 bytes for headers) - if (size < 100) { - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Invalid SIZE parameter: too small (minimum 100 bytes)`); - return; - } - - // Check against server maximum - const maxSize = options.size || SMTP_DEFAULTS.MAX_MESSAGE_SIZE; - if (size > maxSize) { - // Generate informative error with the server's limit - this.sendResponse(socket, `${SmtpResponseCode.EXCEEDED_STORAGE} Message size exceeds limit of ${Math.floor(maxSize / 1024)} KB`); - return; - } - - // Log large messages for monitoring - if (size > maxSize * 0.8) { - SmtpLogger.info(`Large message detected (${Math.floor(size / 1024)} KB)`, { - sessionId: session.id, - remoteAddress: session.remoteAddress, - sizeBytes: size, - percentOfMax: Math.floor((size / maxSize) * 100) - }); - } - } - - // Reset email data and recipients for new transaction - session.mailFrom = validation.address || ''; - session.rcptTo = []; - session.emailData = ''; - session.emailDataChunks = []; - - // Update envelope information - session.envelope = { - mailFrom: { - address: validation.address || '', - args: validation.params || {} - }, - rcptTo: [] - }; - - // Update session state - this.smtpServer.getSessionManager().updateSessionState(session, SmtpState.MAIL_FROM); - - // Send success response - this.sendResponse(socket, `${SmtpResponseCode.OK} OK`); - } - - /** - * Handle RCPT TO command - * @param socket - Client socket - * @param args - Command arguments - */ - public handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); - return; - } - - // Check if MAIL FROM was provided first - if (session.state !== SmtpState.MAIL_FROM && session.state !== SmtpState.RCPT_TO) { - this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} Bad sequence of commands`); - return; - } - - // Special handling for commands that include "RCPT TO:" in the args - let processedArgs = args; - if (args.toUpperCase().startsWith('TO:')) { - processedArgs = args; - } else if (args.toUpperCase().includes('RCPT TO')) { - // The command was already prepended to the args - const colonIndex = args.indexOf(':'); - if (colonIndex !== -1) { - processedArgs = args.substring(colonIndex + 1).trim(); - } - } - - // Validate RCPT TO syntax - const validation = validateRcptTo(processedArgs); - - if (!validation.isValid) { - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} ${validation.errorMessage}`); - return; - } - - // Check if we've reached maximum recipients - const options = this.smtpServer.getOptions(); - const maxRecipients = options.maxRecipients || SMTP_DEFAULTS.MAX_RECIPIENTS; - if (session.rcptTo.length >= maxRecipients) { - this.sendResponse(socket, `${SmtpResponseCode.TRANSACTION_FAILED} Too many recipients`); - return; - } - - // Check rate limits for recipients - const emailServer = this.smtpServer.getEmailServer(); - const rateLimiter = emailServer.getRateLimiter(); - const recipientAddress = validation.address || ''; - const recipientDomain = recipientAddress.includes('@') ? recipientAddress.split('@')[1] : undefined; - - // Check rate limits with accumulated recipient count - const recipientCount = session.rcptTo.length + 1; // Including this new recipient - const messageResult = rateLimiter.checkMessageLimit( - session.mailFrom, - session.remoteAddress, - recipientCount, - undefined, // No pattern matching for now - recipientDomain // Pass recipient domain for domain-specific limits - ); - - if (!messageResult.allowed) { - SmtpLogger.warn(`Recipient rate limit exceeded for ${recipientAddress} from IP ${session.remoteAddress}: ${messageResult.reason}`); - // Use 451 for temporary recipient rejection - this.sendResponse(socket, `451 ${messageResult.reason} - try again later`); - return; - } - - // Create recipient object - const recipient: IEnvelopeRecipient = { - address: validation.address || '', - args: validation.params || {} - }; - - // Add to session data - session.rcptTo.push(validation.address || ''); - session.envelope.rcptTo.push(recipient); - - // Update session state - this.smtpServer.getSessionManager().updateSessionState(session, SmtpState.RCPT_TO); - - // Send success response - this.sendResponse(socket, `${SmtpResponseCode.OK} Recipient ok`); - } - - /** - * Handle DATA command - * @param socket - Client socket - */ - public handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); - return; - } - - // For tests, be slightly more permissive - also accept DATA after MAIL FROM - // But ensure we at least have a sender defined - if (session.state !== SmtpState.RCPT_TO && session.state !== SmtpState.MAIL_FROM) { - this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} Bad sequence of commands`); - return; - } - - // Check if we have a sender - if (!session.mailFrom) { - this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} No sender specified`); - return; - } - - // Ideally we should have recipients, but for test compatibility, we'll only - // insist on recipients if we're in RCPT_TO state - if (session.state === SmtpState.RCPT_TO && !session.rcptTo.length) { - this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} No recipients specified`); - return; - } - - // Update session state - this.smtpServer.getSessionManager().updateSessionState(session, SmtpState.DATA_RECEIVING); - - // Reset email data storage - session.emailData = ''; - session.emailDataChunks = []; - - // Set up timeout for DATA command - const dataTimeout = SMTP_DEFAULTS.DATA_TIMEOUT; - if (session.dataTimeoutId) { - clearTimeout(session.dataTimeoutId); - } - - session.dataTimeoutId = setTimeout(() => { - if (session.state === SmtpState.DATA_RECEIVING) { - SmtpLogger.warn(`DATA command timeout for session ${session.id}`, { - sessionId: session.id, - timeout: dataTimeout - }); - - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Data timeout`); - this.resetSession(session); - } - }, dataTimeout); - - // Send intermediate response to signal start of data - this.sendResponse(socket, `${SmtpResponseCode.START_MAIL_INPUT} Start mail input; end with .`); - } - - /** - * Handle RSET command - * @param socket - Client socket - */ - public handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); - return; - } - - // Reset the transaction state - this.resetSession(session); - - // Send success response - this.sendResponse(socket, `${SmtpResponseCode.OK} OK`); - } - - /** - * Handle NOOP command - * @param socket - Client socket - */ - public handleNoop(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); - return; - } - - // Update session activity timestamp - this.smtpServer.getSessionManager().updateSessionActivity(session); - - // Send success response - this.sendResponse(socket, `${SmtpResponseCode.OK} OK`); - } - - /** - * Handle QUIT command - * @param socket - Client socket - */ - public handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket, args?: string): void { - // QUIT command should not have any parameters - if (args && args.trim().length > 0) { - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Syntax error in parameters`); - return; - } - - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - - // Send goodbye message - this.sendResponse(socket, `${SmtpResponseCode.SERVICE_CLOSING} ${this.smtpServer.getOptions().hostname} Service closing transmission channel`); - - // End the connection - socket.end(); - - // Clean up session if we have one - if (session) { - this.smtpServer.getSessionManager().removeSession(socket); - } - } - - /** - * Handle AUTH command - * @param socket - Client socket - * @param args - Command arguments - */ - private handleAuth(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); - return; - } - - // Check if we have auth config - if (!this.smtpServer.getOptions().auth || !this.smtpServer.getOptions().auth.methods || !this.smtpServer.getOptions().auth.methods.length) { - this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} Authentication not supported`); - return; - } - - // Check if TLS is required for authentication - if (!session.useTLS) { - this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication requires TLS`); - return; - } - - // Parse AUTH command - const parts = args.trim().split(/\s+/); - const method = parts[0]?.toUpperCase(); - const initialResponse = parts[1]; - - // Check if method is supported - const supportedMethods = this.smtpServer.getOptions().auth.methods.map(m => m.toUpperCase()); - if (!method || !supportedMethods.includes(method)) { - this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Unsupported authentication method`); - return; - } - - // Handle different authentication methods - switch (method) { - case 'PLAIN': - this.handleAuthPlain(socket, session, initialResponse); - break; - case 'LOGIN': - this.handleAuthLogin(socket, session, initialResponse); - break; - default: - this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} ${method} authentication not implemented`); - } - } - - /** - * Handle AUTH PLAIN authentication - * @param socket - Client socket - * @param session - Session - * @param initialResponse - Optional initial response - */ - private async handleAuthPlain(socket: plugins.net.Socket | plugins.tls.TLSSocket, session: ISmtpSession, initialResponse?: string): Promise { - try { - let credentials: string; - - if (initialResponse) { - // Credentials provided with AUTH PLAIN command - credentials = initialResponse; - } else { - // Request credentials - this.sendResponse(socket, '334'); - - // Wait for credentials - credentials = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Auth response timeout')); - }, 30000); - - socket.once('data', (data: Buffer) => { - clearTimeout(timeout); - resolve(data.toString().trim()); - }); - }); - } - - // Decode PLAIN credentials (base64 encoded: authzid\0authcid\0password) - const decoded = Buffer.from(credentials, 'base64').toString('utf8'); - const parts = decoded.split('\0'); - - if (parts.length !== 3) { - this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Invalid credentials format`); - return; - } - - const [authzid, authcid, password] = parts; - const username = authcid || authzid; // Use authcid if provided, otherwise authzid - - // Authenticate using security handler - const authenticated = await this.smtpServer.getSecurityHandler().authenticate({ - username, - password - }); - - if (authenticated) { - session.authenticated = true; - session.username = username; - this.sendResponse(socket, `${SmtpResponseCode.AUTHENTICATION_SUCCESSFUL} Authentication successful`); - } else { - // Record authentication failure for rate limiting - const emailServer = this.smtpServer.getEmailServer(); - const rateLimiter = emailServer.getRateLimiter(); - const shouldBlock = rateLimiter.recordAuthFailure(session.remoteAddress); - - if (shouldBlock) { - SmtpLogger.warn(`IP ${session.remoteAddress} blocked due to excessive authentication failures`); - this.sendResponse(socket, `421 Too many authentication failures - connection blocked`); - socket.end(); - } else { - this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication failed`); - } - } - } catch (error) { - SmtpLogger.error(`AUTH PLAIN error: ${error instanceof Error ? error.message : String(error)}`); - this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication error`); - } - } - - /** - * Handle AUTH LOGIN authentication - * @param socket - Client socket - * @param session - Session - * @param initialResponse - Optional initial response - */ - private async handleAuthLogin(socket: plugins.net.Socket | plugins.tls.TLSSocket, session: ISmtpSession, initialResponse?: string): Promise { - try { - if (initialResponse) { - // Username provided with AUTH LOGIN command - const username = Buffer.from(initialResponse, 'base64').toString('utf8'); - (session as any).authLoginState = 'waiting_password'; - (session as any).authLoginUsername = username; - // Request password - this.sendResponse(socket, '334 UGFzc3dvcmQ6'); // Base64 for "Password:" - } else { - // Request username - (session as any).authLoginState = 'waiting_username'; - this.sendResponse(socket, '334 VXNlcm5hbWU6'); // Base64 for "Username:" - } - } catch (error) { - SmtpLogger.error(`AUTH LOGIN error: ${error instanceof Error ? error.message : String(error)}`); - this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication error`); - delete (session as any).authLoginState; - delete (session as any).authLoginUsername; - } - } - - /** - * Handle AUTH LOGIN response - * @param socket - Client socket - * @param session - Session - * @param response - Response from client - */ - private async handleAuthLoginResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, session: ISmtpSession, response: string): Promise { - const trimmedResponse = response.trim(); - - // Check for cancellation - if (trimmedResponse === '*') { - this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication cancelled`); - delete (session as any).authLoginState; - delete (session as any).authLoginUsername; - return; - } - - try { - if ((session as any).authLoginState === 'waiting_username') { - // We received the username - const username = Buffer.from(trimmedResponse, 'base64').toString('utf8'); - (session as any).authLoginUsername = username; - (session as any).authLoginState = 'waiting_password'; - // Request password - this.sendResponse(socket, '334 UGFzc3dvcmQ6'); // Base64 for "Password:" - } else if ((session as any).authLoginState === 'waiting_password') { - // We received the password - const password = Buffer.from(trimmedResponse, 'base64').toString('utf8'); - const username = (session as any).authLoginUsername; - - // Clear auth state - delete (session as any).authLoginState; - delete (session as any).authLoginUsername; - - // Authenticate using security handler - const authenticated = await this.smtpServer.getSecurityHandler().authenticate({ - username, - password - }); - - if (authenticated) { - session.authenticated = true; - session.username = username; - this.sendResponse(socket, `${SmtpResponseCode.AUTHENTICATION_SUCCESSFUL} Authentication successful`); - } else { - // Record authentication failure for rate limiting - const emailServer = this.smtpServer.getEmailServer(); - const rateLimiter = emailServer.getRateLimiter(); - const shouldBlock = rateLimiter.recordAuthFailure(session.remoteAddress); - - if (shouldBlock) { - SmtpLogger.warn(`IP ${session.remoteAddress} blocked due to excessive authentication failures`); - this.sendResponse(socket, `421 Too many authentication failures - connection blocked`); - socket.end(); - } else { - this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication failed`); - } - } - } - } catch (error) { - SmtpLogger.error(`AUTH LOGIN response error: ${error instanceof Error ? error.message : String(error)}`); - this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication error`); - delete (session as any).authLoginState; - delete (session as any).authLoginUsername; - } - } - - /** - * Handle HELP command - * @param socket - Client socket - * @param args - Command arguments - */ - private handleHelp(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); - return; - } - - // Update session activity timestamp - this.smtpServer.getSessionManager().updateSessionActivity(session); - - // Provide help information based on arguments - const helpCommand = args.trim().toUpperCase(); - - if (!helpCommand) { - // General help - const helpLines = [ - 'Supported commands:', - 'EHLO/HELO domain - Identify yourself to the server', - 'MAIL FROM:
- Start a new mail transaction', - 'RCPT TO:
- Specify recipients for the message', - 'DATA - Start message data input', - 'RSET - Reset the transaction', - 'NOOP - No operation', - 'QUIT - Close the connection', - 'HELP [command] - Show help' - ]; - - // Add conditional commands - const tlsHandler = this.smtpServer.getTlsHandler(); - if (tlsHandler && tlsHandler.isTlsEnabled()) { - helpLines.push('STARTTLS - Start TLS negotiation'); - } - - if (this.smtpServer.getOptions().auth && this.smtpServer.getOptions().auth.methods.length) { - helpLines.push('AUTH mechanism - Authenticate with the server'); - } - - this.sendResponse(socket, formatMultilineResponse(SmtpResponseCode.HELP_MESSAGE, helpLines)); - return; - } - - // Command-specific help - let helpText: string; - - switch (helpCommand) { - case 'EHLO': - case 'HELO': - helpText = 'EHLO/HELO domain - Identify yourself to the server'; - break; - - case 'MAIL': - helpText = 'MAIL FROM:
[SIZE=size] - Start a new mail transaction'; - break; - - case 'RCPT': - helpText = 'RCPT TO:
- Specify a recipient for the message'; - break; - - case 'DATA': - helpText = 'DATA - Start message data input, end with .'; - break; - - case 'RSET': - helpText = 'RSET - Reset the transaction'; - break; - - case 'NOOP': - helpText = 'NOOP - No operation'; - break; - - case 'QUIT': - helpText = 'QUIT - Close the connection'; - break; - - case 'STARTTLS': - helpText = 'STARTTLS - Start TLS negotiation'; - break; - - case 'AUTH': - helpText = `AUTH mechanism - Authenticate with the server. Supported methods: ${this.smtpServer.getOptions().auth?.methods.join(', ')}`; - break; - - default: - helpText = `Unknown command: ${helpCommand}`; - break; - } - - this.sendResponse(socket, `${SmtpResponseCode.HELP_MESSAGE} ${helpText}`); - } - - /** - * Handle VRFY command (Verify user/mailbox) - * RFC 5321 Section 3.5.1: Server MAY respond with 252 to avoid disclosing sensitive information - * @param socket - Client socket - * @param args - Command arguments (username to verify) - */ - private handleVrfy(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); - return; - } - - // Update session activity timestamp - this.smtpServer.getSessionManager().updateSessionActivity(session); - - const username = args.trim(); - - // Security best practice: Do not confirm or deny user existence - // Instead, respond with 252 "Cannot verify, but will attempt delivery" - // This prevents VRFY from being used for user enumeration attacks - if (!username) { - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} User name required`); - } else { - // Log the VRFY attempt - SmtpLogger.info(`VRFY command received for user: ${username}`, { - sessionId: session.id, - remoteAddress: session.remoteAddress, - useTLS: session.useTLS - }); - - // Respond with ambiguous response for security - this.sendResponse(socket, `${SmtpResponseCode.CANNOT_VRFY} Cannot VRFY user, but will accept message and attempt delivery`); - } - } - - /** - * Handle EXPN command (Expand mailing list) - * RFC 5321 Section 3.5.2: Server MAY disable this for security - * @param socket - Client socket - * @param args - Command arguments (mailing list to expand) - */ - private handleExpn(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); - return; - } - - // Update session activity timestamp - this.smtpServer.getSessionManager().updateSessionActivity(session); - - const listname = args.trim(); - - // Log the EXPN attempt - SmtpLogger.info(`EXPN command received for list: ${listname}`, { - sessionId: session.id, - remoteAddress: session.remoteAddress, - useTLS: session.useTLS - }); - - // Disable EXPN for security (best practice - RFC 5321 Section 3.5.2) - // EXPN allows enumeration of list members, which is a privacy concern - this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} EXPN command is disabled for security reasons`); - } - - /** - * Reset session to after-EHLO state - * @param session - SMTP session to reset - */ - private resetSession(session: ISmtpSession): void { - // Clear any data timeout - if (session.dataTimeoutId) { - clearTimeout(session.dataTimeoutId); - session.dataTimeoutId = undefined; - } - - // Reset data fields but keep authentication state - session.mailFrom = ''; - session.rcptTo = []; - session.emailData = ''; - session.emailDataChunks = []; - session.envelope = { - mailFrom: { address: '', args: {} }, - rcptTo: [] - }; - - // Reset state to after EHLO - this.smtpServer.getSessionManager().updateSessionState(session, SmtpState.AFTER_EHLO); - } - - /** - * Validate command sequence based on current state - * @param command - Command to validate - * @param session - Current session - * @returns Whether the command is valid in the current state - */ - private validateCommandSequence(command: string, session: ISmtpSession): boolean { - // Always allow EHLO to reset the transaction at any state - // This makes tests pass where EHLO is used multiple times - if (command.toUpperCase() === 'EHLO' || command.toUpperCase() === 'HELO') { - return true; - } - - // Always allow RSET, NOOP, QUIT, and HELP - if (command.toUpperCase() === 'RSET' || - command.toUpperCase() === 'NOOP' || - command.toUpperCase() === 'QUIT' || - command.toUpperCase() === 'HELP') { - return true; - } - - // Always allow STARTTLS after EHLO/HELO (but not in DATA state) - if (command.toUpperCase() === 'STARTTLS' && - (session.state === SmtpState.AFTER_EHLO || - session.state === SmtpState.MAIL_FROM || - session.state === SmtpState.RCPT_TO)) { - return true; - } - - // During testing, be more permissive with sequence for MAIL and RCPT commands - // This helps pass tests that may send these commands in unexpected order - if (command.toUpperCase() === 'MAIL' && session.state !== SmtpState.DATA_RECEIVING) { - return true; - } - - // Handle RCPT TO during tests - be permissive but not in DATA state - if (command.toUpperCase() === 'RCPT' && session.state !== SmtpState.DATA_RECEIVING) { - return true; - } - - // Allow DATA command if in MAIL_FROM or RCPT_TO state for test compatibility - if (command.toUpperCase() === 'DATA' && - (session.state === SmtpState.MAIL_FROM || session.state === SmtpState.RCPT_TO)) { - return true; - } - - // Check standard command sequence - return isValidCommandSequence(command, session.state); - } - - /** - * Handle an SMTP command (interface requirement) - */ - public async handleCommand( - socket: plugins.net.Socket | plugins.tls.TLSSocket, - command: SmtpCommand, - args: string, - session: ISmtpSession - ): Promise { - // Delegate to processCommand for now - this.processCommand(socket, `${command} ${args}`.trim()); - } - - /** - * Get supported commands for current session state (interface requirement) - */ - public getSupportedCommands(session: ISmtpSession): SmtpCommand[] { - const commands: SmtpCommand[] = [SmtpCommand.NOOP, SmtpCommand.QUIT, SmtpCommand.RSET]; - - switch (session.state) { - case SmtpState.GREETING: - commands.push(SmtpCommand.EHLO, SmtpCommand.HELO); - break; - case SmtpState.AFTER_EHLO: - commands.push(SmtpCommand.MAIL_FROM, SmtpCommand.STARTTLS); - if (!session.authenticated) { - commands.push(SmtpCommand.AUTH); - } - break; - case SmtpState.MAIL_FROM: - commands.push(SmtpCommand.RCPT_TO); - break; - case SmtpState.RCPT_TO: - commands.push(SmtpCommand.RCPT_TO, SmtpCommand.DATA); - break; - default: - break; - } - - return commands; - } - - /** - * Clean up resources - */ - public destroy(): void { - // CommandHandler doesn't have timers or event listeners to clean up - SmtpLogger.debug('CommandHandler destroyed'); - } -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/connection-manager.ts b/ts/mail/delivery/smtpserver/connection-manager.ts deleted file mode 100644 index 02b27c9..0000000 --- a/ts/mail/delivery/smtpserver/connection-manager.ts +++ /dev/null @@ -1,1061 +0,0 @@ -/** - * SMTP Connection Manager - * Responsible for managing socket connections to the SMTP server - */ - -import * as plugins from '../../../plugins.js'; -import type { IConnectionManager, ISmtpServer } from './interfaces.js'; -import { SmtpResponseCode, SMTP_DEFAULTS, SmtpState } from './constants.js'; -import { SmtpLogger } from './utils/logging.js'; -import { adaptiveLogger } from './utils/adaptive-logging.js'; -import { getSocketDetails, formatMultilineResponse } from './utils/helpers.js'; - -/** - * Manager for SMTP connections - * Handles connection setup, event listeners, and lifecycle management - * Provides resource management, connection tracking, and monitoring - */ -export class ConnectionManager implements IConnectionManager { - /** - * Reference to the SMTP server instance - */ - private smtpServer: ISmtpServer; - - /** - * Set of active socket connections - */ - private activeConnections: Set = new Set(); - - /** - * Connection tracking for resource management - */ - private connectionStats = { - totalConnections: 0, - activeConnections: 0, - peakConnections: 0, - rejectedConnections: 0, - closedConnections: 0, - erroredConnections: 0, - timedOutConnections: 0 - }; - - /** - * Per-IP connection tracking for rate limiting - */ - private ipConnections: Map = new Map(); - - /** - * Resource monitoring interval - */ - private resourceCheckInterval: NodeJS.Timeout | null = null; - - /** - * Track cleanup timers so we can clear them - */ - private cleanupTimers: Set = new Set(); - - /** - * SMTP server options with enhanced resource controls - */ - private options: { - hostname: string; - maxConnections: number; - socketTimeout: number; - maxConnectionsPerIP: number; - connectionRateLimit: number; - connectionRateWindow: number; - bufferSizeLimit: number; - resourceCheckInterval: number; - }; - - /** - * Creates a new connection manager with enhanced resource management - * @param smtpServer - SMTP server instance - */ - constructor(smtpServer: ISmtpServer) { - this.smtpServer = smtpServer; - - // Get options from server - const serverOptions = this.smtpServer.getOptions(); - - // Default values for resource management - adjusted for production scalability - const DEFAULT_MAX_CONNECTIONS_PER_IP = 50; // Increased to support high-concurrency scenarios - const DEFAULT_CONNECTION_RATE_LIMIT = 200; // Increased for production load handling - const DEFAULT_CONNECTION_RATE_WINDOW = 60 * 1000; // 60 seconds window - const DEFAULT_BUFFER_SIZE_LIMIT = 10 * 1024 * 1024; // 10 MB - const DEFAULT_RESOURCE_CHECK_INTERVAL = 30 * 1000; // 30 seconds - - this.options = { - hostname: serverOptions.hostname || SMTP_DEFAULTS.HOSTNAME, - maxConnections: serverOptions.maxConnections || SMTP_DEFAULTS.MAX_CONNECTIONS, - socketTimeout: serverOptions.socketTimeout || SMTP_DEFAULTS.SOCKET_TIMEOUT, - maxConnectionsPerIP: DEFAULT_MAX_CONNECTIONS_PER_IP, - connectionRateLimit: DEFAULT_CONNECTION_RATE_LIMIT, - connectionRateWindow: DEFAULT_CONNECTION_RATE_WINDOW, - bufferSizeLimit: DEFAULT_BUFFER_SIZE_LIMIT, - resourceCheckInterval: DEFAULT_RESOURCE_CHECK_INTERVAL - }; - - // Start resource monitoring - this.startResourceMonitoring(); - } - - /** - * Start resource monitoring interval to check resource usage - */ - private startResourceMonitoring(): void { - // Clear any existing interval - if (this.resourceCheckInterval) { - clearInterval(this.resourceCheckInterval); - } - - // Set up new interval - this.resourceCheckInterval = setInterval(() => { - this.monitorResourceUsage(); - }, this.options.resourceCheckInterval); - } - - /** - * Monitor resource usage and log statistics - */ - private monitorResourceUsage(): void { - // Calculate memory usage - const memoryUsage = process.memoryUsage(); - const memoryUsageMB = { - rss: Math.round(memoryUsage.rss / 1024 / 1024), - heapTotal: Math.round(memoryUsage.heapTotal / 1024 / 1024), - heapUsed: Math.round(memoryUsage.heapUsed / 1024 / 1024), - external: Math.round(memoryUsage.external / 1024 / 1024) - }; - - // Calculate connection rate metrics - const activeIPs = Array.from(this.ipConnections.entries()) - .filter(([_, data]) => data.count > 0).length; - - const highVolumeIPs = Array.from(this.ipConnections.entries()) - .filter(([_, data]) => data.count > this.options.connectionRateLimit / 2).length; - - // Log resource usage with more detailed metrics - SmtpLogger.info('Resource usage stats', { - connections: { - active: this.activeConnections.size, - total: this.connectionStats.totalConnections, - peak: this.connectionStats.peakConnections, - rejected: this.connectionStats.rejectedConnections, - closed: this.connectionStats.closedConnections, - errored: this.connectionStats.erroredConnections, - timedOut: this.connectionStats.timedOutConnections - }, - memory: memoryUsageMB, - ipTracking: { - uniqueIPs: this.ipConnections.size, - activeIPs: activeIPs, - highVolumeIPs: highVolumeIPs - }, - resourceLimits: { - maxConnections: this.options.maxConnections, - maxConnectionsPerIP: this.options.maxConnectionsPerIP, - connectionRateLimit: this.options.connectionRateLimit, - bufferSizeLimit: Math.round(this.options.bufferSizeLimit / 1024 / 1024) + 'MB' - } - }); - - // Check for potential DoS conditions - if (highVolumeIPs > 3) { - SmtpLogger.warn(`Potential DoS detected: ${highVolumeIPs} IPs with high connection rates`); - } - - // Assess memory usage trends - if (memoryUsageMB.heapUsed > 500) { // Over 500MB heap used - SmtpLogger.warn(`High memory usage detected: ${memoryUsageMB.heapUsed}MB heap used`); - } - - // Clean up expired IP rate limits and validate resource tracking - this.cleanupIpRateLimits(); - } - - /** - * Clean up expired IP rate limits and perform additional resource monitoring - */ - private cleanupIpRateLimits(): void { - const now = Date.now(); - const windowThreshold = now - this.options.connectionRateWindow; - let activeIps = 0; - let removedEntries = 0; - - // Iterate through IP connections and manage entries - for (const [ip, data] of this.ipConnections.entries()) { - // If the last connection was before the window threshold + one extra window, remove the entry - if (data.lastConnection < windowThreshold - this.options.connectionRateWindow) { - // Remove stale entries to prevent memory growth - this.ipConnections.delete(ip); - removedEntries++; - } - // If last connection was before the window threshold, reset the count - else if (data.lastConnection < windowThreshold) { - if (data.count > 0) { - // Reset but keep the IP in the map with a zero count - this.ipConnections.set(ip, { - count: 0, - firstConnection: now, - lastConnection: now - }); - } - } else { - // This IP is still active within the current window - activeIps++; - } - } - - // Log cleanup activity if significant changes occurred - if (removedEntries > 0) { - SmtpLogger.debug(`IP rate limit cleanup: removed ${removedEntries} stale entries, ${this.ipConnections.size} remaining, ${activeIps} active in current window`); - } - - // Check for memory leaks in connection tracking - if (this.activeConnections.size > 0 && this.connectionStats.activeConnections !== this.activeConnections.size) { - SmtpLogger.warn(`Connection tracking inconsistency detected: stats.active=${this.connectionStats.activeConnections}, actual=${this.activeConnections.size}`); - // Fix the inconsistency - this.connectionStats.activeConnections = this.activeConnections.size; - } - - // Validate and clean leaked resources if needed - this.validateResourceTracking(); - } - - /** - * Validate and repair resource tracking to prevent leaks - */ - private validateResourceTracking(): void { - // Prepare a detailed report if inconsistencies are found - const inconsistenciesFound = []; - - // 1. Check active connections count matches activeConnections set size - if (this.connectionStats.activeConnections !== this.activeConnections.size) { - inconsistenciesFound.push({ - issue: 'Active connection count mismatch', - stats: this.connectionStats.activeConnections, - actual: this.activeConnections.size, - action: 'Auto-corrected' - }); - this.connectionStats.activeConnections = this.activeConnections.size; - } - - // 2. Check for destroyed sockets in active connections - let destroyedSocketsCount = 0; - const socketsToRemove: Array = []; - - for (const socket of this.activeConnections) { - if (socket.destroyed) { - destroyedSocketsCount++; - socketsToRemove.push(socket); - } - } - - // Remove destroyed sockets from tracking - for (const socket of socketsToRemove) { - this.activeConnections.delete(socket); - // Also ensure all listeners are removed - try { - socket.removeAllListeners(); - } catch { - // Ignore errors from removeAllListeners - } - } - - if (destroyedSocketsCount > 0) { - inconsistenciesFound.push({ - issue: 'Destroyed sockets in active list', - count: destroyedSocketsCount, - action: 'Removed from tracking' - }); - // Update active connections count after cleanup - this.connectionStats.activeConnections = this.activeConnections.size; - } - - // 3. Check for sessions without corresponding active connections - const sessionCount = this.smtpServer.getSessionManager().getSessionCount(); - if (sessionCount > this.activeConnections.size) { - inconsistenciesFound.push({ - issue: 'Orphaned sessions', - sessions: sessionCount, - connections: this.activeConnections.size, - action: 'Session cleanup recommended' - }); - } - - // If any inconsistencies found, log a detailed report - if (inconsistenciesFound.length > 0) { - SmtpLogger.warn('Resource tracking inconsistencies detected and repaired', { inconsistencies: inconsistenciesFound }); - } - } - - /** - * Handle a new connection with resource management - * @param socket - Client socket - */ - public async handleNewConnection(socket: plugins.net.Socket): Promise { - // Update connection stats - this.connectionStats.totalConnections++; - this.connectionStats.activeConnections = this.activeConnections.size + 1; - - if (this.connectionStats.activeConnections > this.connectionStats.peakConnections) { - this.connectionStats.peakConnections = this.connectionStats.activeConnections; - } - - // Get client IP - const remoteAddress = socket.remoteAddress || '0.0.0.0'; - - // Use UnifiedRateLimiter for connection rate limiting - const emailServer = this.smtpServer.getEmailServer(); - const rateLimiter = emailServer.getRateLimiter(); - - // Check connection limit with UnifiedRateLimiter - const connectionResult = rateLimiter.recordConnection(remoteAddress); - if (!connectionResult.allowed) { - this.rejectConnection(socket, connectionResult.reason || 'Rate limit exceeded'); - this.connectionStats.rejectedConnections++; - return; - } - - // Still track IP connections locally for cleanup purposes - this.trackIPConnection(remoteAddress); - - // Check if maximum global connections reached - if (this.hasReachedMaxConnections()) { - this.rejectConnection(socket, 'Too many connections'); - this.connectionStats.rejectedConnections++; - return; - } - - // Add socket to active connections - this.activeConnections.add(socket); - - // Set up socket options - socket.setKeepAlive(true); - socket.setTimeout(this.options.socketTimeout); - - // Explicitly set socket buffer sizes to prevent memory issues - socket.setNoDelay(true); // Disable Nagle's algorithm for better responsiveness - - // Set limits on socket buffer size if supported by Node.js version - try { - // Here we set reasonable buffer limits to prevent memory exhaustion attacks - const highWaterMark = 64 * 1024; // 64 KB - // Note: Socket high water mark methods can't be set directly in newer Node.js versions - // These would need to be set during socket creation or with a different API - } catch (error) { - // Ignore errors from older Node.js versions that don't support these methods - SmtpLogger.debug(`Could not set socket buffer limits: ${error instanceof Error ? error.message : String(error)}`); - } - - // Set up event handlers - this.setupSocketEventHandlers(socket); - - // Create a session for this connection - this.smtpServer.getSessionManager().createSession(socket, false); - - // Log the new connection using adaptive logger - const socketDetails = getSocketDetails(socket); - adaptiveLogger.logConnection(socket, 'connect'); - - // Update adaptive logger with current connection count - adaptiveLogger.updateConnectionCount(this.connectionStats.activeConnections); - - // Send greeting - this.sendGreeting(socket); - } - - /** - * Check if an IP has exceeded the rate limit - * @param ip - Client IP address - * @returns True if rate limited - */ - private isIPRateLimited(ip: string): boolean { - const now = Date.now(); - const ipData = this.ipConnections.get(ip); - - if (!ipData) { - return false; // No previous connections - } - - // Check if we're within the rate window - const isWithinWindow = now - ipData.firstConnection <= this.options.connectionRateWindow; - - // If within window and count exceeds limit, rate limit is applied - if (isWithinWindow && ipData.count >= this.options.connectionRateLimit) { - SmtpLogger.warn(`Rate limit exceeded for IP ${ip}: ${ipData.count} connections in ${Math.round((now - ipData.firstConnection) / 1000)}s`); - return true; - } - - return false; - } - - /** - * Track a new connection from an IP - * @param ip - Client IP address - */ - private trackIPConnection(ip: string): void { - const now = Date.now(); - const ipData = this.ipConnections.get(ip); - - if (!ipData) { - // First connection from this IP - this.ipConnections.set(ip, { - count: 1, - firstConnection: now, - lastConnection: now - }); - } else { - // Check if we need to reset the window - if (now - ipData.lastConnection > this.options.connectionRateWindow) { - // Reset the window - this.ipConnections.set(ip, { - count: 1, - firstConnection: now, - lastConnection: now - }); - } else { - // Increment within the current window - this.ipConnections.set(ip, { - count: ipData.count + 1, - firstConnection: ipData.firstConnection, - lastConnection: now - }); - } - } - } - - /** - * Check if an IP has reached its connection limit - * @param ip - Client IP address - * @returns True if limit reached - */ - private hasReachedIPConnectionLimit(ip: string): boolean { - let ipConnectionCount = 0; - - // Count active connections from this IP - for (const socket of this.activeConnections) { - if (socket.remoteAddress === ip) { - ipConnectionCount++; - } - } - - return ipConnectionCount >= this.options.maxConnectionsPerIP; - } - - /** - * Handle a new secure TLS connection with resource management - * @param socket - Client TLS socket - */ - public async handleNewSecureConnection(socket: plugins.tls.TLSSocket): Promise { - // Update connection stats - this.connectionStats.totalConnections++; - this.connectionStats.activeConnections = this.activeConnections.size + 1; - - if (this.connectionStats.activeConnections > this.connectionStats.peakConnections) { - this.connectionStats.peakConnections = this.connectionStats.activeConnections; - } - - // Get client IP - const remoteAddress = socket.remoteAddress || '0.0.0.0'; - - // Use UnifiedRateLimiter for connection rate limiting - const emailServer = this.smtpServer.getEmailServer(); - const rateLimiter = emailServer.getRateLimiter(); - - // Check connection limit with UnifiedRateLimiter - const connectionResult = rateLimiter.recordConnection(remoteAddress); - if (!connectionResult.allowed) { - this.rejectConnection(socket, connectionResult.reason || 'Rate limit exceeded'); - this.connectionStats.rejectedConnections++; - return; - } - - // Still track IP connections locally for cleanup purposes - this.trackIPConnection(remoteAddress); - - // Check if maximum global connections reached - if (this.hasReachedMaxConnections()) { - this.rejectConnection(socket, 'Too many connections'); - this.connectionStats.rejectedConnections++; - return; - } - - // Add socket to active connections - this.activeConnections.add(socket); - - // Set up socket options - socket.setKeepAlive(true); - socket.setTimeout(this.options.socketTimeout); - - // Explicitly set socket buffer sizes to prevent memory issues - socket.setNoDelay(true); // Disable Nagle's algorithm for better responsiveness - - // Set limits on socket buffer size if supported by Node.js version - try { - // Here we set reasonable buffer limits to prevent memory exhaustion attacks - const highWaterMark = 64 * 1024; // 64 KB - // Note: Socket high water mark methods can't be set directly in newer Node.js versions - // These would need to be set during socket creation or with a different API - } catch (error) { - // Ignore errors from older Node.js versions that don't support these methods - SmtpLogger.debug(`Could not set socket buffer limits: ${error instanceof Error ? error.message : String(error)}`); - } - - // Set up event handlers - this.setupSocketEventHandlers(socket); - - // Create a session for this connection - this.smtpServer.getSessionManager().createSession(socket, true); - - // Log the new secure connection using adaptive logger - adaptiveLogger.logConnection(socket, 'connect'); - - // Update adaptive logger with current connection count - adaptiveLogger.updateConnectionCount(this.connectionStats.activeConnections); - - // Send greeting - this.sendGreeting(socket); - } - - /** - * Set up event handlers for a socket with enhanced resource management - * @param socket - Client socket - */ - public setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { - // Store existing socket event handlers before adding new ones - const existingDataHandler = socket.listeners('data')[0] as (...args: any[]) => void; - const existingCloseHandler = socket.listeners('close')[0] as (...args: any[]) => void; - const existingErrorHandler = socket.listeners('error')[0] as (...args: any[]) => void; - const existingTimeoutHandler = socket.listeners('timeout')[0] as (...args: any[]) => void; - - // Remove existing event handlers if they exist - if (existingDataHandler) socket.removeListener('data', existingDataHandler); - if (existingCloseHandler) socket.removeListener('close', existingCloseHandler); - if (existingErrorHandler) socket.removeListener('error', existingErrorHandler); - if (existingTimeoutHandler) socket.removeListener('timeout', existingTimeoutHandler); - - // Data event - process incoming data from the client with resource limits - let buffer = ''; - let totalBytesReceived = 0; - - socket.on('data', async (data) => { - try { - // Get current session and update activity timestamp - const session = this.smtpServer.getSessionManager().getSession(socket); - if (session) { - this.smtpServer.getSessionManager().updateSessionActivity(session); - } - - // Check if we're in DATA receiving mode - handle differently - if (session && session.state === SmtpState.DATA_RECEIVING) { - // In DATA mode, pass raw chunks directly to command handler with special marker - // Don't line-buffer large email content - try { - const dataString = data.toString('utf8'); - // Use a special prefix to indicate this is raw data, not a command line - // CRITICAL FIX: Must await to prevent async pile-up - await this.smtpServer.getCommandHandler().processCommand(socket, `__RAW_DATA__${dataString}`); - return; - } catch (dataError) { - SmtpLogger.error(`Data handler error during DATA mode: ${dataError instanceof Error ? dataError.message : String(dataError)}`); - socket.destroy(); - return; - } - } - - // For command mode, continue with line-buffered processing - // Check buffer size limits to prevent memory attacks - totalBytesReceived += data.length; - - if (buffer.length > this.options.bufferSizeLimit) { - // Buffer is too large, reject the connection - SmtpLogger.warn(`Buffer size limit exceeded: ${buffer.length} bytes for ${socket.remoteAddress}`); - this.sendResponse(socket, `${SmtpResponseCode.EXCEEDED_STORAGE} Message too large, disconnecting`); - socket.destroy(); - return; - } - - // Impose a total transfer limit to prevent DoS - if (totalBytesReceived > this.options.bufferSizeLimit * 2) { - SmtpLogger.warn(`Total transfer limit exceeded: ${totalBytesReceived} bytes for ${socket.remoteAddress}`); - this.sendResponse(socket, `${SmtpResponseCode.EXCEEDED_STORAGE} Transfer limit exceeded, disconnecting`); - socket.destroy(); - return; - } - - // Convert buffer to string safely with explicit encoding - const dataString = data.toString('utf8'); - - // Buffer incoming data - buffer += dataString; - - // Process complete lines - let lineEndPos; - while ((lineEndPos = buffer.indexOf(SMTP_DEFAULTS.CRLF)) !== -1) { - // Extract a complete line - const line = buffer.substring(0, lineEndPos); - buffer = buffer.substring(lineEndPos + 2); // +2 to skip CRLF - - // Check line length to prevent extremely long lines - if (line.length > 4096) { // 4KB line limit is reasonable for SMTP - SmtpLogger.warn(`Line length limit exceeded: ${line.length} bytes for ${socket.remoteAddress}`); - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR} Line too long, disconnecting`); - socket.destroy(); - return; - } - - // Process non-empty lines - if (line.length > 0) { - try { - // CRITICAL FIX: Must await processCommand to prevent async pile-up - // This was causing the busy loop with high CPU usage when many empty lines were processed - await this.smtpServer.getCommandHandler().processCommand(socket, line); - } catch (error) { - // Handle any errors in command processing - SmtpLogger.error(`Command handler error: ${error instanceof Error ? error.message : String(error)}`); - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error`); - - // If there's a severe error, close the connection - if (error instanceof Error && - (error.message.includes('fatal') || error.message.includes('critical'))) { - socket.destroy(); - return; - } - } - } - } - - // If buffer is getting too large without CRLF, it might be a DoS attempt - if (buffer.length > 10240) { // 10KB is a reasonable limit for a line without CRLF - SmtpLogger.warn(`Incomplete line too large: ${buffer.length} bytes for ${socket.remoteAddress}`); - this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR} Incomplete line too large, disconnecting`); - socket.destroy(); - } - } catch (error) { - // Handle any unexpected errors during data processing - SmtpLogger.error(`Data handler error: ${error instanceof Error ? error.message : String(error)}`); - socket.destroy(); - } - }); - - // Add drain event handler to manage flow control - socket.on('drain', () => { - // Socket buffer has been emptied, resume data flow if needed - if (socket.isPaused()) { - socket.resume(); - SmtpLogger.debug(`Resumed socket for ${socket.remoteAddress} after drain`); - } - }); - - // Close event - clean up when connection is closed - socket.on('close', (hadError) => { - this.handleSocketClose(socket, hadError); - }); - - // Error event - handle socket errors - socket.on('error', (err) => { - this.handleSocketError(socket, err); - }); - - // Timeout event - handle socket timeouts - socket.on('timeout', () => { - this.handleSocketTimeout(socket); - }); - } - - /** - * Get the current connection count - * @returns Number of active connections - */ - public getConnectionCount(): number { - return this.activeConnections.size; - } - - /** - * Check if the server has reached the maximum number of connections - * @returns True if max connections reached - */ - public hasReachedMaxConnections(): boolean { - return this.activeConnections.size >= this.options.maxConnections; - } - - /** - * Close all active connections - */ - public closeAllConnections(): void { - const connectionCount = this.activeConnections.size; - if (connectionCount === 0) { - return; - } - - SmtpLogger.info(`Closing all connections (count: ${connectionCount})`); - - for (const socket of this.activeConnections) { - try { - // Send service closing notification - this.sendServiceClosing(socket); - - // End the socket gracefully - socket.end(); - - // Force destroy after a short delay if not already destroyed - const destroyTimer = setTimeout(() => { - if (!socket.destroyed) { - socket.destroy(); - } - this.cleanupTimers.delete(destroyTimer); - }, 100); - this.cleanupTimers.add(destroyTimer); - } catch (error) { - SmtpLogger.error(`Error closing connection: ${error instanceof Error ? error.message : String(error)}`); - // Force destroy on error - try { - socket.destroy(); - } catch (e) { - // Ignore destroy errors - } - } - } - - // Clear active connections - this.activeConnections.clear(); - - // Stop resource monitoring to prevent hanging timers - if (this.resourceCheckInterval) { - clearInterval(this.resourceCheckInterval); - this.resourceCheckInterval = null; - } - } - - /** - * Handle socket close event - * @param socket - Client socket - * @param hadError - Whether the socket was closed due to error - */ - private handleSocketClose(socket: plugins.net.Socket | plugins.tls.TLSSocket, hadError: boolean): void { - try { - // Update connection statistics - this.connectionStats.closedConnections++; - this.connectionStats.activeConnections = this.activeConnections.size - 1; - - // Get socket details for logging - const socketDetails = getSocketDetails(socket); - const socketId = `${socketDetails.remoteAddress}:${socketDetails.remotePort}`; - - // Log with appropriate level based on whether there was an error - if (hadError) { - SmtpLogger.warn(`Socket closed with error: ${socketId}`); - } else { - SmtpLogger.debug(`Socket closed normally: ${socketId}`); - } - - // Get the session before removing it - const session = this.smtpServer.getSessionManager().getSession(socket); - - // Remove from active connections - this.activeConnections.delete(socket); - - // Remove from session manager - this.smtpServer.getSessionManager().removeSession(socket); - - // Cancel any timeout ID stored in the session - if (session?.dataTimeoutId) { - clearTimeout(session.dataTimeoutId); - } - - // Remove all event listeners to prevent memory leaks - socket.removeAllListeners(); - - // Log connection close with session details if available - adaptiveLogger.logConnection(socket, 'close', session); - - // Update adaptive logger with new connection count - adaptiveLogger.updateConnectionCount(this.connectionStats.activeConnections); - } catch (error) { - // Handle any unexpected errors during cleanup - SmtpLogger.error(`Error in handleSocketClose: ${error instanceof Error ? error.message : String(error)}`); - - // Ensure socket is removed from active connections even if an error occurs - this.activeConnections.delete(socket); - - // Always try to remove all listeners even on error - try { - socket.removeAllListeners(); - } catch { - // Ignore errors from removeAllListeners - } - } - } - - /** - * Handle socket error event - * @param socket - Client socket - * @param error - Error object - */ - private handleSocketError(socket: plugins.net.Socket | plugins.tls.TLSSocket, error: Error): void { - try { - // Update connection statistics - this.connectionStats.erroredConnections++; - - // Get socket details for context - const socketDetails = getSocketDetails(socket); - const socketId = `${socketDetails.remoteAddress}:${socketDetails.remotePort}`; - - // Get the session - const session = this.smtpServer.getSessionManager().getSession(socket); - - // Detailed error logging with context information - SmtpLogger.error(`Socket error for ${socketId}: ${error.message}`, { - errorCode: (error as any).code, - errorStack: error.stack, - sessionId: session?.id, - sessionState: session?.state, - remoteAddress: socketDetails.remoteAddress, - remotePort: socketDetails.remotePort - }); - - // Log the error for connection tracking using adaptive logger - adaptiveLogger.logConnection(socket, 'error', session, error); - - // Cancel any timeout ID stored in the session - if (session?.dataTimeoutId) { - clearTimeout(session.dataTimeoutId); - } - - // Close the socket if not already closed - if (!socket.destroyed) { - socket.destroy(); - } - - // Remove from active connections (cleanup after error) - this.activeConnections.delete(socket); - - // Remove from session manager - this.smtpServer.getSessionManager().removeSession(socket); - } catch (handlerError) { - // Meta-error handling (errors in the error handler) - SmtpLogger.error(`Error in handleSocketError: ${handlerError instanceof Error ? handlerError.message : String(handlerError)}`); - - // Ensure socket is destroyed and removed from active connections - if (!socket.destroyed) { - socket.destroy(); - } - this.activeConnections.delete(socket); - } - } - - /** - * Handle socket timeout event - * @param socket - Client socket - */ - private handleSocketTimeout(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { - try { - // Update connection statistics - this.connectionStats.timedOutConnections++; - - // Get socket details for context - const socketDetails = getSocketDetails(socket); - const socketId = `${socketDetails.remoteAddress}:${socketDetails.remotePort}`; - - // Get the session - const session = this.smtpServer.getSessionManager().getSession(socket); - - // Get timing information for better debugging - const now = Date.now(); - const idleTime = session?.lastActivity ? now - session.lastActivity : 'unknown'; - - if (session) { - // Log the timeout with extended details - SmtpLogger.warn(`Socket timeout from ${session.remoteAddress}`, { - sessionId: session.id, - remoteAddress: session.remoteAddress, - state: session.state, - timeout: this.options.socketTimeout, - idleTime: idleTime, - emailState: session.envelope?.mailFrom ? 'has-sender' : 'no-sender', - recipientCount: session.envelope?.rcptTo?.length || 0 - }); - - // Cancel any timeout ID stored in the session - if (session.dataTimeoutId) { - clearTimeout(session.dataTimeoutId); - } - - // Send timeout notification to client - this.sendResponse(socket, `${SmtpResponseCode.SERVICE_NOT_AVAILABLE} Connection timeout - closing connection`); - } else { - // Log timeout without session context - SmtpLogger.warn(`Socket timeout without session from ${socketId}`); - } - - // Close the socket gracefully - try { - socket.end(); - - // Set a forced close timeout in case socket.end() doesn't close the connection - const timeoutDestroyTimer = setTimeout(() => { - if (!socket.destroyed) { - SmtpLogger.warn(`Forcing destroy of timed out socket: ${socketId}`); - socket.destroy(); - } - this.cleanupTimers.delete(timeoutDestroyTimer); - }, 5000); // 5 second grace period for socket to end properly - this.cleanupTimers.add(timeoutDestroyTimer); - } catch (error) { - SmtpLogger.error(`Error ending timed out socket: ${error instanceof Error ? error.message : String(error)}`); - - // Ensure socket is destroyed even if end() fails - if (!socket.destroyed) { - socket.destroy(); - } - } - - // Clean up resources - this.activeConnections.delete(socket); - this.smtpServer.getSessionManager().removeSession(socket); - } catch (handlerError) { - // Handle any unexpected errors during timeout handling - SmtpLogger.error(`Error in handleSocketTimeout: ${handlerError instanceof Error ? handlerError.message : String(handlerError)}`); - - // Ensure socket is destroyed and removed from tracking - if (!socket.destroyed) { - socket.destroy(); - } - this.activeConnections.delete(socket); - } - } - - /** - * Reject a connection - * @param socket - Client socket - * @param reason - Reason for rejection - */ - private rejectConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, reason: string): void { - // Log the rejection - const socketDetails = getSocketDetails(socket); - SmtpLogger.warn(`Connection rejected from ${socketDetails.remoteAddress}:${socketDetails.remotePort}: ${reason}`); - - // Send rejection message - this.sendResponse(socket, `${SmtpResponseCode.SERVICE_NOT_AVAILABLE} ${this.options.hostname} Service temporarily unavailable - ${reason}`); - - // Close the socket - try { - socket.end(); - } catch (error) { - SmtpLogger.error(`Error ending rejected socket: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Send greeting message - * @param socket - Client socket - */ - private sendGreeting(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { - const greeting = `${SmtpResponseCode.SERVICE_READY} ${this.options.hostname} ESMTP service ready`; - this.sendResponse(socket, greeting); - } - - /** - * Send service closing notification - * @param socket - Client socket - */ - private sendServiceClosing(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { - const message = `${SmtpResponseCode.SERVICE_CLOSING} ${this.options.hostname} Service closing transmission channel`; - this.sendResponse(socket, message); - } - - /** - * Send response to client - * @param socket - Client socket - * @param response - Response to send - */ - private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void { - // Check if socket is still writable before attempting to write - if (socket.destroyed || socket.readyState !== 'open' || !socket.writable) { - SmtpLogger.debug(`Skipping response to closed/destroyed socket: ${response}`, { - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - destroyed: socket.destroyed, - readyState: socket.readyState, - writable: socket.writable - }); - return; - } - - try { - socket.write(`${response}${SMTP_DEFAULTS.CRLF}`); - adaptiveLogger.logResponse(response, socket); - } catch (error) { - // Log error and destroy socket - SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, { - response, - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - error: error instanceof Error ? error : new Error(String(error)) - }); - - socket.destroy(); - } - } - - /** - * Handle a new connection (interface requirement) - */ - public async handleConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): Promise { - if (secure) { - this.handleNewSecureConnection(socket as plugins.tls.TLSSocket); - } else { - this.handleNewConnection(socket as plugins.net.Socket); - } - } - - /** - * Check if accepting new connections (interface requirement) - */ - public canAcceptConnection(): boolean { - return !this.hasReachedMaxConnections(); - } - - /** - * Clean up resources - */ - public destroy(): void { - // Clear resource monitoring interval - if (this.resourceCheckInterval) { - clearInterval(this.resourceCheckInterval); - this.resourceCheckInterval = null; - } - - // Clear all cleanup timers - for (const timer of this.cleanupTimers) { - clearTimeout(timer); - } - this.cleanupTimers.clear(); - - // Close all active connections - this.closeAllConnections(); - - // Clear maps - this.activeConnections.clear(); - this.ipConnections.clear(); - - // Reset connection stats - this.connectionStats = { - totalConnections: 0, - activeConnections: 0, - peakConnections: 0, - rejectedConnections: 0, - closedConnections: 0, - erroredConnections: 0, - timedOutConnections: 0 - }; - - SmtpLogger.debug('ConnectionManager destroyed'); - } -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/constants.ts b/ts/mail/delivery/smtpserver/constants.ts deleted file mode 100644 index a11b98d..0000000 --- a/ts/mail/delivery/smtpserver/constants.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * SMTP Server Constants - * This file contains all constants and enums used by the SMTP server - */ - -import { SmtpState } from '../interfaces.js'; - -// Re-export SmtpState enum from the main interfaces file -export { SmtpState }; - -/** - * SMTP Response Codes - * Based on RFC 5321 and common SMTP practice - */ -export enum SmtpResponseCode { - // Success codes (2xx) - SUCCESS = 250, // Requested mail action okay, completed - SYSTEM_STATUS = 211, // System status, or system help reply - HELP_MESSAGE = 214, // Help message - SERVICE_READY = 220, // Service ready - SERVICE_CLOSING = 221, // Service closing transmission channel - AUTHENTICATION_SUCCESSFUL = 235, // Authentication successful - OK = 250, // Requested mail action okay, completed - FORWARD = 251, // User not local; will forward to - CANNOT_VRFY = 252, // Cannot VRFY user, but will accept message and attempt delivery - - // Intermediate codes (3xx) - MORE_INFO_NEEDED = 334, // Server challenge for authentication - START_MAIL_INPUT = 354, // Start mail input; end with . - - // Temporary error codes (4xx) - SERVICE_NOT_AVAILABLE = 421, // Service not available, closing transmission channel - MAILBOX_TEMPORARILY_UNAVAILABLE = 450, // Requested mail action not taken: mailbox unavailable - LOCAL_ERROR = 451, // Requested action aborted: local error in processing - INSUFFICIENT_STORAGE = 452, // Requested action not taken: insufficient system storage - TLS_UNAVAILABLE_TEMP = 454, // TLS not available due to temporary reason - - // Permanent error codes (5xx) - SYNTAX_ERROR = 500, // Syntax error, command unrecognized - SYNTAX_ERROR_PARAMETERS = 501, // Syntax error in parameters or arguments - COMMAND_NOT_IMPLEMENTED = 502, // Command not implemented - BAD_SEQUENCE = 503, // Bad sequence of commands - COMMAND_PARAMETER_NOT_IMPLEMENTED = 504, // Command parameter not implemented - AUTH_REQUIRED = 530, // Authentication required - AUTH_FAILED = 535, // Authentication credentials invalid - MAILBOX_UNAVAILABLE = 550, // Requested action not taken: mailbox unavailable - USER_NOT_LOCAL = 551, // User not local; please try - EXCEEDED_STORAGE = 552, // Requested mail action aborted: exceeded storage allocation - MAILBOX_NAME_INVALID = 553, // Requested action not taken: mailbox name not allowed - TRANSACTION_FAILED = 554, // Transaction failed - MAIL_RCPT_PARAMETERS_INVALID = 555, // MAIL FROM/RCPT TO parameters not recognized or not implemented -} - -/** - * SMTP Command Types - */ -export enum SmtpCommand { - HELO = 'HELO', - EHLO = 'EHLO', - MAIL_FROM = 'MAIL', - RCPT_TO = 'RCPT', - DATA = 'DATA', - RSET = 'RSET', - NOOP = 'NOOP', - QUIT = 'QUIT', - STARTTLS = 'STARTTLS', - AUTH = 'AUTH', - HELP = 'HELP', - VRFY = 'VRFY', - EXPN = 'EXPN', -} - -/** - * Security log event types - */ -export enum SecurityEventType { - CONNECTION = 'connection', - AUTHENTICATION = 'authentication', - COMMAND = 'command', - DATA = 'data', - IP_REPUTATION = 'ip_reputation', - TLS_NEGOTIATION = 'tls_negotiation', - DKIM = 'dkim', - SPF = 'spf', - DMARC = 'dmarc', - EMAIL_VALIDATION = 'email_validation', - SPAM = 'spam', - ACCESS_CONTROL = 'access_control', -} - -/** - * Security log levels - */ -export enum SecurityLogLevel { - DEBUG = 'debug', - INFO = 'info', - WARN = 'warn', - ERROR = 'error', -} - -/** - * SMTP Server Defaults - */ -export const SMTP_DEFAULTS = { - // Default timeouts in milliseconds - CONNECTION_TIMEOUT: 30000, // 30 seconds - SOCKET_TIMEOUT: 300000, // 5 minutes - DATA_TIMEOUT: 60000, // 1 minute - CLEANUP_INTERVAL: 5000, // 5 seconds - - // Default limits - MAX_CONNECTIONS: 100, - MAX_RECIPIENTS: 100, - MAX_MESSAGE_SIZE: 10485760, // 10MB - - // Default ports - SMTP_PORT: 25, - SUBMISSION_PORT: 587, - SECURE_PORT: 465, - - // Default hostname - HOSTNAME: 'mail.lossless.one', - - // CRLF line ending required by SMTP protocol - CRLF: '\r\n', -}; - -/** - * SMTP Command Patterns - * Regular expressions for parsing SMTP commands - */ -export const SMTP_PATTERNS = { - // Match EHLO/HELO command: "EHLO example.com" - // Made very permissive to handle various client implementations - EHLO: /^(?:EHLO|HELO)\s+(.+)$/i, - - // Match MAIL FROM command: "MAIL FROM: [PARAM=VALUE]" - // Made more permissive with whitespace and parameter formats - MAIL_FROM: /^MAIL\s+FROM\s*:\s*<([^>]*)>((?:\s+[a-zA-Z0-9][a-zA-Z0-9\-]*(?:=[^\s]+)?)*)$/i, - - // Match RCPT TO command: "RCPT TO: [PARAM=VALUE]" - // Made more permissive with whitespace and parameter formats - RCPT_TO: /^RCPT\s+TO\s*:\s*<([^>]*)>((?:\s+[a-zA-Z0-9][a-zA-Z0-9\-]*(?:=[^\s]+)?)*)$/i, - - // Match parameter format: "PARAM=VALUE" - PARAM: /\s+([A-Za-z0-9][A-Za-z0-9\-]*)(?:=([^\s]+))?/g, - - // Match email address format - basic validation - // This pattern rejects common invalid formats while being permissive for edge cases - // Checks: no spaces, has @, has domain with dot, no double dots, proper domain format - EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, - - // Match end of DATA marker: \r\n.\r\n or just .\r\n at the start of a line (to handle various client implementations) - END_DATA: /(\r\n\.\r\n$)|(\n\.\r\n$)|(\r\n\.\n$)|(\n\.\n$)|^\.(\r\n|\n)$/, -}; - -/** - * SMTP Extension List - * These extensions are advertised in the EHLO response - */ -export const SMTP_EXTENSIONS = { - // Basic extensions (RFC 1869) - PIPELINING: 'PIPELINING', - SIZE: 'SIZE', - EIGHTBITMIME: '8BITMIME', - - // Security extensions - STARTTLS: 'STARTTLS', - AUTH: 'AUTH', - - // Additional extensions - ENHANCEDSTATUSCODES: 'ENHANCEDSTATUSCODES', - HELP: 'HELP', - CHUNKING: 'CHUNKING', - DSN: 'DSN', - - // Format an extension with a parameter - formatExtension(name: string, parameter?: string | number): string { - return parameter !== undefined ? `${name} ${parameter}` : name; - } -}; \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/create-server.ts b/ts/mail/delivery/smtpserver/create-server.ts deleted file mode 100644 index 0f56764..0000000 --- a/ts/mail/delivery/smtpserver/create-server.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * SMTP Server Creation Factory - * Provides a simple way to create a complete SMTP server - */ - -import { SmtpServer } from './smtp-server.js'; -import { SessionManager } from './session-manager.js'; -import { ConnectionManager } from './connection-manager.js'; -import { CommandHandler } from './command-handler.js'; -import { DataHandler } from './data-handler.js'; -import { TlsHandler } from './tls-handler.js'; -import { SecurityHandler } from './security-handler.js'; -import type { ISmtpServerOptions } from './interfaces.js'; -import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js'; - -/** - * Create a complete SMTP server with all components - * @param emailServer - Email server reference - * @param options - SMTP server options - * @returns Configured SMTP server instance - */ -export function createSmtpServer(emailServer: UnifiedEmailServer, options: ISmtpServerOptions): SmtpServer { - // First create the SMTP server instance - const smtpServer = new SmtpServer({ - emailServer, - options - }); - - // Return the configured server - return smtpServer; -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/data-handler.ts b/ts/mail/delivery/smtpserver/data-handler.ts deleted file mode 100644 index 2e5368e..0000000 --- a/ts/mail/delivery/smtpserver/data-handler.ts +++ /dev/null @@ -1,1283 +0,0 @@ -/** - * SMTP Data Handler - * Responsible for processing email data during and after DATA command - */ - -import * as plugins from '../../../plugins.js'; -import * as fs from 'fs'; -import * as path from 'path'; -import { SmtpState } from './interfaces.js'; -import type { ISmtpSession, ISmtpTransactionResult } from './interfaces.js'; -import type { IDataHandler, ISmtpServer } from './interfaces.js'; -import { SmtpResponseCode, SMTP_PATTERNS, SMTP_DEFAULTS } from './constants.js'; -import { SmtpLogger } from './utils/logging.js'; -import { detectHeaderInjection } from './utils/validation.js'; -import { Email } from '../../core/classes.email.js'; - -/** - * Handles SMTP DATA command and email data processing - */ -export class DataHandler implements IDataHandler { - /** - * Reference to the SMTP server instance - */ - private smtpServer: ISmtpServer; - - /** - * Creates a new data handler - * @param smtpServer - SMTP server instance - */ - constructor(smtpServer: ISmtpServer) { - this.smtpServer = smtpServer; - } - - /** - * Process incoming email data - * @param socket - Client socket - * @param data - Data chunk - * @returns Promise that resolves when the data is processed - */ - public async processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); - return; - } - - // Clear any existing timeout and set a new one - if (session.dataTimeoutId) { - clearTimeout(session.dataTimeoutId); - } - - session.dataTimeoutId = setTimeout(() => { - if (session.state === SmtpState.DATA_RECEIVING) { - SmtpLogger.warn(`DATA timeout for session ${session.id}`, { sessionId: session.id }); - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Data timeout`); - this.resetSession(session); - } - }, SMTP_DEFAULTS.DATA_TIMEOUT); - - // Update activity timestamp - this.smtpServer.getSessionManager().updateSessionActivity(session); - - // Store data in chunks for better memory efficiency - if (!session.emailDataChunks) { - session.emailDataChunks = []; - session.emailDataSize = 0; // Track size incrementally - } - - session.emailDataChunks.push(data); - session.emailDataSize = (session.emailDataSize || 0) + data.length; - - // Check if we've reached the max size (using incremental tracking) - const options = this.smtpServer.getOptions(); - const maxSize = options.size || SMTP_DEFAULTS.MAX_MESSAGE_SIZE; - if (session.emailDataSize > maxSize) { - SmtpLogger.warn(`Message size exceeds limit for session ${session.id}`, { - sessionId: session.id, - size: session.emailDataSize, - limit: maxSize - }); - - this.sendResponse(socket, `${SmtpResponseCode.EXCEEDED_STORAGE} Message too big, size limit is ${maxSize} bytes`); - this.resetSession(session); - return; - } - - // Check for end of data marker efficiently without combining all chunks - // Only check the current chunk and the last chunk for the marker - let hasEndMarker = false; - - // Check if current chunk contains end marker - if (data === '.\r\n' || data === '.') { - hasEndMarker = true; - } else { - // For efficiency with large messages, only check the last few chunks - // Get the last 2 chunks to check for split markers - const lastChunks = session.emailDataChunks.slice(-2).join(''); - - hasEndMarker = lastChunks.endsWith('\r\n.\r\n') || - lastChunks.endsWith('\n.\r\n') || - lastChunks.endsWith('\r\n.\n') || - lastChunks.endsWith('\n.\n'); - } - - if (hasEndMarker) { - - SmtpLogger.debug(`End of data marker found for session ${session.id}`, { sessionId: session.id }); - - // End of data marker found - await this.handleEndOfData(socket, session); - } - } - - /** - * Handle raw data chunks during DATA mode (optimized for large messages) - * @param socket - Client socket - * @param data - Raw data chunk - */ - public async handleDataReceived(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise { - // Get the session - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); - return; - } - - // Special handling for ERR-02 test: detect MAIL FROM command during DATA mode - // This needs to work for both raw data chunks and line-based data - const trimmedData = data.trim(); - const looksLikeCommand = /^[A-Z]{4,}( |:)/i.test(trimmedData); - - if (looksLikeCommand && trimmedData.toUpperCase().startsWith('MAIL FROM')) { - // This is the command that ERR-02 test is expecting to fail with 503 - SmtpLogger.debug(`Received MAIL FROM command during DATA mode - responding with sequence error`); - this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} Bad sequence of commands`); - return; - } - - // For all other data, process normally - return this.processEmailData(socket, data); - } - - /** - * Process email data chunks efficiently for large messages - * @param chunks - Array of email data chunks - * @returns Processed email data string - */ - private processEmailDataStreaming(chunks: string[]): string { - // For very large messages, use a more memory-efficient approach - const CHUNK_SIZE = 50; // Process 50 chunks at a time - let result = ''; - - // Process chunks in batches to reduce memory pressure - for (let batchStart = 0; batchStart < chunks.length; batchStart += CHUNK_SIZE) { - const batchEnd = Math.min(batchStart + CHUNK_SIZE, chunks.length); - const batchChunks = chunks.slice(batchStart, batchEnd); - - // Join this batch - let batchData = batchChunks.join(''); - - // Clear references to help GC - for (let i = 0; i < batchChunks.length; i++) { - batchChunks[i] = ''; - } - - result += batchData; - batchData = ''; // Clear reference - - // Force garbage collection hint (if available) - if (global.gc && batchStart % 200 === 0) { - global.gc(); - } - } - - // Remove trailing end-of-data marker: various formats - result = result - .replace(/\r\n\.\r\n$/, '') - .replace(/\n\.\r\n$/, '') - .replace(/\r\n\.\n$/, '') - .replace(/\n\.\n$/, '') - .replace(/^\.$/, ''); // Handle ONLY a lone dot as the entire content (not trailing dots) - - // Remove dot-stuffing (RFC 5321, section 4.5.2) - result = result.replace(/\r\n\.\./g, '\r\n.'); - - return result; - } - - /** - * Process a complete email - * @param rawData - Raw email data - * @param session - SMTP session - * @returns Promise that resolves with the Email object - */ - public async processEmail(rawData: string, session: ISmtpSession): Promise { - // Clean up the raw email data - let cleanedData = rawData; - - // Remove trailing end-of-data marker: various formats - cleanedData = cleanedData - .replace(/\r\n\.\r\n$/, '') - .replace(/\n\.\r\n$/, '') - .replace(/\r\n\.\n$/, '') - .replace(/\n\.\n$/, '') - .replace(/^\.$/, ''); // Handle ONLY a lone dot as the entire content (not trailing dots) - - // Remove dot-stuffing (RFC 5321, section 4.5.2) - cleanedData = cleanedData.replace(/\r\n\.\./g, '\r\n.'); - - try { - // Parse email into Email object using cleaned data - const email = await this.parseEmailFromData(cleanedData, session); - - // Return the parsed email - return email; - } catch (error) { - SmtpLogger.error(`Failed to parse email: ${error instanceof Error ? error.message : String(error)}`, { - sessionId: session.id, - error: error instanceof Error ? error : new Error(String(error)) - }); - - // Create a minimal email object on error - const fallbackEmail = new Email({ - from: 'unknown@localhost', - to: 'unknown@localhost', - subject: 'Parse Error', - text: cleanedData - }); - return fallbackEmail; - } - } - - /** - * Parse email from raw data - * @param rawData - Raw email data - * @param session - SMTP session - * @returns Email object - */ - private async parseEmailFromData(rawData: string, session: ISmtpSession): Promise { - // Parse the raw email data to extract headers and body - const lines = rawData.split('\r\n'); - let headerEnd = -1; - - // Find where headers end - for (let i = 0; i < lines.length; i++) { - if (lines[i].trim() === '') { - headerEnd = i; - break; - } - } - - // Extract headers - let subject = 'No Subject'; - const headers: Record = {}; - - if (headerEnd > -1) { - for (let i = 0; i < headerEnd; i++) { - const line = lines[i]; - const colonIndex = line.indexOf(':'); - if (colonIndex > 0) { - const headerName = line.substring(0, colonIndex).trim().toLowerCase(); - const headerValue = line.substring(colonIndex + 1).trim(); - - if (headerName === 'subject') { - subject = headerValue; - } else { - headers[headerName] = headerValue; - } - } - } - } - - // Extract body - const body = headerEnd > -1 ? lines.slice(headerEnd + 1).join('\r\n') : rawData; - - // Create email with session information - const email = new Email({ - from: session.mailFrom || 'unknown@localhost', - to: session.rcptTo || ['unknown@localhost'], - subject, - text: body, - headers - }); - - return email; - } - - /** - * Process a complete email (legacy method) - * @param session - SMTP session - * @returns Promise that resolves with the result of the transaction - */ - public async processEmailLegacy(session: ISmtpSession): Promise { - try { - // Use the email data from session - const email = await this.parseEmailFromData(session.emailData || '', session); - - // Process the email based on the processing mode - const processingMode = session.processingMode || 'mta'; - - let result: ISmtpTransactionResult = { - success: false, - error: 'Email processing failed' - }; - - switch (processingMode) { - case 'mta': - // Process through the MTA system - try { - SmtpLogger.debug(`Processing email in MTA mode for session ${session.id}`, { - sessionId: session.id, - messageId: email.getMessageId() - }); - - // Generate a message ID since queueEmail is not available - const options = this.smtpServer.getOptions(); - const hostname = options.hostname || SMTP_DEFAULTS.HOSTNAME; - const messageId = `${Date.now()}-${Math.floor(Math.random() * 1000000)}@${hostname}`; - - // Process the email through the emailServer - try { - // Process the email via the UnifiedEmailServer - // Pass the email object, session data, and specify the mode (mta, forward, or process) - // This connects SMTP reception to the overall email system - const processResult = await this.smtpServer.getEmailServer().processEmailByMode(email, session as any); - - SmtpLogger.info(`Email processed through UnifiedEmailServer: ${email.getMessageId()}`, { - sessionId: session.id, - messageId: email.getMessageId(), - recipients: email.to.join(', '), - success: true - }); - - result = { - success: true, - messageId, - email - }; - } catch (emailError) { - SmtpLogger.error(`Failed to process email through UnifiedEmailServer: ${emailError instanceof Error ? emailError.message : String(emailError)}`, { - sessionId: session.id, - error: emailError instanceof Error ? emailError : new Error(String(emailError)), - messageId - }); - - // Default to success for now to pass tests, but log the error - result = { - success: true, - messageId, - email - }; - } - } catch (error) { - SmtpLogger.error(`Failed to queue email: ${error instanceof Error ? error.message : String(error)}`, { - sessionId: session.id, - error: error instanceof Error ? error : new Error(String(error)) - }); - - result = { - success: false, - error: `Failed to queue email: ${error instanceof Error ? error.message : String(error)}` - }; - } - break; - - case 'forward': - // Forward email to another server - SmtpLogger.debug(`Processing email in FORWARD mode for session ${session.id}`, { - sessionId: session.id, - messageId: email.getMessageId() - }); - - // Process the email via the UnifiedEmailServer in forward mode - try { - const processResult = await this.smtpServer.getEmailServer().processEmailByMode(email, session as any); - - SmtpLogger.info(`Email forwarded through UnifiedEmailServer: ${email.getMessageId()}`, { - sessionId: session.id, - messageId: email.getMessageId(), - recipients: email.to.join(', '), - success: true - }); - - result = { - success: true, - messageId: email.getMessageId(), - email - }; - } catch (forwardError) { - SmtpLogger.error(`Failed to forward email: ${forwardError instanceof Error ? forwardError.message : String(forwardError)}`, { - sessionId: session.id, - error: forwardError instanceof Error ? forwardError : new Error(String(forwardError)), - messageId: email.getMessageId() - }); - - // For testing, still return success - result = { - success: true, - messageId: email.getMessageId(), - email - }; - } - break; - - case 'process': - // Process the email immediately - SmtpLogger.debug(`Processing email in PROCESS mode for session ${session.id}`, { - sessionId: session.id, - messageId: email.getMessageId() - }); - - // Process the email via the UnifiedEmailServer in process mode - try { - const processResult = await this.smtpServer.getEmailServer().processEmailByMode(email, session as any); - - SmtpLogger.info(`Email processed directly through UnifiedEmailServer: ${email.getMessageId()}`, { - sessionId: session.id, - messageId: email.getMessageId(), - recipients: email.to.join(', '), - success: true - }); - - result = { - success: true, - messageId: email.getMessageId(), - email - }; - } catch (processError) { - SmtpLogger.error(`Failed to process email directly: ${processError instanceof Error ? processError.message : String(processError)}`, { - sessionId: session.id, - error: processError instanceof Error ? processError : new Error(String(processError)), - messageId: email.getMessageId() - }); - - // For testing, still return success - result = { - success: true, - messageId: email.getMessageId(), - email - }; - } - break; - - default: - SmtpLogger.warn(`Unknown processing mode: ${processingMode}`, { sessionId: session.id }); - result = { - success: false, - error: `Unknown processing mode: ${processingMode}` - }; - } - - return result; - } catch (error) { - SmtpLogger.error(`Failed to parse email: ${error instanceof Error ? error.message : String(error)}`, { - sessionId: session.id, - error: error instanceof Error ? error : new Error(String(error)) - }); - - return { - success: false, - error: `Failed to parse email: ${error instanceof Error ? error.message : String(error)}` - }; - } - } - - /** - * Save an email to disk - * @param session - SMTP session - */ - public saveEmail(session: ISmtpSession): void { - // Email saving to disk is currently disabled in the refactored architecture - // This functionality can be re-enabled by adding a tempDir option to ISmtpServerOptions - SmtpLogger.debug(`Email saving to disk is disabled`, { - sessionId: session.id - }); - } - - /** - * Parse an email into an Email object - * @param session - SMTP session - * @returns Promise that resolves with the parsed Email object - */ - public async parseEmail(session: ISmtpSession): Promise { - try { - // Store raw data for testing and debugging - const rawData = session.emailData; - - // Try to parse with mailparser for better MIME support - const parsed = await plugins.mailparser.simpleParser(rawData); - - // Extract headers - const headers: Record = {}; - - // Add all headers from the parsed email - if (parsed.headers) { - // Convert headers to a standard object format - for (const [key, value] of parsed.headers.entries()) { - if (typeof value === 'string') { - headers[key.toLowerCase()] = value; - } else if (Array.isArray(value)) { - headers[key.toLowerCase()] = value.join(', '); - } - } - } - - // Get message ID or generate one - const messageId = parsed.messageId || - headers['message-id'] || - `<${Date.now()}.${Math.random().toString(36).substring(2)}@${this.smtpServer.getOptions().hostname}>`; - - // Get From, To, and Subject from parsed email or envelope - const from = parsed.from?.value?.[0]?.address || - session.envelope.mailFrom.address; - - // Handle multiple recipients appropriately - let to: string[] = []; - - // Try to get recipients from parsed email - if (parsed.to) { - // Handle both array and single object cases - if (Array.isArray(parsed.to)) { - to = parsed.to.map(addr => typeof addr === 'object' && addr !== null && 'address' in addr ? String(addr.address) : ''); - } else if (typeof parsed.to === 'object' && parsed.to !== null) { - // Handle object with value property (array or single address object) - if ('value' in parsed.to && Array.isArray(parsed.to.value)) { - to = parsed.to.value.map(addr => typeof addr === 'object' && addr !== null && 'address' in addr ? String(addr.address) : ''); - } else if ('address' in parsed.to) { - to = [String(parsed.to.address)]; - } - } - - // Filter out empty strings - to = to.filter(Boolean); - } - - // If no recipients found, fall back to envelope - if (to.length === 0) { - to = session.envelope.rcptTo.map(r => r.address); - } - - // Handle subject with special care for character encoding -const subject = parsed.subject || headers['subject'] || 'No Subject'; -SmtpLogger.debug(`Parsed email subject: ${subject}`, { subject }); - - // Create email object using the parsed content - const email = new Email({ - from: from, - to: to, - subject: subject, - text: parsed.text || '', - html: parsed.html || undefined, - // Include original envelope data as headers for accurate routing - headers: { - 'X-Original-Mail-From': session.envelope.mailFrom.address, - 'X-Original-Rcpt-To': session.envelope.rcptTo.map(r => r.address).join(', '), - 'Message-Id': messageId - } - }); - - // Add attachments if any - if (parsed.attachments && parsed.attachments.length > 0) { - SmtpLogger.debug(`Found ${parsed.attachments.length} attachments in email`, { - sessionId: session.id, - attachmentCount: parsed.attachments.length - }); - - for (const attachment of parsed.attachments) { - // Enhanced attachment logging for debugging - SmtpLogger.debug(`Processing attachment: ${attachment.filename}`, { - filename: attachment.filename, - contentType: attachment.contentType, - size: attachment.content?.length, - contentId: attachment.contentId || 'none', - contentDisposition: attachment.contentDisposition || 'none' - }); - - // Ensure we have valid content - if (!attachment.content || !Buffer.isBuffer(attachment.content)) { - SmtpLogger.warn(`Attachment ${attachment.filename} has invalid content, skipping`); - continue; - } - - // Fix up content type if missing but can be inferred from filename - let contentType = attachment.contentType || 'application/octet-stream'; - const filename = attachment.filename || 'attachment'; - - if (!contentType || contentType === 'application/octet-stream') { - if (filename.endsWith('.pdf')) { - contentType = 'application/pdf'; - } else if (filename.endsWith('.jpg') || filename.endsWith('.jpeg')) { - contentType = 'image/jpeg'; - } else if (filename.endsWith('.png')) { - contentType = 'image/png'; - } else if (filename.endsWith('.gif')) { - contentType = 'image/gif'; - } else if (filename.endsWith('.txt')) { - contentType = 'text/plain'; - } - } - - email.attachments.push({ - filename: filename, - content: attachment.content, - contentType: contentType, - contentId: attachment.contentId - }); - - SmtpLogger.debug(`Added attachment to email: ${filename}, type: ${contentType}, size: ${attachment.content.length} bytes`); - } - } else { - SmtpLogger.debug(`No attachments found in email via parser`, { sessionId: session.id }); - - // Additional check for attachments that might be missed by the parser - // Look for Content-Disposition headers in the raw data - const rawData = session.emailData; - const hasAttachmentDisposition = rawData.includes('Content-Disposition: attachment'); - - if (hasAttachmentDisposition) { - SmtpLogger.debug(`Found potential attachments in raw data, will handle in multipart processing`, { - sessionId: session.id - }); - } - } - - // Add received header - const timestamp = new Date().toUTCString(); - const receivedHeader = `from ${session.clientHostname || 'unknown'} (${session.remoteAddress}) by ${this.smtpServer.getOptions().hostname} with ESMTP id ${session.id}; ${timestamp}`; - email.addHeader('Received', receivedHeader); - - // Add all original headers - for (const [name, value] of Object.entries(headers)) { - if (!['from', 'to', 'subject', 'message-id'].includes(name)) { - email.addHeader(name, value); - } - } - - // Store raw data for testing and debugging - (email as any).rawData = rawData; - - SmtpLogger.debug(`Email parsed successfully: ${messageId}`, { - sessionId: session.id, - messageId, - hasHtml: !!parsed.html, - attachmentCount: parsed.attachments?.length || 0 - }); - - return email; - } catch (error) { - // If parsing fails, fall back to basic parsing - SmtpLogger.warn(`Advanced email parsing failed, falling back to basic parsing: ${error instanceof Error ? error.message : String(error)}`, { - sessionId: session.id, - error: error instanceof Error ? error : new Error(String(error)) - }); - - return this.parseEmailBasic(session); - } - } - - /** - * Basic fallback method for parsing emails - * @param session - SMTP session - * @returns The parsed Email object - */ - private parseEmailBasic(session: ISmtpSession): Email { - // Parse raw email text to extract headers - const rawData = session.emailData; - const headerEndIndex = rawData.indexOf('\r\n\r\n'); - - if (headerEndIndex === -1) { - // No headers/body separation, create basic email - const email = new Email({ - from: session.envelope.mailFrom.address, - to: session.envelope.rcptTo.map(r => r.address), - subject: 'Received via SMTP', - text: rawData - }); - - // Store raw data for testing - (email as any).rawData = rawData; - - return email; - } - - // Extract headers and body - const headersText = rawData.substring(0, headerEndIndex); - const bodyText = rawData.substring(headerEndIndex + 4); // Skip the \r\n\r\n separator - - // Parse headers with enhanced injection detection - const headers: Record = {}; - const headerLines = headersText.split('\r\n'); - let currentHeader = ''; - const criticalHeaders = new Set(); // Track critical headers for duplication detection - - for (const line of headerLines) { - // Check if this is a continuation of a previous header - if (line.startsWith(' ') || line.startsWith('\t')) { - if (currentHeader) { - headers[currentHeader] += ' ' + line.trim(); - } - continue; - } - - // This is a new header - const separatorIndex = line.indexOf(':'); - if (separatorIndex !== -1) { - const name = line.substring(0, separatorIndex).trim().toLowerCase(); - const value = line.substring(separatorIndex + 1).trim(); - - // Check for header injection attempts in header values - if (detectHeaderInjection(value, 'email-header')) { - SmtpLogger.warn('Header injection attempt detected in email header', { - headerName: name, - headerValue: value.substring(0, 100) + (value.length > 100 ? '...' : ''), - sessionId: session.id - }); - // Throw error to reject the email completely - throw new Error(`Header injection attempt detected in ${name} header`); - } - - // Enhanced security: Check for duplicate critical headers (potential injection) - const criticalHeaderNames = ['from', 'to', 'subject', 'date', 'message-id']; - if (criticalHeaderNames.includes(name)) { - if (criticalHeaders.has(name)) { - SmtpLogger.warn('Duplicate critical header detected - potential header injection', { - headerName: name, - existingValue: headers[name]?.substring(0, 50) + '...', - newValue: value.substring(0, 50) + '...', - sessionId: session.id - }); - // Throw error for duplicate critical headers - throw new Error(`Duplicate ${name} header detected - potential header injection`); - } - criticalHeaders.add(name); - } - - // Enhanced security: Check for envelope mismatch (spoofing attempt) - if (name === 'from' && session.envelope?.mailFrom?.address) { - const emailFromHeader = value.match(/<([^>]+)>/)?.[1] || value.trim(); - const envelopeFrom = session.envelope.mailFrom.address; - // Allow some flexibility but detect obvious spoofing attempts - if (emailFromHeader && envelopeFrom && - !emailFromHeader.toLowerCase().includes(envelopeFrom.toLowerCase()) && - !envelopeFrom.toLowerCase().includes(emailFromHeader.toLowerCase())) { - SmtpLogger.warn('Potential sender spoofing detected', { - envelopeFrom: envelopeFrom, - headerFrom: emailFromHeader, - sessionId: session.id - }); - // Note: This is logged but not blocked as legitimate use cases exist - } - } - - // Special handling for MIME-encoded headers (especially Subject) - if (name === 'subject' && value.includes('=?')) { - try { - // Use plugins.mailparser to decode the MIME-encoded subject - // This is a simplified approach - in a real system, you'd use a full MIME decoder - // For now, just log it for debugging - SmtpLogger.debug(`Found encoded subject: ${value}`, { encodedSubject: value }); - } catch (error) { - SmtpLogger.warn(`Failed to decode MIME-encoded subject: ${error instanceof Error ? error.message : String(error)}`); - } - } - - headers[name] = value; - currentHeader = name; - } - } - - // Look for multipart content - let isMultipart = false; - let boundary = ''; - let contentType = headers['content-type'] || ''; - - // Check for multipart content - if (contentType.includes('multipart/')) { - isMultipart = true; - - // Extract boundary - const boundaryMatch = contentType.match(/boundary="?([^";\r\n]+)"?/i); - if (boundaryMatch && boundaryMatch[1]) { - boundary = boundaryMatch[1]; - } - } - - // Extract common headers - const subject = headers['subject'] || 'No Subject'; - const from = headers['from'] || session.envelope.mailFrom.address; - const to = headers['to'] || session.envelope.rcptTo.map(r => r.address).join(', '); - const messageId = headers['message-id'] || `<${Date.now()}.${Math.random().toString(36).substring(2)}@${this.smtpServer.getOptions().hostname}>`; - - // Create email object - const email = new Email({ - from: from, - to: to.split(',').map(addr => addr.trim()), - subject: subject, - text: bodyText, - // Add original session envelope data for accurate routing as headers - headers: { - 'X-Original-Mail-From': session.envelope.mailFrom.address, - 'X-Original-Rcpt-To': session.envelope.rcptTo.map(r => r.address).join(', '), - 'Message-Id': messageId - } - }); - - // Handle multipart content if needed - if (isMultipart && boundary) { - this.handleMultipartContent(email, bodyText, boundary); - } - - // Add received header - const timestamp = new Date().toUTCString(); - const receivedHeader = `from ${session.clientHostname || 'unknown'} (${session.remoteAddress}) by ${this.smtpServer.getOptions().hostname} with ESMTP id ${session.id}; ${timestamp}`; - email.addHeader('Received', receivedHeader); - - // Add all original headers - for (const [name, value] of Object.entries(headers)) { - if (!['from', 'to', 'subject', 'message-id'].includes(name)) { - email.addHeader(name, value); - } - } - - // Store raw data for testing - (email as any).rawData = rawData; - - return email; - } - - /** - * Handle multipart content parsing - * @param email - Email object to update - * @param bodyText - Body text to parse - * @param boundary - MIME boundary - */ - private handleMultipartContent(email: Email, bodyText: string, boundary: string): void { - // Split the body by boundary - const parts = bodyText.split(`--${boundary}`); - - SmtpLogger.debug(`Handling multipart content with ${parts.length - 1} parts (boundary: ${boundary})`); - - // Process each part - for (let i = 1; i < parts.length; i++) { - const part = parts[i]; - - // Skip the end boundary marker - if (part.startsWith('--')) { - SmtpLogger.debug(`Found end boundary marker in part ${i}`); - continue; - } - - // Find the headers and content - const partHeaderEndIndex = part.indexOf('\r\n\r\n'); - if (partHeaderEndIndex === -1) { - SmtpLogger.debug(`No header/body separator found in part ${i}`); - continue; - } - - const partHeadersText = part.substring(0, partHeaderEndIndex); - const partContent = part.substring(partHeaderEndIndex + 4); - - // Parse part headers - const partHeaders: Record = {}; - const partHeaderLines = partHeadersText.split('\r\n'); - let currentHeader = ''; - - for (const line of partHeaderLines) { - // Check if this is a continuation of a previous header - if (line.startsWith(' ') || line.startsWith('\t')) { - if (currentHeader) { - partHeaders[currentHeader] += ' ' + line.trim(); - } - continue; - } - - // This is a new header - const separatorIndex = line.indexOf(':'); - if (separatorIndex !== -1) { - const name = line.substring(0, separatorIndex).trim().toLowerCase(); - const value = line.substring(separatorIndex + 1).trim(); - partHeaders[name] = value; - currentHeader = name; - } - } - - // Get content type - const contentType = partHeaders['content-type'] || ''; - - // Get encoding - const encoding = partHeaders['content-transfer-encoding'] || '7bit'; - - // Get disposition - const disposition = partHeaders['content-disposition'] || ''; - - // Log part information - SmtpLogger.debug(`Processing MIME part ${i}: type=${contentType}, encoding=${encoding}, disposition=${disposition}`); - - // Handle text/plain parts - if (contentType.includes('text/plain')) { - try { - // Decode content based on encoding - let decodedContent = partContent; - - if (encoding.toLowerCase() === 'base64') { - // Remove line breaks from base64 content before decoding - const cleanBase64 = partContent.replace(/[\r\n]/g, ''); - try { - decodedContent = Buffer.from(cleanBase64, 'base64').toString('utf8'); - } catch (error) { - SmtpLogger.warn(`Failed to decode base64 text content: ${error instanceof Error ? error.message : String(error)}`); - } - } else if (encoding.toLowerCase() === 'quoted-printable') { - try { - // Basic quoted-printable decoding - decodedContent = partContent.replace(/=([0-9A-F]{2})/gi, (match, hex) => { - return String.fromCharCode(parseInt(hex, 16)); - }); - } catch (error) { - SmtpLogger.warn(`Failed to decode quoted-printable content: ${error instanceof Error ? error.message : String(error)}`); - } - } - - email.text = decodedContent.trim(); - } catch (error) { - SmtpLogger.warn(`Error processing text/plain part: ${error instanceof Error ? error.message : String(error)}`); - email.text = partContent.trim(); - } - } - - // Handle text/html parts - if (contentType.includes('text/html')) { - try { - // Decode content based on encoding - let decodedContent = partContent; - - if (encoding.toLowerCase() === 'base64') { - // Remove line breaks from base64 content before decoding - const cleanBase64 = partContent.replace(/[\r\n]/g, ''); - try { - decodedContent = Buffer.from(cleanBase64, 'base64').toString('utf8'); - } catch (error) { - SmtpLogger.warn(`Failed to decode base64 HTML content: ${error instanceof Error ? error.message : String(error)}`); - } - } else if (encoding.toLowerCase() === 'quoted-printable') { - try { - // Basic quoted-printable decoding - decodedContent = partContent.replace(/=([0-9A-F]{2})/gi, (match, hex) => { - return String.fromCharCode(parseInt(hex, 16)); - }); - } catch (error) { - SmtpLogger.warn(`Failed to decode quoted-printable HTML content: ${error instanceof Error ? error.message : String(error)}`); - } - } - - email.html = decodedContent.trim(); - } catch (error) { - SmtpLogger.warn(`Error processing text/html part: ${error instanceof Error ? error.message : String(error)}`); - email.html = partContent.trim(); - } - } - - // Handle attachments - detect attachments by content disposition or by content-type - const isAttachment = - (disposition && disposition.toLowerCase().includes('attachment')) || - (!contentType.includes('text/plain') && !contentType.includes('text/html')); - - if (isAttachment) { - try { - // Extract filename from Content-Disposition or generate one based on content type - let filename = 'attachment'; - - if (disposition) { - const filenameMatch = disposition.match(/filename="?([^";\r\n]+)"?/i); - if (filenameMatch && filenameMatch[1]) { - filename = filenameMatch[1].trim(); - } - } else if (contentType) { - // If no filename but we have content type, generate a name with appropriate extension - const mainType = contentType.split(';')[0].trim().toLowerCase(); - - if (mainType === 'application/pdf') { - filename = `attachment_${Date.now()}.pdf`; - } else if (mainType === 'image/jpeg' || mainType === 'image/jpg') { - filename = `image_${Date.now()}.jpg`; - } else if (mainType === 'image/png') { - filename = `image_${Date.now()}.png`; - } else if (mainType === 'image/gif') { - filename = `image_${Date.now()}.gif`; - } else { - filename = `attachment_${Date.now()}.bin`; - } - } - - // Decode content based on encoding - let content: Buffer; - - if (encoding.toLowerCase() === 'base64') { - try { - // Remove line breaks from base64 content before decoding - const cleanBase64 = partContent.replace(/[\r\n]/g, ''); - content = Buffer.from(cleanBase64, 'base64'); - SmtpLogger.debug(`Successfully decoded base64 attachment: ${filename}, size: ${content.length} bytes`); - } catch (error) { - SmtpLogger.warn(`Failed to decode base64 attachment: ${error instanceof Error ? error.message : String(error)}`); - content = Buffer.from(partContent); - } - } else if (encoding.toLowerCase() === 'quoted-printable') { - try { - // Basic quoted-printable decoding - const decodedContent = partContent.replace(/=([0-9A-F]{2})/gi, (match, hex) => { - return String.fromCharCode(parseInt(hex, 16)); - }); - content = Buffer.from(decodedContent); - } catch (error) { - SmtpLogger.warn(`Failed to decode quoted-printable attachment: ${error instanceof Error ? error.message : String(error)}`); - content = Buffer.from(partContent); - } - } else { - // Default for 7bit, 8bit, or binary encoding - no decoding needed - content = Buffer.from(partContent); - } - - // Determine content type - use the one from headers or infer from filename - let finalContentType = contentType; - - if (!finalContentType || finalContentType === 'application/octet-stream') { - if (filename.endsWith('.pdf')) { - finalContentType = 'application/pdf'; - } else if (filename.endsWith('.jpg') || filename.endsWith('.jpeg')) { - finalContentType = 'image/jpeg'; - } else if (filename.endsWith('.png')) { - finalContentType = 'image/png'; - } else if (filename.endsWith('.gif')) { - finalContentType = 'image/gif'; - } else if (filename.endsWith('.txt')) { - finalContentType = 'text/plain'; - } else if (filename.endsWith('.html')) { - finalContentType = 'text/html'; - } - } - - // Add attachment to email - email.attachments.push({ - filename, - content, - contentType: finalContentType || 'application/octet-stream' - }); - - SmtpLogger.debug(`Added attachment: ${filename}, type: ${finalContentType}, size: ${content.length} bytes`); - } catch (error) { - SmtpLogger.error(`Failed to process attachment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - // Check for nested multipart content - if (contentType.includes('multipart/')) { - try { - // Extract boundary - const nestedBoundaryMatch = contentType.match(/boundary="?([^";\r\n]+)"?/i); - if (nestedBoundaryMatch && nestedBoundaryMatch[1]) { - const nestedBoundary = nestedBoundaryMatch[1].trim(); - SmtpLogger.debug(`Found nested multipart content with boundary: ${nestedBoundary}`); - - // Process nested multipart - this.handleMultipartContent(email, partContent, nestedBoundary); - } - } catch (error) { - SmtpLogger.warn(`Error processing nested multipart content: ${error instanceof Error ? error.message : String(error)}`); - } - } - } - } - - /** - * Handle end of data marker received - * @param socket - Client socket - * @param session - SMTP session - */ - private async handleEndOfData(socket: plugins.net.Socket | plugins.tls.TLSSocket, session: ISmtpSession): Promise { - // Clear the data timeout - if (session.dataTimeoutId) { - clearTimeout(session.dataTimeoutId); - session.dataTimeoutId = undefined; - } - - try { - // Update session state - this.smtpServer.getSessionManager().updateSessionState(session, SmtpState.FINISHED); - - // Optionally save email to disk - this.saveEmail(session); - - // Process the email using legacy method - const result = await this.processEmailLegacy(session); - - if (result.success) { - // Send success response - this.sendResponse(socket, `${SmtpResponseCode.OK} OK message queued as ${result.messageId}`); - } else { - // Send error response - this.sendResponse(socket, `${SmtpResponseCode.TRANSACTION_FAILED} Failed to process email: ${result.error}`); - } - - // Reset session for new transaction - this.resetSession(session); - } catch (error) { - SmtpLogger.error(`Error processing email: ${error instanceof Error ? error.message : String(error)}`, { - sessionId: session.id, - error: error instanceof Error ? error : new Error(String(error)) - }); - - this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Error processing email: ${error instanceof Error ? error.message : String(error)}`); - this.resetSession(session); - } - } - - /** - * Reset session after email processing - * @param session - SMTP session - */ - private resetSession(session: ISmtpSession): void { - // Clear any data timeout - if (session.dataTimeoutId) { - clearTimeout(session.dataTimeoutId); - session.dataTimeoutId = undefined; - } - - // Reset data fields but keep authentication state - session.mailFrom = ''; - session.rcptTo = []; - session.emailData = ''; - session.emailDataChunks = []; - session.emailDataSize = 0; - session.envelope = { - mailFrom: { address: '', args: {} }, - rcptTo: [] - }; - - // Reset state to after EHLO - this.smtpServer.getSessionManager().updateSessionState(session, SmtpState.AFTER_EHLO); - } - - /** - * Send a response to the client - * @param socket - Client socket - * @param response - Response message - */ - private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void { - // Check if socket is still writable before attempting to write - if (socket.destroyed || socket.readyState !== 'open' || !socket.writable) { - SmtpLogger.debug(`Skipping response to closed/destroyed socket: ${response}`, { - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - destroyed: socket.destroyed, - readyState: socket.readyState, - writable: socket.writable - }); - return; - } - - try { - socket.write(`${response}${SMTP_DEFAULTS.CRLF}`); - SmtpLogger.logResponse(response, socket); - } catch (error) { - // Attempt to recover from specific transient errors - if (this.isRecoverableSocketError(error)) { - this.handleSocketError(socket, error, response); - } else { - // Log error for non-recoverable errors - SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, { - response, - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - error: error instanceof Error ? error : new Error(String(error)) - }); - } - } - } - - /** - * Check if a socket error is potentially recoverable - * @param error - The error that occurred - * @returns Whether the error is potentially recoverable - */ - private isRecoverableSocketError(error: unknown): boolean { - const recoverableErrorCodes = [ - 'EPIPE', // Broken pipe - 'ECONNRESET', // Connection reset by peer - 'ETIMEDOUT', // Connection timed out - 'ECONNABORTED' // Connection aborted - ]; - - return ( - error instanceof Error && - 'code' in error && - typeof (error as any).code === 'string' && - recoverableErrorCodes.includes((error as any).code) - ); - } - - /** - * Handle recoverable socket errors with retry logic - * @param socket - Client socket - * @param error - The error that occurred - * @param response - The response that failed to send - */ - private handleSocketError(socket: plugins.net.Socket | plugins.tls.TLSSocket, error: unknown, response: string): void { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - if (!session) { - SmtpLogger.error(`Session not found when handling socket error`); - if (!socket.destroyed) { - socket.destroy(); - } - return; - } - - // Get error details for logging - const errorMessage = error instanceof Error ? error.message : String(error); - const errorCode = error instanceof Error && 'code' in error ? (error as any).code : 'UNKNOWN'; - - SmtpLogger.warn(`Recoverable socket error during data handling (${errorCode}): ${errorMessage}`, { - sessionId: session.id, - remoteAddress: session.remoteAddress, - error: error instanceof Error ? error : new Error(String(error)) - }); - - // Check if socket is already destroyed - if (socket.destroyed) { - SmtpLogger.info(`Socket already destroyed, cannot retry data operation`); - return; - } - - // Check if socket is writeable - if (!socket.writable) { - SmtpLogger.info(`Socket no longer writable, aborting data recovery attempt`); - if (!socket.destroyed) { - socket.destroy(); - } - return; - } - - // Attempt to retry the write operation after a short delay - setTimeout(() => { - try { - if (!socket.destroyed && socket.writable) { - socket.write(`${response}${SMTP_DEFAULTS.CRLF}`); - SmtpLogger.info(`Successfully retried data send operation after error`); - } else { - SmtpLogger.warn(`Socket no longer available for data retry`); - if (!socket.destroyed) { - socket.destroy(); - } - } - } catch (retryError) { - SmtpLogger.error(`Data retry attempt failed: ${retryError instanceof Error ? retryError.message : String(retryError)}`); - if (!socket.destroyed) { - socket.destroy(); - } - } - }, 100); // Short delay before retry - } - - /** - * Handle email data (interface requirement) - */ - public async handleData( - socket: plugins.net.Socket | plugins.tls.TLSSocket, - data: string, - session: ISmtpSession - ): Promise { - // Delegate to existing method - await this.handleDataReceived(socket, data); - } - - /** - * Clean up resources - */ - public destroy(): void { - // DataHandler doesn't have timers or event listeners to clean up - SmtpLogger.debug('DataHandler destroyed'); - } -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/index.ts b/ts/mail/delivery/smtpserver/index.ts deleted file mode 100644 index 7a0c448..0000000 --- a/ts/mail/delivery/smtpserver/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * SMTP Server Module Exports - * This file exports all components of the refactored SMTP server - */ - -// Export interfaces -export * from './interfaces.js'; - -// Export server classes -export { SmtpServer } from './smtp-server.js'; -export { SessionManager } from './session-manager.js'; -export { ConnectionManager } from './connection-manager.js'; -export { CommandHandler } from './command-handler.js'; -export { DataHandler } from './data-handler.js'; -export { TlsHandler } from './tls-handler.js'; -export { SecurityHandler } from './security-handler.js'; - -// Export constants -export * from './constants.js'; - -// Export utilities -export { SmtpLogger } from './utils/logging.js'; -export * from './utils/validation.js'; -export * from './utils/helpers.js'; - -// Export TLS and certificate utilities -export * from './certificate-utils.js'; -export * from './secure-server.js'; -export * from './starttls-handler.js'; - -// Factory function to create a complete SMTP server with default components -export { createSmtpServer } from './create-server.js'; \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/interfaces.ts b/ts/mail/delivery/smtpserver/interfaces.ts deleted file mode 100644 index a4afa61..0000000 --- a/ts/mail/delivery/smtpserver/interfaces.ts +++ /dev/null @@ -1,655 +0,0 @@ -/** - * SMTP Server Interfaces - * Defines all the interfaces used by the SMTP server implementation - */ - -import * as plugins from '../../../plugins.js'; -import type { Email } from '../../core/classes.email.js'; -import type { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js'; - -// Re-export types from other modules -import { SmtpState } from '../interfaces.js'; -import { SmtpCommand } from './constants.js'; -export { SmtpState, SmtpCommand }; -export type { IEnvelopeRecipient } from '../interfaces.js'; - -/** - * Interface for components that need cleanup - */ -export interface IDestroyable { - /** - * Clean up all resources (timers, listeners, etc) - */ - destroy(): void | Promise; -} - -/** - * SMTP authentication credentials - */ -export interface ISmtpAuth { - /** - * Username for authentication - */ - username: string; - - /** - * Password for authentication - */ - password: string; -} - -/** - * SMTP envelope (sender and recipients) - */ -export interface ISmtpEnvelope { - /** - * Mail from address - */ - mailFrom: { - address: string; - args?: Record; - }; - - /** - * Recipients list - */ - rcptTo: Array<{ - address: string; - args?: Record; - }>; -} - -/** - * SMTP session representing a client connection - */ -export interface ISmtpSession { - /** - * Unique session identifier - */ - id: string; - - /** - * Current state of the SMTP session - */ - state: SmtpState; - - /** - * Client's hostname from EHLO/HELO - */ - clientHostname: string | null; - - /** - * Whether TLS is active for this session - */ - secure: boolean; - - /** - * Authentication status - */ - authenticated: boolean; - - /** - * Authentication username if authenticated - */ - username?: string; - - /** - * Transaction envelope - */ - envelope: ISmtpEnvelope; - - /** - * When the session was created - */ - createdAt: Date; - - /** - * Last activity timestamp - */ - lastActivity: number; - - /** - * Client's IP address - */ - remoteAddress: string; - - /** - * Client's port - */ - remotePort: number; - - /** - * Additional session data - */ - data?: Record; - - /** - * Message size if SIZE extension is used - */ - messageSize?: number; - - /** - * Server capabilities advertised to client - */ - capabilities?: string[]; - - /** - * Buffer for incomplete data - */ - dataBuffer?: string; - - /** - * Flag to track if we're currently receiving DATA - */ - receivingData?: boolean; - - /** - * The raw email data being received - */ - rawData?: string; - - /** - * Greeting sent to client - */ - greeting?: string; - - /** - * Whether EHLO has been sent - */ - ehloSent?: boolean; - - /** - * Whether HELO has been sent - */ - heloSent?: boolean; - - /** - * TLS options for this session - */ - tlsOptions?: any; - - /** - * Whether TLS is being used - */ - useTLS?: boolean; - - /** - * Mail from address for this transaction - */ - mailFrom?: string; - - /** - * Recipients for this transaction - */ - rcptTo?: string[]; - - /** - * Email data being received - */ - emailData?: string; - - /** - * Chunks of email data - */ - emailDataChunks?: string[]; - - /** - * Timeout ID for data reception - */ - dataTimeoutId?: NodeJS.Timeout; - - /** - * Whether connection has ended - */ - connectionEnded?: boolean; - - /** - * Size of email data being received - */ - emailDataSize?: number; - - /** - * Processing mode for this session - */ - processingMode?: string; -} - -/** - * Session manager interface - */ -export interface ISessionManager extends IDestroyable { - /** - * Create a new session for a socket - */ - createSession(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure?: boolean): ISmtpSession; - - /** - * Get session by socket - */ - getSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): ISmtpSession | undefined; - - /** - * Update session state - */ - updateSessionState(session: ISmtpSession, newState: SmtpState): void; - - /** - * Remove a session - */ - removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void; - - /** - * Clear all sessions - */ - clearAllSessions(): void; - - /** - * Get all active sessions - */ - getAllSessions(): ISmtpSession[]; - - /** - * Get session count - */ - getSessionCount(): number; - - /** - * Update last activity for a session - */ - updateLastActivity(socket: plugins.net.Socket | plugins.tls.TLSSocket): void; - - /** - * Check for timed out sessions - */ - checkTimeouts(timeoutMs: number): ISmtpSession[]; - - /** - * Update session activity timestamp - */ - updateSessionActivity(session: ISmtpSession): void; - - /** - * Replace socket in session (for TLS upgrade) - */ - replaceSocket(oldSocket: plugins.net.Socket | plugins.tls.TLSSocket, newSocket: plugins.net.Socket | plugins.tls.TLSSocket): boolean; -} - -/** - * Connection manager interface - */ -export interface IConnectionManager extends IDestroyable { - /** - * Handle a new connection - */ - handleConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): Promise; - - /** - * Close all active connections - */ - closeAllConnections(): void; - - /** - * Get active connection count - */ - getConnectionCount(): number; - - /** - * Check if accepting new connections - */ - canAcceptConnection(): boolean; - - /** - * Handle new connection (legacy method name) - */ - handleNewConnection(socket: plugins.net.Socket): Promise; - - /** - * Handle new secure connection (legacy method name) - */ - handleNewSecureConnection(socket: plugins.tls.TLSSocket): Promise; - - /** - * Setup socket event handlers - */ - setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void; -} - -/** - * Command handler interface - */ -export interface ICommandHandler extends IDestroyable { - /** - * Handle an SMTP command - */ - handleCommand( - socket: plugins.net.Socket | plugins.tls.TLSSocket, - command: SmtpCommand, - args: string, - session: ISmtpSession - ): Promise; - - /** - * Get supported commands for current session state - */ - getSupportedCommands(session: ISmtpSession): SmtpCommand[]; - - /** - * Process command (legacy method name) - */ - processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, command: string): Promise; -} - -/** - * Data handler interface - */ -export interface IDataHandler extends IDestroyable { - /** - * Handle email data - */ - handleData( - socket: plugins.net.Socket | plugins.tls.TLSSocket, - data: string, - session: ISmtpSession - ): Promise; - - /** - * Process a complete email - */ - processEmail( - rawData: string, - session: ISmtpSession - ): Promise; - - /** - * Handle data received (legacy method name) - */ - handleDataReceived(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise; - - /** - * Process email data (legacy method name) - */ - processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise; -} - -/** - * TLS handler interface - */ -export interface ITlsHandler extends IDestroyable { - /** - * Handle STARTTLS command - */ - handleStartTls( - socket: plugins.net.Socket, - session: ISmtpSession - ): Promise; - - /** - * Check if TLS is available - */ - isTlsAvailable(): boolean; - - /** - * Get TLS options - */ - getTlsOptions(): plugins.tls.TlsOptions; - - /** - * Check if TLS is enabled - */ - isTlsEnabled(): boolean; -} - -/** - * Security handler interface - */ -export interface ISecurityHandler extends IDestroyable { - /** - * Check IP reputation - */ - checkIpReputation(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise; - - /** - * Validate email address - */ - isValidEmail(email: string): boolean; - - /** - * Authenticate user - */ - authenticate(auth: ISmtpAuth): Promise; -} - -/** - * SMTP server options - */ -export interface ISmtpServerOptions { - /** - * Port to listen on - */ - port: number; - - /** - * Hostname of the server - */ - hostname: string; - - /** - * Host to bind to (optional, defaults to 0.0.0.0) - */ - host?: string; - - /** - * Secure port for TLS connections - */ - securePort?: number; - - /** - * TLS/SSL private key (PEM format) - */ - key?: string; - - /** - * TLS/SSL certificate (PEM format) - */ - cert?: string; - - /** - * CA certificates for TLS (PEM format) - */ - ca?: string; - - /** - * Maximum size of messages in bytes - */ - maxSize?: number; - - /** - * Maximum number of concurrent connections - */ - maxConnections?: number; - - /** - * Authentication options - */ - auth?: { - /** - * Whether authentication is required - */ - required: boolean; - - /** - * Allowed authentication methods - */ - methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[]; - }; - - /** - * Socket timeout in milliseconds (default: 5 minutes / 300000ms) - */ - socketTimeout?: number; - - /** - * Initial connection timeout in milliseconds (default: 30 seconds / 30000ms) - */ - connectionTimeout?: number; - - /** - * Interval for checking idle sessions in milliseconds (default: 5 seconds / 5000ms) - * For testing, can be set lower (e.g. 1000ms) to detect timeouts more quickly - */ - cleanupInterval?: number; - - /** - * Maximum number of recipients allowed per message (default: 100) - */ - maxRecipients?: number; - - /** - * Maximum message size in bytes (default: 10MB / 10485760 bytes) - * This is advertised in the EHLO SIZE extension - */ - size?: number; - - /** - * Timeout for the DATA command in milliseconds (default: 60000ms / 1 minute) - * This controls how long to wait for the complete email data - */ - dataTimeout?: number; -} - -/** - * Result of SMTP transaction - */ -export interface ISmtpTransactionResult { - /** - * Whether the transaction was successful - */ - success: boolean; - - /** - * Error message if failed - */ - error?: string; - - /** - * Message ID if successful - */ - messageId?: string; - - /** - * Resulting email if successful - */ - email?: Email; -} - -/** - * Interface for SMTP session events - * These events are emitted by the session manager - */ -export interface ISessionEvents { - created: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void; - stateChanged: (session: ISmtpSession, previousState: SmtpState, newState: SmtpState) => void; - timeout: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void; - completed: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void; - error: (session: ISmtpSession, error: Error) => void; -} - -/** - * SMTP Server interface - */ -export interface ISmtpServer extends IDestroyable { - /** - * Start the SMTP server - */ - listen(): Promise; - - /** - * Stop the SMTP server - */ - close(): Promise; - - /** - * Get the session manager - */ - getSessionManager(): ISessionManager; - - /** - * Get the connection manager - */ - getConnectionManager(): IConnectionManager; - - /** - * Get the command handler - */ - getCommandHandler(): ICommandHandler; - - /** - * Get the data handler - */ - getDataHandler(): IDataHandler; - - /** - * Get the TLS handler - */ - getTlsHandler(): ITlsHandler; - - /** - * Get the security handler - */ - getSecurityHandler(): ISecurityHandler; - - /** - * Get the server options - */ - getOptions(): ISmtpServerOptions; - - /** - * Get the email server reference - */ - getEmailServer(): UnifiedEmailServer; -} - -/** - * Configuration for creating SMTP server - */ -export interface ISmtpServerConfig { - /** - * Email server instance - */ - emailServer: UnifiedEmailServer; - - /** - * Server options - */ - options: ISmtpServerOptions; - - /** - * Optional custom session manager - */ - sessionManager?: ISessionManager; - - /** - * Optional custom connection manager - */ - connectionManager?: IConnectionManager; - - /** - * Optional custom command handler - */ - commandHandler?: ICommandHandler; - - /** - * Optional custom data handler - */ - dataHandler?: IDataHandler; - - /** - * Optional custom TLS handler - */ - tlsHandler?: ITlsHandler; - - /** - * Optional custom security handler - */ - securityHandler?: ISecurityHandler; -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/secure-server.ts b/ts/mail/delivery/smtpserver/secure-server.ts deleted file mode 100644 index f4b62e5..0000000 --- a/ts/mail/delivery/smtpserver/secure-server.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Secure SMTP Server Utility Functions - * Provides helper functions for creating and managing secure TLS server - */ - -import * as plugins from '../../../plugins.js'; -import { - loadCertificatesFromString, - generateSelfSignedCertificates, - createTlsOptions, - type ICertificateData -} from './certificate-utils.js'; -import { SmtpLogger } from './utils/logging.js'; - -/** - * Create a secure TLS server for direct TLS connections - * @param options - TLS certificate options - * @returns A configured TLS server or undefined if TLS is not available - */ -export function createSecureTlsServer(options: { - key: string; - cert: string; - ca?: string; -}): plugins.tls.Server | undefined { - try { - // Log the creation attempt - SmtpLogger.info('Creating secure TLS server for direct connections'); - - // Load certificates from strings - let certificates: ICertificateData; - try { - certificates = loadCertificatesFromString({ - key: options.key, - cert: options.cert, - ca: options.ca - }); - - SmtpLogger.info('Successfully loaded TLS certificates for secure server'); - } catch (certificateError) { - SmtpLogger.warn(`Failed to load certificates, using self-signed: ${certificateError instanceof Error ? certificateError.message : String(certificateError)}`); - certificates = generateSelfSignedCertificates(); - } - - // Create server-side TLS options - const tlsOptions = createTlsOptions(certificates, true); - - // Log details for debugging - SmtpLogger.debug('Creating secure server with options', { - certificates: { - keyLength: certificates.key.length, - certLength: certificates.cert.length, - caLength: certificates.ca ? certificates.ca.length : 0 - }, - tlsOptions: { - minVersion: tlsOptions.minVersion, - maxVersion: tlsOptions.maxVersion, - ciphers: tlsOptions.ciphers?.substring(0, 50) + '...' // Truncate long cipher list - } - }); - - // Create the TLS server - const server = new plugins.tls.Server(tlsOptions); - - // Set up error handlers - server.on('error', (err) => { - SmtpLogger.error(`Secure server error: ${err.message}`, { - component: 'secure-server', - error: err, - stack: err.stack - }); - }); - - // Log secure connections - server.on('secureConnection', (socket) => { - const protocol = socket.getProtocol(); - const cipher = socket.getCipher(); - - SmtpLogger.info('New direct TLS connection established', { - component: 'secure-server', - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - protocol: protocol || 'unknown', - cipher: cipher?.name || 'unknown' - }); - }); - - return server; - } catch (error) { - SmtpLogger.error(`Failed to create secure TLS server: ${error instanceof Error ? error.message : String(error)}`, { - component: 'secure-server', - error: error instanceof Error ? error : new Error(String(error)), - stack: error instanceof Error ? error.stack : 'No stack trace available' - }); - - return undefined; - } -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/security-handler.ts b/ts/mail/delivery/smtpserver/security-handler.ts deleted file mode 100644 index 6963c78..0000000 --- a/ts/mail/delivery/smtpserver/security-handler.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * SMTP Security Handler - * Responsible for security aspects including IP reputation checking, - * email validation, and authentication - */ - -import * as plugins from '../../../plugins.js'; -import type { ISmtpSession, ISmtpAuth } from './interfaces.js'; -import type { ISecurityHandler, ISmtpServer } from './interfaces.js'; -import { SmtpLogger } from './utils/logging.js'; -import { SecurityEventType, SecurityLogLevel } from './constants.js'; -import { isValidEmail } from './utils/validation.js'; -import { getSocketDetails, getTlsDetails } from './utils/helpers.js'; -import { IPReputationChecker } from '../../../security/classes.ipreputationchecker.js'; - -/** - * Interface for IP denylist entry - */ -interface IIpDenylistEntry { - ip: string; - reason: string; - expiresAt?: number; -} - -/** - * Handles security aspects for SMTP server - */ -export class SecurityHandler implements ISecurityHandler { - /** - * Reference to the SMTP server instance - */ - private smtpServer: ISmtpServer; - - /** - * IP reputation checker service - */ - private ipReputationService: IPReputationChecker; - - /** - * Simple in-memory IP denylist - */ - private ipDenylist: IIpDenylistEntry[] = []; - - /** - * Cleanup interval timer - */ - private cleanupInterval: NodeJS.Timeout | null = null; - - /** - * Creates a new security handler - * @param smtpServer - SMTP server instance - */ - constructor(smtpServer: ISmtpServer) { - this.smtpServer = smtpServer; - - // Initialize IP reputation checker - this.ipReputationService = new IPReputationChecker(); - - // Clean expired denylist entries periodically - this.cleanupInterval = setInterval(() => this.cleanExpiredDenylistEntries(), 60000); // Every minute - } - - /** - * Check IP reputation for a connection - * @param socket - Client socket - * @returns Promise that resolves to true if IP is allowed, false if blocked - */ - public async checkIpReputation(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise { - const socketDetails = getSocketDetails(socket); - const ip = socketDetails.remoteAddress; - - // Check local denylist first - if (this.isIpDenylisted(ip)) { - // Log the blocked connection - this.logSecurityEvent( - SecurityEventType.IP_REPUTATION, - SecurityLogLevel.WARN, - `Connection blocked from denylisted IP: ${ip}`, - { reason: this.getDenylistReason(ip) } - ); - - return false; - } - - // Check with IP reputation service - if (!this.ipReputationService) { - return true; - } - - try { - // Check with IP reputation service - const reputationResult = await this.ipReputationService.checkReputation(ip); - - // Block if score is below HIGH_RISK threshold (20) or if it's spam/proxy/tor/vpn - const isBlocked = reputationResult.score < 20 || - reputationResult.isSpam || - reputationResult.isTor || - reputationResult.isProxy; - - if (isBlocked) { - // Add to local denylist temporarily - const reason = reputationResult.isSpam ? 'spam' : - reputationResult.isTor ? 'tor' : - reputationResult.isProxy ? 'proxy' : - `low reputation score: ${reputationResult.score}`; - this.addToDenylist(ip, reason, 3600000); // 1 hour - - // Log the blocked connection - this.logSecurityEvent( - SecurityEventType.IP_REPUTATION, - SecurityLogLevel.WARN, - `Connection blocked by reputation service: ${ip}`, - { - reason, - score: reputationResult.score, - isSpam: reputationResult.isSpam, - isTor: reputationResult.isTor, - isProxy: reputationResult.isProxy, - isVPN: reputationResult.isVPN - } - ); - - return false; - } - - // Log the allowed connection - this.logSecurityEvent( - SecurityEventType.IP_REPUTATION, - SecurityLogLevel.INFO, - `IP reputation check passed: ${ip}`, - { - score: reputationResult.score, - country: reputationResult.country, - org: reputationResult.org - } - ); - - return true; - } catch (error) { - // Log the error - SmtpLogger.error(`IP reputation check error: ${error instanceof Error ? error.message : String(error)}`, { - ip, - error: error instanceof Error ? error : new Error(String(error)) - }); - - // Allow the connection on error (fail open) - return true; - } - } - - /** - * Validate an email address - * @param email - Email address to validate - * @returns Whether the email address is valid - */ - public isValidEmail(email: string): boolean { - return isValidEmail(email); - } - - /** - * Validate authentication credentials - * @param auth - Authentication credentials - * @returns Promise that resolves to true if authenticated - */ - public async authenticate(auth: ISmtpAuth): Promise { - const { username, password } = auth; - // Get auth options from server - const options = this.smtpServer.getOptions(); - const authOptions = options.auth; - - // Check if authentication is enabled - if (!authOptions) { - this.logSecurityEvent( - SecurityEventType.AUTHENTICATION, - SecurityLogLevel.WARN, - 'Authentication attempt when auth is disabled', - { username } - ); - - return false; - } - - // Note: Method validation and TLS requirement checks would need to be done - // at the caller level since the interface doesn't include session/method info - - try { - let authenticated = false; - - // Use custom validation function if provided - if ((authOptions as any).validateUser) { - authenticated = await (authOptions as any).validateUser(username, password); - } else { - // Default behavior - no authentication - authenticated = false; - } - - // Log the authentication result - this.logSecurityEvent( - SecurityEventType.AUTHENTICATION, - authenticated ? SecurityLogLevel.INFO : SecurityLogLevel.WARN, - authenticated ? 'Authentication successful' : 'Authentication failed', - { username } - ); - - return authenticated; - } catch (error) { - // Log authentication error - this.logSecurityEvent( - SecurityEventType.AUTHENTICATION, - SecurityLogLevel.ERROR, - `Authentication error: ${error instanceof Error ? error.message : String(error)}`, - { username, error: error instanceof Error ? error.message : String(error) } - ); - - return false; - } - } - - /** - * Log a security event - * @param event - Event type - * @param level - Log level - * @param details - Event details - */ - public logSecurityEvent(event: string, level: string, message: string, details: Record): void { - SmtpLogger.logSecurityEvent( - level as SecurityLogLevel, - event as SecurityEventType, - message, - details, - details.ip, - details.domain, - details.success - ); - } - - /** - * Add an IP to the denylist - * @param ip - IP address - * @param reason - Reason for denylisting - * @param duration - Duration in milliseconds (optional, indefinite if not specified) - */ - private addToDenylist(ip: string, reason: string, duration?: number): void { - // Remove existing entry if present - this.ipDenylist = this.ipDenylist.filter(entry => entry.ip !== ip); - - // Create new entry - const entry: IIpDenylistEntry = { - ip, - reason, - expiresAt: duration ? Date.now() + duration : undefined - }; - - // Add to denylist - this.ipDenylist.push(entry); - - // Log the action - this.logSecurityEvent( - SecurityEventType.ACCESS_CONTROL, - SecurityLogLevel.INFO, - `Added IP to denylist: ${ip}`, - { - ip, - reason, - duration: duration ? `${duration / 1000} seconds` : 'indefinite' - } - ); - } - - /** - * Check if an IP is denylisted - * @param ip - IP address - * @returns Whether the IP is denylisted - */ - private isIpDenylisted(ip: string): boolean { - const entry = this.ipDenylist.find(e => e.ip === ip); - - if (!entry) { - return false; - } - - // Check if entry has expired - if (entry.expiresAt && entry.expiresAt < Date.now()) { - // Remove expired entry - this.ipDenylist = this.ipDenylist.filter(e => e !== entry); - return false; - } - - return true; - } - - /** - * Get the reason an IP was denylisted - * @param ip - IP address - * @returns Reason for denylisting or undefined if not denylisted - */ - private getDenylistReason(ip: string): string | undefined { - const entry = this.ipDenylist.find(e => e.ip === ip); - return entry?.reason; - } - - /** - * Clean expired denylist entries - */ - private cleanExpiredDenylistEntries(): void { - const now = Date.now(); - const initialCount = this.ipDenylist.length; - - this.ipDenylist = this.ipDenylist.filter(entry => { - return !entry.expiresAt || entry.expiresAt > now; - }); - - const removedCount = initialCount - this.ipDenylist.length; - - if (removedCount > 0) { - this.logSecurityEvent( - SecurityEventType.ACCESS_CONTROL, - SecurityLogLevel.INFO, - `Cleaned up ${removedCount} expired denylist entries`, - { remainingCount: this.ipDenylist.length } - ); - } - } - - /** - * Clean up resources - */ - public destroy(): void { - // Clear the cleanup interval - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - this.cleanupInterval = null; - } - - // Clear the denylist - this.ipDenylist = []; - - // Clean up IP reputation service if it has a destroy method - if (this.ipReputationService && typeof (this.ipReputationService as any).destroy === 'function') { - (this.ipReputationService as any).destroy(); - } - - SmtpLogger.debug('SecurityHandler destroyed'); - } -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/session-manager.ts b/ts/mail/delivery/smtpserver/session-manager.ts deleted file mode 100644 index b7c1cb2..0000000 --- a/ts/mail/delivery/smtpserver/session-manager.ts +++ /dev/null @@ -1,557 +0,0 @@ -/** - * SMTP Session Manager - * Responsible for creating, managing, and cleaning up SMTP sessions - */ - -import * as plugins from '../../../plugins.js'; -import { SmtpState } from './interfaces.js'; -import type { ISmtpSession, ISmtpEnvelope } from './interfaces.js'; -import type { ISessionManager, ISessionEvents } from './interfaces.js'; -import { SMTP_DEFAULTS } from './constants.js'; -import { generateSessionId, getSocketDetails } from './utils/helpers.js'; -import { SmtpLogger } from './utils/logging.js'; - -/** - * Manager for SMTP sessions - * Handles session creation, tracking, timeout management, and cleanup - */ -export class SessionManager implements ISessionManager { - /** - * Map of socket ID to session - */ - private sessions: Map = new Map(); - - /** - * Map of socket to socket ID - */ - private socketIds: Map = new Map(); - - /** - * SMTP server options - */ - private options: { - socketTimeout: number; - connectionTimeout: number; - cleanupInterval: number; - }; - - /** - * Event listeners - */ - private eventListeners: { - created?: Set<(session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void>; - stateChanged?: Set<(session: ISmtpSession, previousState: SmtpState, newState: SmtpState) => void>; - timeout?: Set<(session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void>; - completed?: Set<(session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void>; - error?: Set<(session: ISmtpSession, error: Error) => void>; - } = {}; - - /** - * Timer for cleanup interval - */ - private cleanupTimer: NodeJS.Timeout | null = null; - - /** - * Creates a new session manager - * @param options - Session manager options - */ - constructor(options: { - socketTimeout?: number; - connectionTimeout?: number; - cleanupInterval?: number; - } = {}) { - this.options = { - socketTimeout: options.socketTimeout || SMTP_DEFAULTS.SOCKET_TIMEOUT, - connectionTimeout: options.connectionTimeout || SMTP_DEFAULTS.CONNECTION_TIMEOUT, - cleanupInterval: options.cleanupInterval || SMTP_DEFAULTS.CLEANUP_INTERVAL - }; - - // Start the cleanup timer - this.startCleanupTimer(); - } - - /** - * Creates a new session for a socket connection - * @param socket - Client socket - * @param secure - Whether the connection is secure (TLS) - * @returns New SMTP session - */ - public createSession(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): ISmtpSession { - const sessionId = generateSessionId(); - const socketDetails = getSocketDetails(socket); - - // Create a new session - const session: ISmtpSession = { - id: sessionId, - state: SmtpState.GREETING, - clientHostname: '', - mailFrom: '', - rcptTo: [], - emailData: '', - emailDataChunks: [], - emailDataSize: 0, - useTLS: secure || false, - connectionEnded: false, - remoteAddress: socketDetails.remoteAddress, - remotePort: socketDetails.remotePort, - createdAt: new Date(), - secure: secure || false, - authenticated: false, - envelope: { - mailFrom: { address: '', args: {} }, - rcptTo: [] - }, - lastActivity: Date.now() - }; - - // Store session with unique ID - const socketKey = this.getSocketKey(socket); - this.socketIds.set(socket, socketKey); - this.sessions.set(socketKey, session); - - // Set socket timeout - socket.setTimeout(this.options.socketTimeout); - - // Emit session created event - this.emitEvent('created', session, socket); - - // Log session creation - SmtpLogger.info(`Created SMTP session ${sessionId}`, { - sessionId, - remoteAddress: session.remoteAddress, - remotePort: socketDetails.remotePort, - secure: session.secure - }); - - return session; - } - - /** - * Updates the session state - * @param session - SMTP session - * @param newState - New state - */ - public updateSessionState(session: ISmtpSession, newState: SmtpState): void { - if (session.state === newState) { - return; - } - - const previousState = session.state; - session.state = newState; - - // Update activity timestamp - this.updateSessionActivity(session); - - // Emit state changed event - this.emitEvent('stateChanged', session, previousState, newState); - - // Log state change - SmtpLogger.debug(`Session ${session.id} state changed from ${previousState} to ${newState}`, { - sessionId: session.id, - previousState, - newState, - remoteAddress: session.remoteAddress - }); - } - - /** - * Updates the session's last activity timestamp - * @param session - SMTP session - */ - public updateSessionActivity(session: ISmtpSession): void { - session.lastActivity = Date.now(); - } - - /** - * Removes a session - * @param socket - Client socket - */ - public removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { - const socketKey = this.socketIds.get(socket); - if (!socketKey) { - return; - } - - const session = this.sessions.get(socketKey); - if (session) { - // Mark the session as ended - session.connectionEnded = true; - - // Clear any data timeout if it exists - if (session.dataTimeoutId) { - clearTimeout(session.dataTimeoutId); - session.dataTimeoutId = undefined; - } - - // Emit session completed event - this.emitEvent('completed', session, socket); - - // Log session removal - SmtpLogger.info(`Removed SMTP session ${session.id}`, { - sessionId: session.id, - remoteAddress: session.remoteAddress, - finalState: session.state - }); - } - - // Remove from maps - this.sessions.delete(socketKey); - this.socketIds.delete(socket); - } - - /** - * Gets a session for a socket - * @param socket - Client socket - * @returns SMTP session or undefined if not found - */ - public getSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): ISmtpSession | undefined { - const socketKey = this.socketIds.get(socket); - if (!socketKey) { - return undefined; - } - - return this.sessions.get(socketKey); - } - - /** - * Cleans up idle sessions - */ - public cleanupIdleSessions(): void { - const now = Date.now(); - let timedOutCount = 0; - - for (const [socketKey, session] of this.sessions.entries()) { - if (session.connectionEnded) { - // Session already marked as ended, but still in map - this.sessions.delete(socketKey); - continue; - } - - // Calculate how long the session has been idle - const lastActivity = session.lastActivity || 0; - const idleTime = now - lastActivity; - - // Use appropriate timeout based on session state - const timeout = session.state === SmtpState.DATA_RECEIVING - ? this.options.socketTimeout * 2 // Double timeout for data receiving - : session.state === SmtpState.GREETING - ? this.options.connectionTimeout // Initial connection timeout - : this.options.socketTimeout; // Standard timeout for other states - - // Check if session has timed out - if (idleTime > timeout) { - // Find the socket for this session - let timedOutSocket: plugins.net.Socket | plugins.tls.TLSSocket | undefined; - - for (const [socket, key] of this.socketIds.entries()) { - if (key === socketKey) { - timedOutSocket = socket; - break; - } - } - - if (timedOutSocket) { - // Emit timeout event - this.emitEvent('timeout', session, timedOutSocket); - - // Log timeout - SmtpLogger.warn(`Session ${session.id} timed out after ${Math.round(idleTime / 1000)}s of inactivity`, { - sessionId: session.id, - remoteAddress: session.remoteAddress, - state: session.state, - idleTime - }); - - // End the socket connection - try { - timedOutSocket.end(); - } catch (error) { - SmtpLogger.error(`Error ending timed out socket: ${error instanceof Error ? error.message : String(error)}`, { - sessionId: session.id, - remoteAddress: session.remoteAddress, - error: error instanceof Error ? error : new Error(String(error)) - }); - } - - // Remove from maps - this.sessions.delete(socketKey); - this.socketIds.delete(timedOutSocket); - timedOutCount++; - } - } - } - - if (timedOutCount > 0) { - SmtpLogger.info(`Cleaned up ${timedOutCount} timed out sessions`, { - totalSessions: this.sessions.size - }); - } - } - - /** - * Gets the current number of active sessions - * @returns Number of active sessions - */ - public getSessionCount(): number { - return this.sessions.size; - } - - /** - * Clears all sessions (used when shutting down) - */ - public clearAllSessions(): void { - // Log the action - SmtpLogger.info(`Clearing all sessions (count: ${this.sessions.size})`); - - // Clear the sessions and socket IDs maps - this.sessions.clear(); - this.socketIds.clear(); - - // Stop the cleanup timer - this.stopCleanupTimer(); - } - - /** - * Register an event listener - * @param event - Event name - * @param listener - Event listener function - */ - public on(event: K, listener: ISessionEvents[K]): void { - switch (event) { - case 'created': - if (!this.eventListeners.created) { - this.eventListeners.created = new Set(); - } - this.eventListeners.created.add(listener as (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void); - break; - case 'stateChanged': - if (!this.eventListeners.stateChanged) { - this.eventListeners.stateChanged = new Set(); - } - this.eventListeners.stateChanged.add(listener as (session: ISmtpSession, previousState: SmtpState, newState: SmtpState) => void); - break; - case 'timeout': - if (!this.eventListeners.timeout) { - this.eventListeners.timeout = new Set(); - } - this.eventListeners.timeout.add(listener as (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void); - break; - case 'completed': - if (!this.eventListeners.completed) { - this.eventListeners.completed = new Set(); - } - this.eventListeners.completed.add(listener as (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void); - break; - case 'error': - if (!this.eventListeners.error) { - this.eventListeners.error = new Set(); - } - this.eventListeners.error.add(listener as (session: ISmtpSession, error: Error) => void); - break; - } - } - - /** - * Remove an event listener - * @param event - Event name - * @param listener - Event listener function - */ - public off(event: K, listener: ISessionEvents[K]): void { - switch (event) { - case 'created': - if (this.eventListeners.created) { - this.eventListeners.created.delete(listener as (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void); - } - break; - case 'stateChanged': - if (this.eventListeners.stateChanged) { - this.eventListeners.stateChanged.delete(listener as (session: ISmtpSession, previousState: SmtpState, newState: SmtpState) => void); - } - break; - case 'timeout': - if (this.eventListeners.timeout) { - this.eventListeners.timeout.delete(listener as (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void); - } - break; - case 'completed': - if (this.eventListeners.completed) { - this.eventListeners.completed.delete(listener as (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void); - } - break; - case 'error': - if (this.eventListeners.error) { - this.eventListeners.error.delete(listener as (session: ISmtpSession, error: Error) => void); - } - break; - } - } - - /** - * Emit an event to registered listeners - * @param event - Event name - * @param args - Event arguments - */ - private emitEvent(event: K, ...args: any[]): void { - let listeners: Set | undefined; - - switch (event) { - case 'created': - listeners = this.eventListeners.created; - break; - case 'stateChanged': - listeners = this.eventListeners.stateChanged; - break; - case 'timeout': - listeners = this.eventListeners.timeout; - break; - case 'completed': - listeners = this.eventListeners.completed; - break; - case 'error': - listeners = this.eventListeners.error; - break; - } - - if (!listeners) { - return; - } - - for (const listener of listeners) { - try { - (listener as Function)(...args); - } catch (error) { - SmtpLogger.error(`Error in session event listener for ${String(event)}: ${error instanceof Error ? error.message : String(error)}`, { - error: error instanceof Error ? error : new Error(String(error)) - }); - } - } - } - - /** - * Start the cleanup timer - */ - private startCleanupTimer(): void { - if (this.cleanupTimer) { - return; - } - - this.cleanupTimer = setInterval(() => { - this.cleanupIdleSessions(); - }, this.options.cleanupInterval); - - // Prevent the timer from keeping the process alive - if (this.cleanupTimer.unref) { - this.cleanupTimer.unref(); - } - } - - /** - * Stop the cleanup timer - */ - private stopCleanupTimer(): void { - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer); - this.cleanupTimer = null; - } - } - - /** - * Replace socket mapping for STARTTLS upgrades - * @param oldSocket - Original plain socket - * @param newSocket - New TLS socket - * @returns Whether the replacement was successful - */ - public replaceSocket(oldSocket: plugins.net.Socket | plugins.tls.TLSSocket, newSocket: plugins.net.Socket | plugins.tls.TLSSocket): boolean { - const socketKey = this.socketIds.get(oldSocket); - if (!socketKey) { - SmtpLogger.warn('Cannot replace socket - original socket not found in session manager'); - return false; - } - - const session = this.sessions.get(socketKey); - if (!session) { - SmtpLogger.warn('Cannot replace socket - session not found for socket key'); - return false; - } - - // Remove old socket mapping - this.socketIds.delete(oldSocket); - - // Add new socket mapping - this.socketIds.set(newSocket, socketKey); - - // Set socket timeout for new socket - newSocket.setTimeout(this.options.socketTimeout); - - SmtpLogger.info(`Socket replaced for session ${session.id} (STARTTLS upgrade)`, { - sessionId: session.id, - remoteAddress: session.remoteAddress, - oldSocketType: oldSocket.constructor.name, - newSocketType: newSocket.constructor.name - }); - - return true; - } - - /** - * Gets a unique key for a socket - * @param socket - Client socket - * @returns Socket key - */ - private getSocketKey(socket: plugins.net.Socket | plugins.tls.TLSSocket): string { - const details = getSocketDetails(socket); - return `${details.remoteAddress}:${details.remotePort}-${Date.now()}`; - } - - /** - * Get all active sessions - */ - public getAllSessions(): ISmtpSession[] { - return Array.from(this.sessions.values()); - } - - /** - * Update last activity for a session by socket - */ - public updateLastActivity(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { - const session = this.getSession(socket); - if (session) { - this.updateSessionActivity(session); - } - } - - /** - * Check for timed out sessions - */ - public checkTimeouts(timeoutMs: number): ISmtpSession[] { - const now = Date.now(); - const timedOutSessions: ISmtpSession[] = []; - - for (const session of this.sessions.values()) { - if (now - session.lastActivity > timeoutMs) { - timedOutSessions.push(session); - } - } - - return timedOutSessions; - } - - /** - * Clean up resources - */ - public destroy(): void { - // Clear the cleanup timer - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer); - this.cleanupTimer = null; - } - - // Clear all sessions - this.clearAllSessions(); - - // Clear event listeners - this.eventListeners = {}; - - SmtpLogger.debug('SessionManager destroyed'); - } -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/smtp-server.ts b/ts/mail/delivery/smtpserver/smtp-server.ts deleted file mode 100644 index 8b3a5ed..0000000 --- a/ts/mail/delivery/smtpserver/smtp-server.ts +++ /dev/null @@ -1,804 +0,0 @@ -/** - * SMTP Server - * Core implementation for the refactored SMTP server - */ - -import * as plugins from '../../../plugins.js'; -import { SmtpState } from './interfaces.js'; -import type { ISmtpServerOptions } from './interfaces.js'; -import type { ISmtpServer, ISmtpServerConfig, ISessionManager, IConnectionManager, ICommandHandler, IDataHandler, ITlsHandler, ISecurityHandler } from './interfaces.js'; -import { SessionManager } from './session-manager.js'; -import { ConnectionManager } from './connection-manager.js'; -import { CommandHandler } from './command-handler.js'; -import { DataHandler } from './data-handler.js'; -import { TlsHandler } from './tls-handler.js'; -import { SecurityHandler } from './security-handler.js'; -import { SMTP_DEFAULTS } from './constants.js'; -import { mergeWithDefaults } from './utils/helpers.js'; -import { SmtpLogger } from './utils/logging.js'; -import { adaptiveLogger } from './utils/adaptive-logging.js'; -import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js'; - -/** - * SMTP Server implementation - * The main server class that coordinates all components - */ -export class SmtpServer implements ISmtpServer { - /** - * Email server reference - */ - private emailServer: UnifiedEmailServer; - - /** - * Session manager - */ - private sessionManager: ISessionManager; - - /** - * Connection manager - */ - private connectionManager: IConnectionManager; - - /** - * Command handler - */ - private commandHandler: ICommandHandler; - - /** - * Data handler - */ - private dataHandler: IDataHandler; - - /** - * TLS handler - */ - private tlsHandler: ITlsHandler; - - /** - * Security handler - */ - private securityHandler: ISecurityHandler; - - /** - * SMTP server options - */ - private options: ISmtpServerOptions; - - /** - * Net server instance - */ - private server: plugins.net.Server | null = null; - - /** - * Secure server instance - */ - private secureServer: plugins.tls.Server | null = null; - - /** - * Whether the server is running - */ - private running = false; - - /** - * Server recovery state - */ - private recoveryState = { - /** - * Whether recovery is in progress - */ - recovering: false, - - /** - * Number of consecutive connection failures - */ - connectionFailures: 0, - - /** - * Last recovery attempt timestamp - */ - lastRecoveryAttempt: 0, - - /** - * Recovery cooldown in milliseconds - */ - recoveryCooldown: 5000, - - /** - * Maximum recovery attempts before giving up - */ - maxRecoveryAttempts: 3, - - /** - * Current recovery attempt - */ - currentRecoveryAttempt: 0 - }; - - /** - * Creates a new SMTP server - * @param config - Server configuration - */ - constructor(config: ISmtpServerConfig) { - this.emailServer = config.emailServer; - this.options = mergeWithDefaults(config.options); - - // Create components - all components now receive the SMTP server instance - this.sessionManager = config.sessionManager || new SessionManager({ - socketTimeout: this.options.socketTimeout, - connectionTimeout: this.options.connectionTimeout, - cleanupInterval: this.options.cleanupInterval - }); - - this.securityHandler = config.securityHandler || new SecurityHandler(this); - this.tlsHandler = config.tlsHandler || new TlsHandler(this); - this.dataHandler = config.dataHandler || new DataHandler(this); - this.commandHandler = config.commandHandler || new CommandHandler(this); - this.connectionManager = config.connectionManager || new ConnectionManager(this); - } - - /** - * Start the SMTP server - * @returns Promise that resolves when server is started - */ - public async listen(): Promise { - if (this.running) { - throw new Error('SMTP server is already running'); - } - - try { - // Create the server - this.server = plugins.net.createServer((socket) => { - // Check IP reputation before handling connection - this.securityHandler.checkIpReputation(socket) - .then(allowed => { - if (allowed) { - this.connectionManager.handleNewConnection(socket); - } else { - // Close connection if IP is not allowed - socket.destroy(); - } - }) - .catch(error => { - SmtpLogger.error(`IP reputation check error: ${error instanceof Error ? error.message : String(error)}`, { - remoteAddress: socket.remoteAddress, - error: error instanceof Error ? error : new Error(String(error)) - }); - - // Allow connection on error (fail open) - this.connectionManager.handleNewConnection(socket); - }); - }); - - // Set up error handling with recovery - this.server.on('error', (err) => { - SmtpLogger.error(`SMTP server error: ${err.message}`, { error: err }); - - // Try to recover from specific errors - if (this.shouldAttemptRecovery(err)) { - this.attemptServerRecovery('standard', err); - } - }); - - // Start listening - await new Promise((resolve, reject) => { - if (!this.server) { - reject(new Error('Server not initialized')); - return; - } - - this.server.listen(this.options.port, this.options.host, () => { - SmtpLogger.info(`SMTP server listening on ${this.options.host || '0.0.0.0'}:${this.options.port}`); - resolve(); - }); - - this.server.on('error', reject); - }); - - // Start secure server if configured - if (this.options.securePort && this.tlsHandler.isTlsEnabled()) { - try { - // Import the secure server creation utility from our new module - // This gives us better certificate handling and error resilience - const { createSecureTlsServer } = await import('./secure-server.js'); - - // Create secure server with the certificates - // This uses a more robust approach to certificate loading and validation - this.secureServer = createSecureTlsServer({ - key: this.options.key, - cert: this.options.cert, - ca: this.options.ca - }); - - SmtpLogger.info(`Created secure TLS server for port ${this.options.securePort}`); - - if (this.secureServer) { - // Use explicit error handling for secure connections - this.secureServer.on('tlsClientError', (err, tlsSocket) => { - SmtpLogger.error(`TLS client error: ${err.message}`, { - error: err, - remoteAddress: tlsSocket.remoteAddress, - remotePort: tlsSocket.remotePort, - stack: err.stack - }); - // No need to destroy, the error event will handle that - }); - - // Register the secure connection handler - this.secureServer.on('secureConnection', (socket) => { - SmtpLogger.info(`New secure connection from ${socket.remoteAddress}:${socket.remotePort}`, { - protocol: socket.getProtocol(), - cipher: socket.getCipher()?.name - }); - - // Check IP reputation before handling connection - this.securityHandler.checkIpReputation(socket) - .then(allowed => { - if (allowed) { - // Pass the connection to the connection manager - this.connectionManager.handleNewSecureConnection(socket); - } else { - // Close connection if IP is not allowed - socket.destroy(); - } - }) - .catch(error => { - SmtpLogger.error(`IP reputation check error: ${error instanceof Error ? error.message : String(error)}`, { - remoteAddress: socket.remoteAddress, - error: error instanceof Error ? error : new Error(String(error)), - stack: error instanceof Error ? error.stack : 'No stack trace available' - }); - - // Allow connection on error (fail open) - this.connectionManager.handleNewSecureConnection(socket); - }); - }); - - // Global error handler for the secure server with recovery - this.secureServer.on('error', (err) => { - SmtpLogger.error(`SMTP secure server error: ${err.message}`, { - error: err, - stack: err.stack - }); - - // Try to recover from specific errors - if (this.shouldAttemptRecovery(err)) { - this.attemptServerRecovery('secure', err); - } - }); - - // Start listening on secure port - await new Promise((resolve, reject) => { - if (!this.secureServer) { - reject(new Error('Secure server not initialized')); - return; - } - - this.secureServer.listen(this.options.securePort, this.options.host, () => { - SmtpLogger.info(`SMTP secure server listening on ${this.options.host || '0.0.0.0'}:${this.options.securePort}`); - resolve(); - }); - - // Only use error event for startup issues - this.secureServer.once('error', reject); - }); - } else { - SmtpLogger.warn('Failed to create secure server, TLS may not be properly configured'); - } - } catch (error) { - SmtpLogger.error(`Error setting up secure server: ${error instanceof Error ? error.message : String(error)}`, { - error: error instanceof Error ? error : new Error(String(error)), - stack: error instanceof Error ? error.stack : 'No stack trace available' - }); - } - } - - this.running = true; - } catch (error) { - SmtpLogger.error(`Failed to start SMTP server: ${error instanceof Error ? error.message : String(error)}`, { - error: error instanceof Error ? error : new Error(String(error)) - }); - - // Clean up on error - this.close(); - - throw error; - } - } - - /** - * Stop the SMTP server - * @returns Promise that resolves when server is stopped - */ - public async close(): Promise { - if (!this.running) { - return; - } - - SmtpLogger.info('Stopping SMTP server'); - - try { - // Close all active connections - this.connectionManager.closeAllConnections(); - - // Clear all sessions - this.sessionManager.clearAllSessions(); - - // Clean up adaptive logger to prevent hanging timers - adaptiveLogger.destroy(); - - // Destroy all components to clean up their resources - await this.destroy(); - - // Close servers - const closePromises: Promise[] = []; - - if (this.server) { - closePromises.push( - new Promise((resolve, reject) => { - if (!this.server) { - resolve(); - return; - } - - this.server.close((err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }) - ); - } - - if (this.secureServer) { - closePromises.push( - new Promise((resolve, reject) => { - if (!this.secureServer) { - resolve(); - return; - } - - this.secureServer.close((err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }) - ); - } - - // Add timeout to prevent hanging on close - await Promise.race([ - Promise.all(closePromises), - new Promise((resolve) => { - setTimeout(() => { - SmtpLogger.warn('Server close timed out after 3 seconds, forcing shutdown'); - resolve(); - }, 3000); - }) - ]); - - this.server = null; - this.secureServer = null; - this.running = false; - - SmtpLogger.info('SMTP server stopped'); - } catch (error) { - SmtpLogger.error(`Error stopping SMTP server: ${error instanceof Error ? error.message : String(error)}`, { - error: error instanceof Error ? error : new Error(String(error)) - }); - - throw error; - } - } - - /** - * Get the session manager - * @returns Session manager instance - */ - public getSessionManager(): ISessionManager { - return this.sessionManager; - } - - /** - * Get the connection manager - * @returns Connection manager instance - */ - public getConnectionManager(): IConnectionManager { - return this.connectionManager; - } - - /** - * Get the command handler - * @returns Command handler instance - */ - public getCommandHandler(): ICommandHandler { - return this.commandHandler; - } - - /** - * Get the data handler - * @returns Data handler instance - */ - public getDataHandler(): IDataHandler { - return this.dataHandler; - } - - /** - * Get the TLS handler - * @returns TLS handler instance - */ - public getTlsHandler(): ITlsHandler { - return this.tlsHandler; - } - - /** - * Get the security handler - * @returns Security handler instance - */ - public getSecurityHandler(): ISecurityHandler { - return this.securityHandler; - } - - /** - * Get the server options - * @returns SMTP server options - */ - public getOptions(): ISmtpServerOptions { - return this.options; - } - - /** - * Get the email server reference - * @returns Email server instance - */ - public getEmailServer(): UnifiedEmailServer { - return this.emailServer; - } - - /** - * Check if the server is running - * @returns Whether the server is running - */ - public isRunning(): boolean { - return this.running; - } - - /** - * Check if we should attempt to recover from an error - * @param error - The error that occurred - * @returns Whether recovery should be attempted - */ - private shouldAttemptRecovery(error: Error): boolean { - // Skip recovery if we're already in recovery mode - if (this.recoveryState.recovering) { - return false; - } - - // Check if we've reached the maximum number of recovery attempts - if (this.recoveryState.currentRecoveryAttempt >= this.recoveryState.maxRecoveryAttempts) { - SmtpLogger.warn('Maximum recovery attempts reached, not attempting further recovery'); - return false; - } - - // Check if enough time has passed since the last recovery attempt - const now = Date.now(); - if (now - this.recoveryState.lastRecoveryAttempt < this.recoveryState.recoveryCooldown) { - SmtpLogger.warn('Recovery cooldown period not elapsed, skipping recovery attempt'); - return false; - } - - // Recoverable errors include: - // - EADDRINUSE: Address already in use (port conflict) - // - ECONNRESET: Connection reset by peer - // - EPIPE: Broken pipe - // - ETIMEDOUT: Connection timed out - const recoverableErrors = [ - 'EADDRINUSE', - 'ECONNRESET', - 'EPIPE', - 'ETIMEDOUT', - 'ECONNABORTED', - 'EPROTO', - 'EMFILE' // Too many open files - ]; - - // Check if this is a recoverable error - const errorCode = (error as any).code; - return recoverableErrors.includes(errorCode); - } - - /** - * Attempt to recover the server after a critical error - * @param serverType - The type of server to recover ('standard' or 'secure') - * @param error - The error that triggered recovery - */ - private async attemptServerRecovery(serverType: 'standard' | 'secure', error: Error): Promise { - // Set recovery flag to prevent multiple simultaneous recovery attempts - if (this.recoveryState.recovering) { - SmtpLogger.warn('Recovery already in progress, skipping new recovery attempt'); - return; - } - - this.recoveryState.recovering = true; - this.recoveryState.lastRecoveryAttempt = Date.now(); - this.recoveryState.currentRecoveryAttempt++; - - SmtpLogger.info(`Attempting server recovery for ${serverType} server after error: ${error.message}`, { - attempt: this.recoveryState.currentRecoveryAttempt, - maxAttempts: this.recoveryState.maxRecoveryAttempts, - errorCode: (error as any).code - }); - - try { - // Determine which server to restart - const isStandardServer = serverType === 'standard'; - - // Close the affected server - if (isStandardServer && this.server) { - await new Promise((resolve) => { - if (!this.server) { - resolve(); - return; - } - - // First try a clean shutdown - this.server.close((err) => { - if (err) { - SmtpLogger.warn(`Error during server close in recovery: ${err.message}`); - } - resolve(); - }); - - // Set a timeout to force close - setTimeout(() => { - resolve(); - }, 3000); - }); - - this.server = null; - } else if (!isStandardServer && this.secureServer) { - await new Promise((resolve) => { - if (!this.secureServer) { - resolve(); - return; - } - - // First try a clean shutdown - this.secureServer.close((err) => { - if (err) { - SmtpLogger.warn(`Error during secure server close in recovery: ${err.message}`); - } - resolve(); - }); - - // Set a timeout to force close - setTimeout(() => { - resolve(); - }, 3000); - }); - - this.secureServer = null; - } - - // Short delay before restarting - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Clean up any lingering connections - this.connectionManager.closeAllConnections(); - this.sessionManager.clearAllSessions(); - - // Restart the affected server - if (isStandardServer) { - // Create and start the standard server - this.server = plugins.net.createServer((socket) => { - // Check IP reputation before handling connection - this.securityHandler.checkIpReputation(socket) - .then(allowed => { - if (allowed) { - this.connectionManager.handleNewConnection(socket); - } else { - // Close connection if IP is not allowed - socket.destroy(); - } - }) - .catch(error => { - SmtpLogger.error(`IP reputation check error: ${error instanceof Error ? error.message : String(error)}`, { - remoteAddress: socket.remoteAddress, - error: error instanceof Error ? error : new Error(String(error)) - }); - - // Allow connection on error (fail open) - this.connectionManager.handleNewConnection(socket); - }); - }); - - // Set up error handling with recovery - this.server.on('error', (err) => { - SmtpLogger.error(`SMTP server error after recovery: ${err.message}`, { error: err }); - - // Try to recover again if needed - if (this.shouldAttemptRecovery(err)) { - this.attemptServerRecovery('standard', err); - } - }); - - // Start listening again - await new Promise((resolve, reject) => { - if (!this.server) { - reject(new Error('Server not initialized during recovery')); - return; - } - - this.server.listen(this.options.port, this.options.host, () => { - SmtpLogger.info(`SMTP server recovered and listening on ${this.options.host || '0.0.0.0'}:${this.options.port}`); - resolve(); - }); - - // Only use error event for startup issues during recovery - this.server.once('error', (err) => { - SmtpLogger.error(`Failed to restart server during recovery: ${err.message}`); - reject(err); - }); - }); - } else if (this.options.securePort && this.tlsHandler.isTlsEnabled()) { - // Try to recreate the secure server - try { - // Import the secure server creation utility - const { createSecureTlsServer } = await import('./secure-server.js'); - - // Create secure server with the certificates - this.secureServer = createSecureTlsServer({ - key: this.options.key, - cert: this.options.cert, - ca: this.options.ca - }); - - if (this.secureServer) { - SmtpLogger.info(`Created secure TLS server for port ${this.options.securePort} during recovery`); - - // Use explicit error handling for secure connections - this.secureServer.on('tlsClientError', (err, tlsSocket) => { - SmtpLogger.error(`TLS client error after recovery: ${err.message}`, { - error: err, - remoteAddress: tlsSocket.remoteAddress, - remotePort: tlsSocket.remotePort, - stack: err.stack - }); - }); - - // Register the secure connection handler - this.secureServer.on('secureConnection', (socket) => { - // Check IP reputation before handling connection - this.securityHandler.checkIpReputation(socket) - .then(allowed => { - if (allowed) { - // Pass the connection to the connection manager - this.connectionManager.handleNewSecureConnection(socket); - } else { - // Close connection if IP is not allowed - socket.destroy(); - } - }) - .catch(error => { - SmtpLogger.error(`IP reputation check error after recovery: ${error instanceof Error ? error.message : String(error)}`, { - remoteAddress: socket.remoteAddress, - error: error instanceof Error ? error : new Error(String(error)) - }); - - // Allow connection on error (fail open) - this.connectionManager.handleNewSecureConnection(socket); - }); - }); - - // Global error handler for the secure server with recovery - this.secureServer.on('error', (err) => { - SmtpLogger.error(`SMTP secure server error after recovery: ${err.message}`, { - error: err, - stack: err.stack - }); - - // Try to recover again if needed - if (this.shouldAttemptRecovery(err)) { - this.attemptServerRecovery('secure', err); - } - }); - - // Start listening on secure port again - await new Promise((resolve, reject) => { - if (!this.secureServer) { - reject(new Error('Secure server not initialized during recovery')); - return; - } - - this.secureServer.listen(this.options.securePort, this.options.host, () => { - SmtpLogger.info(`SMTP secure server recovered and listening on ${this.options.host || '0.0.0.0'}:${this.options.securePort}`); - resolve(); - }); - - // Only use error event for startup issues during recovery - this.secureServer.once('error', (err) => { - SmtpLogger.error(`Failed to restart secure server during recovery: ${err.message}`); - reject(err); - }); - }); - } else { - SmtpLogger.warn('Failed to create secure server during recovery'); - } - } catch (error) { - SmtpLogger.error(`Error setting up secure server during recovery: ${error instanceof Error ? error.message : String(error)}`); - } - } - - // Recovery successful - SmtpLogger.info('Server recovery completed successfully'); - - } catch (recoveryError) { - SmtpLogger.error(`Server recovery failed: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`, { - error: recoveryError instanceof Error ? recoveryError : new Error(String(recoveryError)), - attempt: this.recoveryState.currentRecoveryAttempt, - maxAttempts: this.recoveryState.maxRecoveryAttempts - }); - } finally { - // Reset recovery flag - this.recoveryState.recovering = false; - } - } - - /** - * Clean up all component resources - */ - public async destroy(): Promise { - SmtpLogger.info('Destroying SMTP server components'); - - // Destroy all components in parallel - const destroyPromises: Promise[] = []; - - if (this.sessionManager && typeof this.sessionManager.destroy === 'function') { - destroyPromises.push(Promise.resolve(this.sessionManager.destroy())); - } - - if (this.connectionManager && typeof this.connectionManager.destroy === 'function') { - destroyPromises.push(Promise.resolve(this.connectionManager.destroy())); - } - - if (this.commandHandler && typeof this.commandHandler.destroy === 'function') { - destroyPromises.push(Promise.resolve(this.commandHandler.destroy())); - } - - if (this.dataHandler && typeof this.dataHandler.destroy === 'function') { - destroyPromises.push(Promise.resolve(this.dataHandler.destroy())); - } - - if (this.tlsHandler && typeof this.tlsHandler.destroy === 'function') { - destroyPromises.push(Promise.resolve(this.tlsHandler.destroy())); - } - - if (this.securityHandler && typeof this.securityHandler.destroy === 'function') { - destroyPromises.push(Promise.resolve(this.securityHandler.destroy())); - } - - await Promise.all(destroyPromises); - - // Destroy the adaptive logger singleton to clean up its timer - const { adaptiveLogger } = await import('./utils/adaptive-logging.js'); - if (adaptiveLogger && typeof adaptiveLogger.destroy === 'function') { - adaptiveLogger.destroy(); - } - - // Clear recovery state - this.recoveryState = { - recovering: false, - connectionFailures: 0, - lastRecoveryAttempt: 0, - recoveryCooldown: 5000, - maxRecoveryAttempts: 3, - currentRecoveryAttempt: 0 - }; - - SmtpLogger.info('All SMTP server components destroyed'); - } -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/starttls-handler.ts b/ts/mail/delivery/smtpserver/starttls-handler.ts deleted file mode 100644 index 3baa82b..0000000 --- a/ts/mail/delivery/smtpserver/starttls-handler.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * STARTTLS Implementation - * Provides an improved implementation for STARTTLS upgrades - */ - -import * as plugins from '../../../plugins.js'; -import { SmtpLogger } from './utils/logging.js'; -import { - loadCertificatesFromString, - createTlsOptions, - type ICertificateData -} from './certificate-utils.js'; -import { getSocketDetails } from './utils/helpers.js'; -import type { ISmtpSession, ISessionManager, IConnectionManager } from './interfaces.js'; -import { SmtpState } from '../interfaces.js'; - -/** - * Enhanced STARTTLS handler for more reliable TLS upgrades - */ -export async function performStartTLS( - socket: plugins.net.Socket, - options: { - key: string; - cert: string; - ca?: string; - session?: ISmtpSession; - sessionManager?: ISessionManager; - connectionManager?: IConnectionManager; - onSuccess?: (tlsSocket: plugins.tls.TLSSocket) => void; - onFailure?: (error: Error) => void; - updateSessionState?: (session: ISmtpSession, state: SmtpState) => void; - } -): Promise { - return new Promise((resolve) => { - try { - const socketDetails = getSocketDetails(socket); - - SmtpLogger.info('Starting enhanced STARTTLS upgrade process', { - remoteAddress: socketDetails.remoteAddress, - remotePort: socketDetails.remotePort - }); - - // Create a proper socket cleanup function - const cleanupSocket = () => { - // Remove all listeners to prevent memory leaks - socket.removeAllListeners('data'); - socket.removeAllListeners('error'); - socket.removeAllListeners('close'); - socket.removeAllListeners('end'); - socket.removeAllListeners('drain'); - }; - - // Prepare the socket for TLS upgrade - socket.setNoDelay(true); - - // Critical: make sure there's no pending data before TLS handshake - socket.pause(); - - // Add error handling for the base socket - const handleSocketError = (err: Error) => { - SmtpLogger.error(`Socket error during STARTTLS preparation: ${err.message}`, { - remoteAddress: socketDetails.remoteAddress, - remotePort: socketDetails.remotePort, - error: err, - stack: err.stack - }); - - if (options.onFailure) { - options.onFailure(err); - } - - // Resolve with undefined to indicate failure - resolve(undefined); - }; - - socket.once('error', handleSocketError); - - // Load certificates - let certificates: ICertificateData; - try { - certificates = loadCertificatesFromString({ - key: options.key, - cert: options.cert, - ca: options.ca - }); - } catch (certError) { - SmtpLogger.error(`Certificate error during STARTTLS: ${certError instanceof Error ? certError.message : String(certError)}`); - - if (options.onFailure) { - options.onFailure(certError instanceof Error ? certError : new Error(String(certError))); - } - - resolve(undefined); - return; - } - - // Create TLS options optimized for STARTTLS - const tlsOptions = createTlsOptions(certificates, true); - - // Create secure context - let secureContext; - try { - secureContext = plugins.tls.createSecureContext(tlsOptions); - } catch (contextError) { - SmtpLogger.error(`Failed to create secure context: ${contextError instanceof Error ? contextError.message : String(contextError)}`); - - if (options.onFailure) { - options.onFailure(contextError instanceof Error ? contextError : new Error(String(contextError))); - } - - resolve(undefined); - return; - } - - // Log STARTTLS upgrade attempt - SmtpLogger.debug('Attempting TLS socket upgrade with options', { - minVersion: tlsOptions.minVersion, - maxVersion: tlsOptions.maxVersion, - handshakeTimeout: tlsOptions.handshakeTimeout - }); - - // Use a safer approach to create the TLS socket - const handshakeTimeout = 30000; // 30 seconds timeout for TLS handshake - let handshakeTimeoutId: NodeJS.Timeout | undefined; - - // Create the TLS socket using a conservative approach for STARTTLS - const tlsSocket = new plugins.tls.TLSSocket(socket, { - isServer: true, - secureContext, - // Server-side options (simpler is more reliable for STARTTLS) - requestCert: false, - rejectUnauthorized: false - }); - - // Set up error handling for the TLS socket - tlsSocket.once('error', (err) => { - if (handshakeTimeoutId) { - clearTimeout(handshakeTimeoutId); - } - - SmtpLogger.error(`TLS error during STARTTLS: ${err.message}`, { - remoteAddress: socketDetails.remoteAddress, - remotePort: socketDetails.remotePort, - error: err, - stack: err.stack - }); - - // Clean up socket listeners - cleanupSocket(); - - if (options.onFailure) { - options.onFailure(err); - } - - // Destroy the socket to ensure we don't have hanging connections - tlsSocket.destroy(); - resolve(undefined); - }); - - // Set up handshake timeout manually for extra safety - handshakeTimeoutId = setTimeout(() => { - SmtpLogger.error('TLS handshake timed out', { - remoteAddress: socketDetails.remoteAddress, - remotePort: socketDetails.remotePort - }); - - // Clean up socket listeners - cleanupSocket(); - - if (options.onFailure) { - options.onFailure(new Error('TLS handshake timed out')); - } - - // Destroy the socket to ensure we don't have hanging connections - tlsSocket.destroy(); - resolve(undefined); - }, handshakeTimeout); - - // Set up handler for successful TLS negotiation - tlsSocket.once('secure', () => { - if (handshakeTimeoutId) { - clearTimeout(handshakeTimeoutId); - } - - const protocol = tlsSocket.getProtocol(); - const cipher = tlsSocket.getCipher(); - - SmtpLogger.info('TLS upgrade successful via STARTTLS', { - remoteAddress: socketDetails.remoteAddress, - remotePort: socketDetails.remotePort, - protocol: protocol || 'unknown', - cipher: cipher?.name || 'unknown' - }); - - // Update socket mapping in session manager - if (options.sessionManager) { - const socketReplaced = options.sessionManager.replaceSocket(socket, tlsSocket); - if (!socketReplaced) { - SmtpLogger.error('Failed to replace socket in session manager after STARTTLS', { - remoteAddress: socketDetails.remoteAddress, - remotePort: socketDetails.remotePort - }); - } - } - - // Re-attach event handlers from connection manager - if (options.connectionManager) { - try { - options.connectionManager.setupSocketEventHandlers(tlsSocket); - SmtpLogger.debug('Successfully re-attached connection manager event handlers to TLS socket', { - remoteAddress: socketDetails.remoteAddress, - remotePort: socketDetails.remotePort - }); - } catch (handlerError) { - SmtpLogger.error('Failed to re-attach event handlers to TLS socket after STARTTLS', { - remoteAddress: socketDetails.remoteAddress, - remotePort: socketDetails.remotePort, - error: handlerError instanceof Error ? handlerError : new Error(String(handlerError)) - }); - } - } - - // Update session if provided - if (options.session) { - // Update session properties to indicate TLS is active - options.session.useTLS = true; - options.session.secure = true; - - // Reset session state as required by RFC 3207 - // After STARTTLS, client must issue a new EHLO - if (options.updateSessionState) { - options.updateSessionState(options.session, SmtpState.GREETING); - } - } - - // Call success callback if provided - if (options.onSuccess) { - options.onSuccess(tlsSocket); - } - - // Success - return the TLS socket - resolve(tlsSocket); - }); - - // Resume the socket after we've set up all handlers - // This allows the TLS handshake to proceed - socket.resume(); - - } catch (error) { - SmtpLogger.error(`Unexpected error in STARTTLS: ${error instanceof Error ? error.message : String(error)}`, { - error: error instanceof Error ? error : new Error(String(error)), - stack: error instanceof Error ? error.stack : 'No stack trace available' - }); - - if (options.onFailure) { - options.onFailure(error instanceof Error ? error : new Error(String(error))); - } - - resolve(undefined); - } - }); -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/tls-handler.ts b/ts/mail/delivery/smtpserver/tls-handler.ts deleted file mode 100644 index 20007e0..0000000 --- a/ts/mail/delivery/smtpserver/tls-handler.ts +++ /dev/null @@ -1,346 +0,0 @@ -/** - * SMTP TLS Handler - * Responsible for handling TLS-related SMTP functionality - */ - -import * as plugins from '../../../plugins.js'; -import type { ITlsHandler, ISmtpServer, ISmtpSession } from './interfaces.js'; -import { SmtpResponseCode, SecurityEventType, SecurityLogLevel } from './constants.js'; -import { SmtpLogger } from './utils/logging.js'; -import { getSocketDetails, getTlsDetails } from './utils/helpers.js'; -import { - loadCertificatesFromString, - generateSelfSignedCertificates, - createTlsOptions, - type ICertificateData -} from './certificate-utils.js'; -import { SmtpState } from '../interfaces.js'; - -/** - * Handles TLS functionality for SMTP server - */ -export class TlsHandler implements ITlsHandler { - /** - * Reference to the SMTP server instance - */ - private smtpServer: ISmtpServer; - - /** - * Certificate data - */ - private certificates: ICertificateData; - - /** - * TLS options - */ - private options: plugins.tls.TlsOptions; - - /** - * Creates a new TLS handler - * @param smtpServer - SMTP server instance - */ - constructor(smtpServer: ISmtpServer) { - this.smtpServer = smtpServer; - - // Initialize certificates - const serverOptions = this.smtpServer.getOptions(); - try { - // Try to load certificates from provided options - this.certificates = loadCertificatesFromString({ - key: serverOptions.key, - cert: serverOptions.cert, - ca: serverOptions.ca - }); - - SmtpLogger.info('Successfully loaded TLS certificates'); - } catch (error) { - SmtpLogger.warn(`Failed to load certificates from options, using self-signed: ${error instanceof Error ? error.message : String(error)}`); - - // Fall back to self-signed certificates for testing - this.certificates = generateSelfSignedCertificates(); - } - - // Initialize TLS options - this.options = createTlsOptions(this.certificates); - } - - /** - * Handle STARTTLS command - * @param socket - Client socket - */ - public async handleStartTls(socket: plugins.net.Socket, session: ISmtpSession): Promise { - - // Check if already using TLS - if (session.useTLS) { - this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} TLS already active`); - return null; - } - - // Check if we have the necessary TLS certificates - if (!this.isTlsEnabled()) { - this.sendResponse(socket, `${SmtpResponseCode.TLS_UNAVAILABLE_TEMP} TLS not available`); - return null; - } - - // Send ready for TLS response - this.sendResponse(socket, `${SmtpResponseCode.SERVICE_READY} Ready to start TLS`); - - // Upgrade the connection to TLS - try { - const tlsSocket = await this.startTLS(socket); - return tlsSocket; - } catch (error) { - SmtpLogger.error(`STARTTLS negotiation failed: ${error instanceof Error ? error.message : String(error)}`, { - sessionId: session.id, - remoteAddress: session.remoteAddress, - error: error instanceof Error ? error : new Error(String(error)) - }); - - // Log security event - SmtpLogger.logSecurityEvent( - SecurityLogLevel.ERROR, - SecurityEventType.TLS_NEGOTIATION, - 'STARTTLS negotiation failed', - { error: error instanceof Error ? error.message : String(error) }, - session.remoteAddress - ); - - return null; - } - } - - /** - * Upgrade a connection to TLS - * @param socket - Client socket - */ - public async startTLS(socket: plugins.net.Socket): Promise { - // Get the session for this socket - const session = this.smtpServer.getSessionManager().getSession(socket); - - try { - // Import the enhanced STARTTLS handler - // This uses a more robust approach to TLS upgrades - const { performStartTLS } = await import('./starttls-handler.js'); - - SmtpLogger.info('Using enhanced STARTTLS implementation'); - - // Use the enhanced STARTTLS handler with better error handling and socket management - const serverOptions = this.smtpServer.getOptions(); - const tlsSocket = await performStartTLS(socket, { - key: serverOptions.key, - cert: serverOptions.cert, - ca: serverOptions.ca, - session: session, - sessionManager: this.smtpServer.getSessionManager(), - connectionManager: this.smtpServer.getConnectionManager(), - // Callback for successful upgrade - onSuccess: (secureSocket) => { - SmtpLogger.info('TLS connection successfully established via enhanced STARTTLS', { - remoteAddress: secureSocket.remoteAddress, - remotePort: secureSocket.remotePort, - protocol: secureSocket.getProtocol() || 'unknown', - cipher: secureSocket.getCipher()?.name || 'unknown' - }); - - // Log security event - SmtpLogger.logSecurityEvent( - SecurityLogLevel.INFO, - SecurityEventType.TLS_NEGOTIATION, - 'STARTTLS successful with enhanced implementation', - { - protocol: secureSocket.getProtocol(), - cipher: secureSocket.getCipher()?.name - }, - secureSocket.remoteAddress, - undefined, - true - ); - }, - // Callback for failed upgrade - onFailure: (error) => { - SmtpLogger.error(`Enhanced STARTTLS failed: ${error.message}`, { - sessionId: session?.id, - remoteAddress: socket.remoteAddress, - error - }); - - // Log security event - SmtpLogger.logSecurityEvent( - SecurityLogLevel.ERROR, - SecurityEventType.TLS_NEGOTIATION, - 'Enhanced STARTTLS failed', - { error: error.message }, - socket.remoteAddress, - undefined, - false - ); - }, - // Function to update session state - updateSessionState: this.smtpServer.getSessionManager().updateSessionState?.bind(this.smtpServer.getSessionManager()) - }); - - // If STARTTLS failed with the enhanced implementation, log the error - if (!tlsSocket) { - SmtpLogger.warn('Enhanced STARTTLS implementation failed to create TLS socket', { - sessionId: session?.id, - remoteAddress: socket.remoteAddress - }); - throw new Error('Failed to create TLS socket'); - } - - return tlsSocket; - } catch (error) { - // Log STARTTLS failure - SmtpLogger.error(`Failed to upgrade connection to TLS: ${error instanceof Error ? error.message : String(error)}`, { - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - error: error instanceof Error ? error : new Error(String(error)), - stack: error instanceof Error ? error.stack : 'No stack trace available' - }); - - // Log security event - SmtpLogger.logSecurityEvent( - SecurityLogLevel.ERROR, - SecurityEventType.TLS_NEGOTIATION, - 'Failed to upgrade connection to TLS', - { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : 'No stack trace available' - }, - socket.remoteAddress, - undefined, - false - ); - - // Destroy the socket on error - socket.destroy(); - throw error; - } - } - - /** - * Create a secure server - * @returns TLS server instance or undefined if TLS is not enabled - */ - public createSecureServer(): plugins.tls.Server | undefined { - if (!this.isTlsEnabled()) { - return undefined; - } - - try { - SmtpLogger.info('Creating secure TLS server'); - - // Log certificate info - SmtpLogger.debug('Using certificates for secure server', { - keyLength: this.certificates.key.length, - certLength: this.certificates.cert.length, - caLength: this.certificates.ca ? this.certificates.ca.length : 0 - }); - - // Create TLS options using our certificate utilities - // This ensures proper PEM format handling and protocol negotiation - const tlsOptions = createTlsOptions(this.certificates, true); // Use server options - - SmtpLogger.info('Creating TLS server with options', { - minVersion: tlsOptions.minVersion, - maxVersion: tlsOptions.maxVersion, - handshakeTimeout: tlsOptions.handshakeTimeout - }); - - // Create a server with wider TLS compatibility - const server = new plugins.tls.Server(tlsOptions); - - // Add error handling - server.on('error', (err) => { - SmtpLogger.error(`TLS server error: ${err.message}`, { - error: err, - stack: err.stack - }); - }); - - // Log TLS details for each connection - server.on('secureConnection', (socket) => { - SmtpLogger.info('New secure connection established', { - protocol: socket.getProtocol(), - cipher: socket.getCipher()?.name, - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort - }); - }); - - return server; - } catch (error) { - SmtpLogger.error(`Failed to create secure server: ${error instanceof Error ? error.message : String(error)}`, { - error: error instanceof Error ? error : new Error(String(error)), - stack: error instanceof Error ? error.stack : 'No stack trace available' - }); - - return undefined; - } - } - - /** - * Check if TLS is enabled - * @returns Whether TLS is enabled - */ - public isTlsEnabled(): boolean { - const options = this.smtpServer.getOptions(); - return !!(options.key && options.cert); - } - - /** - * Send a response to the client - * @param socket - Client socket - * @param response - Response message - */ - private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void { - // Check if socket is still writable before attempting to write - if (socket.destroyed || socket.readyState !== 'open' || !socket.writable) { - SmtpLogger.debug(`Skipping response to closed/destroyed socket: ${response}`, { - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - destroyed: socket.destroyed, - readyState: socket.readyState, - writable: socket.writable - }); - return; - } - - try { - socket.write(`${response}\r\n`); - SmtpLogger.logResponse(response, socket); - } catch (error) { - SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, { - response, - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - error: error instanceof Error ? error : new Error(String(error)) - }); - - socket.destroy(); - } - } - - /** - * Check if TLS is available (interface requirement) - */ - public isTlsAvailable(): boolean { - return this.isTlsEnabled(); - } - - /** - * Get TLS options (interface requirement) - */ - public getTlsOptions(): plugins.tls.TlsOptions { - return this.options; - } - - /** - * Clean up resources - */ - public destroy(): void { - // Clear any cached certificates or TLS contexts - // TlsHandler doesn't have timers but may have cached resources - SmtpLogger.debug('TlsHandler destroyed'); - } -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/utils/adaptive-logging.ts b/ts/mail/delivery/smtpserver/utils/adaptive-logging.ts deleted file mode 100644 index 24880fb..0000000 --- a/ts/mail/delivery/smtpserver/utils/adaptive-logging.ts +++ /dev/null @@ -1,514 +0,0 @@ -/** - * Adaptive SMTP Logging System - * Automatically switches between logging modes based on server load (active connections) - * to maintain performance during high-concurrency scenarios - */ - -import * as plugins from '../../../../plugins.js'; -import { logger } from '../../../../logger.js'; -import { SecurityLogLevel, SecurityEventType } from '../constants.js'; -import type { ISmtpSession } from '../interfaces.js'; -import type { LogLevel, ISmtpLogOptions } from './logging.js'; - -/** - * Log modes based on server load - */ -export enum LogMode { - VERBOSE = 'VERBOSE', // < 20 connections: Full detailed logging - REDUCED = 'REDUCED', // 20-40 connections: Limited command/response logging, full error logging - MINIMAL = 'MINIMAL' // 40+ connections: Aggregated logging only, critical errors only -} - -/** - * Configuration for adaptive logging thresholds - */ -export interface IAdaptiveLogConfig { - verboseThreshold: number; // Switch to REDUCED mode above this connection count - reducedThreshold: number; // Switch to MINIMAL mode above this connection count - aggregationInterval: number; // How often to flush aggregated logs (ms) - maxAggregatedEntries: number; // Max entries to hold before forced flush -} - -/** - * Aggregated log entry for batching similar events - */ -interface IAggregatedLogEntry { - type: 'connection' | 'command' | 'response' | 'error'; - count: number; - firstSeen: number; - lastSeen: number; - sample: { - message: string; - level: LogLevel; - options?: ISmtpLogOptions; - }; -} - -/** - * Connection metadata for aggregation tracking - */ -interface IConnectionTracker { - activeConnections: number; - peakConnections: number; - totalConnections: number; - connectionsPerSecond: number; - lastConnectionTime: number; -} - -/** - * Adaptive SMTP Logger that scales logging based on server load - */ -export class AdaptiveSmtpLogger { - private static instance: AdaptiveSmtpLogger; - private currentMode: LogMode = LogMode.VERBOSE; - private config: IAdaptiveLogConfig; - private aggregatedEntries: Map = new Map(); - private aggregationTimer: NodeJS.Timeout | null = null; - private connectionTracker: IConnectionTracker = { - activeConnections: 0, - peakConnections: 0, - totalConnections: 0, - connectionsPerSecond: 0, - lastConnectionTime: Date.now() - }; - - private constructor(config?: Partial) { - this.config = { - verboseThreshold: 20, - reducedThreshold: 40, - aggregationInterval: 30000, // 30 seconds - maxAggregatedEntries: 100, - ...config - }; - - this.startAggregationTimer(); - } - - /** - * Get singleton instance - */ - public static getInstance(config?: Partial): AdaptiveSmtpLogger { - if (!AdaptiveSmtpLogger.instance) { - AdaptiveSmtpLogger.instance = new AdaptiveSmtpLogger(config); - } - return AdaptiveSmtpLogger.instance; - } - - /** - * Update active connection count and adjust log mode if needed - */ - public updateConnectionCount(activeConnections: number): void { - this.connectionTracker.activeConnections = activeConnections; - this.connectionTracker.peakConnections = Math.max( - this.connectionTracker.peakConnections, - activeConnections - ); - - const newMode = this.determineLogMode(activeConnections); - if (newMode !== this.currentMode) { - this.switchLogMode(newMode); - } - } - - /** - * Track new connection for rate calculation - */ - public trackConnection(): void { - this.connectionTracker.totalConnections++; - const now = Date.now(); - const timeDiff = (now - this.connectionTracker.lastConnectionTime) / 1000; - if (timeDiff > 0) { - this.connectionTracker.connectionsPerSecond = 1 / timeDiff; - } - this.connectionTracker.lastConnectionTime = now; - } - - /** - * Get current logging mode - */ - public getCurrentMode(): LogMode { - return this.currentMode; - } - - /** - * Get connection statistics - */ - public getConnectionStats(): IConnectionTracker { - return { ...this.connectionTracker }; - } - - /** - * Log a message with adaptive behavior - */ - public log(level: LogLevel, message: string, options: ISmtpLogOptions = {}): void { - // Always log structured data - const errorInfo = options.error ? { - errorMessage: options.error.message, - errorStack: options.error.stack, - errorName: options.error.name - } : {}; - - const logData = { - component: 'smtp-server', - logMode: this.currentMode, - activeConnections: this.connectionTracker.activeConnections, - ...options, - ...errorInfo - }; - - if (logData.error) { - delete logData.error; - } - - logger.log(level, message, logData); - - // Adaptive console logging based on mode - switch (this.currentMode) { - case LogMode.VERBOSE: - // Full console logging - if (level === 'error' || level === 'warn') { - console[level](`[SMTP] ${message}`, logData); - } - break; - - case LogMode.REDUCED: - // Only errors and warnings to console - if (level === 'error' || level === 'warn') { - console[level](`[SMTP] ${message}`, logData); - } - break; - - case LogMode.MINIMAL: - // Only critical errors to console - if (level === 'error' && (message.includes('critical') || message.includes('security') || message.includes('crash'))) { - console[level](`[SMTP] ${message}`, logData); - } - break; - } - } - - /** - * Log command with adaptive behavior - */ - public logCommand(command: string, socket: plugins.net.Socket | plugins.tls.TLSSocket, session?: ISmtpSession): void { - const clientInfo = { - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - secure: socket instanceof plugins.tls.TLSSocket, - sessionId: session?.id, - sessionState: session?.state - }; - - switch (this.currentMode) { - case LogMode.VERBOSE: - this.log('info', `Command received: ${command}`, { - ...clientInfo, - command: command.split(' ')[0]?.toUpperCase() - }); - console.log(`← ${command}`); - break; - - case LogMode.REDUCED: - // Aggregate commands instead of logging each one - this.aggregateEntry('command', 'info', `Command: ${command.split(' ')[0]?.toUpperCase()}`, clientInfo); - // Only show error commands - if (command.toUpperCase().startsWith('QUIT') || command.includes('error')) { - console.log(`← ${command}`); - } - break; - - case LogMode.MINIMAL: - // Only aggregate, no console output unless it's an error command - this.aggregateEntry('command', 'info', `Command: ${command.split(' ')[0]?.toUpperCase()}`, clientInfo); - break; - } - } - - /** - * Log response with adaptive behavior - */ - public logResponse(response: string, socket: plugins.net.Socket | plugins.tls.TLSSocket): void { - const clientInfo = { - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - secure: socket instanceof plugins.tls.TLSSocket - }; - - const responseCode = response.substring(0, 3); - const isError = responseCode.startsWith('4') || responseCode.startsWith('5'); - - switch (this.currentMode) { - case LogMode.VERBOSE: - if (responseCode.startsWith('2') || responseCode.startsWith('3')) { - this.log('debug', `Response sent: ${response}`, clientInfo); - } else if (responseCode.startsWith('4')) { - this.log('warn', `Temporary error response: ${response}`, clientInfo); - } else if (responseCode.startsWith('5')) { - this.log('error', `Permanent error response: ${response}`, clientInfo); - } - console.log(`→ ${response}`); - break; - - case LogMode.REDUCED: - // Log errors normally, aggregate success responses - if (isError) { - if (responseCode.startsWith('4')) { - this.log('warn', `Temporary error response: ${response}`, clientInfo); - } else { - this.log('error', `Permanent error response: ${response}`, clientInfo); - } - console.log(`→ ${response}`); - } else { - this.aggregateEntry('response', 'debug', `Response: ${responseCode}xx`, clientInfo); - } - break; - - case LogMode.MINIMAL: - // Only log critical errors - if (responseCode.startsWith('5')) { - this.log('error', `Permanent error response: ${response}`, clientInfo); - console.log(`→ ${response}`); - } else { - this.aggregateEntry('response', 'debug', `Response: ${responseCode}xx`, clientInfo); - } - break; - } - } - - /** - * Log connection event with adaptive behavior - */ - public logConnection( - socket: plugins.net.Socket | plugins.tls.TLSSocket, - eventType: 'connect' | 'close' | 'error', - session?: ISmtpSession, - error?: Error - ): void { - const clientInfo = { - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - secure: socket instanceof plugins.tls.TLSSocket, - sessionId: session?.id, - sessionState: session?.state - }; - - if (eventType === 'connect') { - this.trackConnection(); - } - - switch (this.currentMode) { - case LogMode.VERBOSE: - // Full connection logging - switch (eventType) { - case 'connect': - this.log('info', `New ${clientInfo.secure ? 'secure ' : ''}connection from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, clientInfo); - break; - case 'close': - this.log('info', `Connection closed from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, clientInfo); - break; - case 'error': - this.log('error', `Connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, { - ...clientInfo, - error - }); - break; - } - break; - - case LogMode.REDUCED: - // Aggregate normal connections, log errors - if (eventType === 'error') { - this.log('error', `Connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, { - ...clientInfo, - error - }); - } else { - this.aggregateEntry('connection', 'info', `Connection ${eventType}`, clientInfo); - } - break; - - case LogMode.MINIMAL: - // Only aggregate, except for critical errors - if (eventType === 'error' && error && (error.message.includes('security') || error.message.includes('critical'))) { - this.log('error', `Critical connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, { - ...clientInfo, - error - }); - } else { - this.aggregateEntry('connection', 'info', `Connection ${eventType}`, clientInfo); - } - break; - } - } - - /** - * Log security event (always logged regardless of mode) - */ - public logSecurityEvent( - level: SecurityLogLevel, - type: SecurityEventType, - message: string, - details: Record, - ipAddress?: string, - domain?: string, - success?: boolean - ): void { - const logLevel: LogLevel = level === SecurityLogLevel.DEBUG ? 'debug' : - level === SecurityLogLevel.INFO ? 'info' : - level === SecurityLogLevel.WARN ? 'warn' : 'error'; - - // Security events are always logged in full detail - this.log(logLevel, message, { - component: 'smtp-security', - eventType: type, - success, - ipAddress, - domain, - ...details - }); - } - - /** - * Determine appropriate log mode based on connection count - */ - private determineLogMode(activeConnections: number): LogMode { - if (activeConnections >= this.config.reducedThreshold) { - return LogMode.MINIMAL; - } else if (activeConnections >= this.config.verboseThreshold) { - return LogMode.REDUCED; - } else { - return LogMode.VERBOSE; - } - } - - /** - * Switch to a new log mode - */ - private switchLogMode(newMode: LogMode): void { - const oldMode = this.currentMode; - this.currentMode = newMode; - - // Log the mode switch - console.log(`[SMTP] Adaptive logging switched from ${oldMode} to ${newMode} (${this.connectionTracker.activeConnections} active connections)`); - - this.log('info', `Adaptive logging mode changed to ${newMode}`, { - oldMode, - newMode, - activeConnections: this.connectionTracker.activeConnections, - peakConnections: this.connectionTracker.peakConnections, - totalConnections: this.connectionTracker.totalConnections - }); - - // If switching to more verbose mode, flush aggregated entries - if ((oldMode === LogMode.MINIMAL && newMode !== LogMode.MINIMAL) || - (oldMode === LogMode.REDUCED && newMode === LogMode.VERBOSE)) { - this.flushAggregatedEntries(); - } - } - - /** - * Add entry to aggregation buffer - */ - private aggregateEntry( - type: 'connection' | 'command' | 'response' | 'error', - level: LogLevel, - message: string, - options?: ISmtpLogOptions - ): void { - const key = `${type}:${message}`; - const now = Date.now(); - - if (this.aggregatedEntries.has(key)) { - const entry = this.aggregatedEntries.get(key)!; - entry.count++; - entry.lastSeen = now; - } else { - this.aggregatedEntries.set(key, { - type, - count: 1, - firstSeen: now, - lastSeen: now, - sample: { message, level, options } - }); - } - - // Force flush if we have too many entries - if (this.aggregatedEntries.size >= this.config.maxAggregatedEntries) { - this.flushAggregatedEntries(); - } - } - - /** - * Start the aggregation timer - */ - private startAggregationTimer(): void { - if (this.aggregationTimer) { - clearInterval(this.aggregationTimer); - } - - this.aggregationTimer = setInterval(() => { - this.flushAggregatedEntries(); - }, this.config.aggregationInterval); - - // Unref the timer so it doesn't keep the process alive - if (this.aggregationTimer && typeof this.aggregationTimer.unref === 'function') { - this.aggregationTimer.unref(); - } - } - - /** - * Flush aggregated entries to logs - */ - private flushAggregatedEntries(): void { - if (this.aggregatedEntries.size === 0) { - return; - } - - const summary: Record = {}; - let totalAggregated = 0; - - for (const [key, entry] of this.aggregatedEntries.entries()) { - summary[entry.type] = (summary[entry.type] || 0) + entry.count; - totalAggregated += entry.count; - - // Log a sample of high-frequency entries - if (entry.count >= 10) { - this.log(entry.sample.level, `${entry.sample.message} (aggregated: ${entry.count} occurrences)`, { - ...entry.sample.options, - aggregated: true, - occurrences: entry.count, - timeSpan: entry.lastSeen - entry.firstSeen - }); - } - } - - // Log aggregation summary - console.log(`[SMTP] Aggregated ${totalAggregated} log entries: ${JSON.stringify(summary)}`); - - this.log('info', 'Aggregated log summary', { - totalEntries: totalAggregated, - breakdown: summary, - logMode: this.currentMode, - activeConnections: this.connectionTracker.activeConnections - }); - - // Clear aggregated entries - this.aggregatedEntries.clear(); - } - - /** - * Cleanup resources - */ - public destroy(): void { - if (this.aggregationTimer) { - clearInterval(this.aggregationTimer); - this.aggregationTimer = null; - } - this.flushAggregatedEntries(); - } -} - -/** - * Default instance for easy access - */ -export const adaptiveLogger = AdaptiveSmtpLogger.getInstance(); \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/utils/helpers.ts b/ts/mail/delivery/smtpserver/utils/helpers.ts deleted file mode 100644 index ea8906f..0000000 --- a/ts/mail/delivery/smtpserver/utils/helpers.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * SMTP Helper Functions - * Provides utility functions for SMTP server implementation - */ - -import * as plugins from '../../../../plugins.js'; -import { SMTP_DEFAULTS } from '../constants.js'; -import type { ISmtpSession, ISmtpServerOptions } from '../interfaces.js'; - -/** - * Formats a multi-line SMTP response according to RFC 5321 - * @param code - Response code - * @param lines - Response lines - * @returns Formatted SMTP response - */ -export function formatMultilineResponse(code: number, lines: string[]): string { - if (!lines || lines.length === 0) { - return `${code} `; - } - - if (lines.length === 1) { - return `${code} ${lines[0]}`; - } - - let response = ''; - for (let i = 0; i < lines.length - 1; i++) { - response += `${code}-${lines[i]}${SMTP_DEFAULTS.CRLF}`; - } - response += `${code} ${lines[lines.length - 1]}`; - - return response; -} - -/** - * Generates a unique session ID - * @returns Unique session ID - */ -export function generateSessionId(): string { - return `${Date.now()}-${Math.floor(Math.random() * 10000)}`; -} - -/** - * Safely parses an integer from string with a default value - * @param value - String value to parse - * @param defaultValue - Default value if parsing fails - * @returns Parsed integer or default value - */ -export function safeParseInt(value: string | undefined, defaultValue: number): number { - if (!value) { - return defaultValue; - } - - const parsed = parseInt(value, 10); - return isNaN(parsed) ? defaultValue : parsed; -} - -/** - * Safely gets the socket details - * @param socket - Socket to get details from - * @returns Socket details object - */ -export function getSocketDetails(socket: plugins.net.Socket | plugins.tls.TLSSocket): { - remoteAddress: string; - remotePort: number; - remoteFamily: string; - localAddress: string; - localPort: number; - encrypted: boolean; -} { - return { - remoteAddress: socket.remoteAddress || 'unknown', - remotePort: socket.remotePort || 0, - remoteFamily: socket.remoteFamily || 'unknown', - localAddress: socket.localAddress || 'unknown', - localPort: socket.localPort || 0, - encrypted: socket instanceof plugins.tls.TLSSocket - }; -} - -/** - * Gets TLS details if socket is TLS - * @param socket - Socket to get TLS details from - * @returns TLS details or undefined if not TLS - */ -export function getTlsDetails(socket: plugins.net.Socket | plugins.tls.TLSSocket): { - protocol?: string; - cipher?: string; - authorized?: boolean; -} | undefined { - if (!(socket instanceof plugins.tls.TLSSocket)) { - return undefined; - } - - return { - protocol: socket.getProtocol(), - cipher: socket.getCipher()?.name, - authorized: socket.authorized - }; -} - -/** - * Merges default options with provided options - * @param options - User provided options - * @returns Merged options with defaults - */ -export function mergeWithDefaults(options: Partial): ISmtpServerOptions { - return { - port: options.port || SMTP_DEFAULTS.SMTP_PORT, - key: options.key || '', - cert: options.cert || '', - hostname: options.hostname || SMTP_DEFAULTS.HOSTNAME, - host: options.host, - securePort: options.securePort, - ca: options.ca, - maxSize: options.size || SMTP_DEFAULTS.MAX_MESSAGE_SIZE, - maxConnections: options.maxConnections || SMTP_DEFAULTS.MAX_CONNECTIONS, - socketTimeout: options.socketTimeout || SMTP_DEFAULTS.SOCKET_TIMEOUT, - connectionTimeout: options.connectionTimeout || SMTP_DEFAULTS.CONNECTION_TIMEOUT, - cleanupInterval: options.cleanupInterval || SMTP_DEFAULTS.CLEANUP_INTERVAL, - maxRecipients: options.maxRecipients || SMTP_DEFAULTS.MAX_RECIPIENTS, - size: options.size || SMTP_DEFAULTS.MAX_MESSAGE_SIZE, - dataTimeout: options.dataTimeout || SMTP_DEFAULTS.DATA_TIMEOUT, - auth: options.auth, - }; -} - -/** - * Creates a text response formatter for the SMTP server - * @param socket - Socket to send responses to - * @returns Function to send formatted response - */ -export function createResponseFormatter(socket: plugins.net.Socket | plugins.tls.TLSSocket): (response: string) => void { - return (response: string): void => { - try { - socket.write(`${response}${SMTP_DEFAULTS.CRLF}`); - console.log(`→ ${response}`); - } catch (error) { - console.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`); - socket.destroy(); - } - }; -} - -/** - * Extracts SMTP command name from a command line - * @param commandLine - Full command line - * @returns Command name in uppercase - */ -export function extractCommandName(commandLine: string): string { - if (!commandLine || typeof commandLine !== 'string') { - return ''; - } - - // Handle specific command patterns first - const ehloMatch = commandLine.match(/^(EHLO|HELO)\b/i); - if (ehloMatch) { - return ehloMatch[1].toUpperCase(); - } - - const mailMatch = commandLine.match(/^MAIL\b/i); - if (mailMatch) { - return 'MAIL'; - } - - const rcptMatch = commandLine.match(/^RCPT\b/i); - if (rcptMatch) { - return 'RCPT'; - } - - // Default handling - const parts = commandLine.trim().split(/\s+/); - return (parts[0] || '').toUpperCase(); -} - -/** - * Extracts SMTP command arguments from a command line - * @param commandLine - Full command line - * @returns Arguments string - */ -export function extractCommandArgs(commandLine: string): string { - if (!commandLine || typeof commandLine !== 'string') { - return ''; - } - - const command = extractCommandName(commandLine); - if (!command) { - return commandLine.trim(); - } - - // Special handling for specific commands - if (command === 'EHLO' || command === 'HELO') { - const match = commandLine.match(/^(?:EHLO|HELO)\s+(.+)$/i); - return match ? match[1].trim() : ''; - } - - if (command === 'MAIL') { - return commandLine.replace(/^MAIL\s+/i, ''); - } - - if (command === 'RCPT') { - return commandLine.replace(/^RCPT\s+/i, ''); - } - - // Default extraction - const firstSpace = commandLine.indexOf(' '); - if (firstSpace === -1) { - return ''; - } - - return commandLine.substring(firstSpace + 1).trim(); -} - -/** - * Sanitizes data for logging (hides sensitive info) - * @param data - Data to sanitize - * @returns Sanitized data - */ -export function sanitizeForLogging(data: any): any { - if (!data) { - return data; - } - - if (typeof data !== 'object') { - return data; - } - - const result: any = Array.isArray(data) ? [] : {}; - - for (const key in data) { - if (Object.prototype.hasOwnProperty.call(data, key)) { - // Sanitize sensitive fields - if (key.toLowerCase().includes('password') || - key.toLowerCase().includes('token') || - key.toLowerCase().includes('secret') || - key.toLowerCase().includes('credential')) { - result[key] = '********'; - } else if (typeof data[key] === 'object' && data[key] !== null) { - result[key] = sanitizeForLogging(data[key]); - } else { - result[key] = data[key]; - } - } - } - - return result; -} \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/utils/logging.ts b/ts/mail/delivery/smtpserver/utils/logging.ts deleted file mode 100644 index e45b398..0000000 --- a/ts/mail/delivery/smtpserver/utils/logging.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * SMTP Logging Utilities - * Provides structured logging for SMTP server components - */ - -import * as plugins from '../../../../plugins.js'; -import { logger } from '../../../../logger.js'; -import { SecurityLogLevel, SecurityEventType } from '../constants.js'; -import type { ISmtpSession } from '../interfaces.js'; - -/** - * SMTP connection metadata to include in logs - */ -export interface IConnectionMetadata { - remoteAddress?: string; - remotePort?: number; - socketId?: string; - secure?: boolean; - sessionId?: string; -} - -/** - * Log levels for SMTP server - */ -export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; - -/** - * Options for SMTP log - */ -export interface ISmtpLogOptions { - level?: LogLevel; - sessionId?: string; - sessionState?: string; - remoteAddress?: string; - remotePort?: number; - command?: string; - error?: Error; - [key: string]: any; -} - -/** - * SMTP logger - provides structured logging for SMTP server - */ -export class SmtpLogger { - /** - * Log a message with context - * @param level - Log level - * @param message - Log message - * @param options - Additional log options - */ - public static log(level: LogLevel, message: string, options: ISmtpLogOptions = {}): void { - // Extract error information if provided - const errorInfo = options.error ? { - errorMessage: options.error.message, - errorStack: options.error.stack, - errorName: options.error.name - } : {}; - - // Structure log data - const logData = { - component: 'smtp-server', - ...options, - ...errorInfo - }; - - // Remove error from log data to avoid duplication - if (logData.error) { - delete logData.error; - } - - // Log through the main logger - logger.log(level, message, logData); - - // Also console log for immediate visibility during development - if (level === 'error' || level === 'warn') { - console[level](`[SMTP] ${message}`, logData); - } - } - - /** - * Log debug level message - * @param message - Log message - * @param options - Additional log options - */ - public static debug(message: string, options: ISmtpLogOptions = {}): void { - this.log('debug', message, options); - } - - /** - * Log info level message - * @param message - Log message - * @param options - Additional log options - */ - public static info(message: string, options: ISmtpLogOptions = {}): void { - this.log('info', message, options); - } - - /** - * Log warning level message - * @param message - Log message - * @param options - Additional log options - */ - public static warn(message: string, options: ISmtpLogOptions = {}): void { - this.log('warn', message, options); - } - - /** - * Log error level message - * @param message - Log message - * @param options - Additional log options - */ - public static error(message: string, options: ISmtpLogOptions = {}): void { - this.log('error', message, options); - } - - /** - * Log command received from client - * @param command - The command string - * @param socket - The client socket - * @param session - The SMTP session - */ - public static logCommand(command: string, socket: plugins.net.Socket | plugins.tls.TLSSocket, session?: ISmtpSession): void { - const clientInfo = { - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - secure: socket instanceof plugins.tls.TLSSocket, - sessionId: session?.id, - sessionState: session?.state - }; - - this.info(`Command received: ${command}`, { - ...clientInfo, - command: command.split(' ')[0]?.toUpperCase() - }); - - // Also log to console for easy debugging - console.log(`← ${command}`); - } - - /** - * Log response sent to client - * @param response - The response string - * @param socket - The client socket - */ - public static logResponse(response: string, socket: plugins.net.Socket | plugins.tls.TLSSocket): void { - const clientInfo = { - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - secure: socket instanceof plugins.tls.TLSSocket - }; - - // Get the response code from the beginning of the response - const responseCode = response.substring(0, 3); - - // Log different levels based on response code - if (responseCode.startsWith('2') || responseCode.startsWith('3')) { - this.debug(`Response sent: ${response}`, clientInfo); - } else if (responseCode.startsWith('4')) { - this.warn(`Temporary error response: ${response}`, clientInfo); - } else if (responseCode.startsWith('5')) { - this.error(`Permanent error response: ${response}`, clientInfo); - } - - // Also log to console for easy debugging - console.log(`→ ${response}`); - } - - /** - * Log client connection event - * @param socket - The client socket - * @param eventType - Type of connection event (connect, close, error) - * @param session - The SMTP session - * @param error - Optional error object for error events - */ - public static logConnection( - socket: plugins.net.Socket | plugins.tls.TLSSocket, - eventType: 'connect' | 'close' | 'error', - session?: ISmtpSession, - error?: Error - ): void { - const clientInfo = { - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - secure: socket instanceof plugins.tls.TLSSocket, - sessionId: session?.id, - sessionState: session?.state - }; - - switch (eventType) { - case 'connect': - this.info(`New ${clientInfo.secure ? 'secure ' : ''}connection from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, clientInfo); - break; - - case 'close': - this.info(`Connection closed from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, clientInfo); - break; - - case 'error': - this.error(`Connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, { - ...clientInfo, - error - }); - break; - } - } - - /** - * Log security event - * @param level - Security log level - * @param type - Security event type - * @param message - Log message - * @param details - Event details - * @param ipAddress - Client IP address - * @param domain - Optional domain involved - * @param success - Whether the security check was successful - */ - public static logSecurityEvent( - level: SecurityLogLevel, - type: SecurityEventType, - message: string, - details: Record, - ipAddress?: string, - domain?: string, - success?: boolean - ): void { - // Map security log level to system log level - const logLevel: LogLevel = level === SecurityLogLevel.DEBUG ? 'debug' : - level === SecurityLogLevel.INFO ? 'info' : - level === SecurityLogLevel.WARN ? 'warn' : 'error'; - - // Log the security event - this.log(logLevel, message, { - component: 'smtp-security', - eventType: type, - success, - ipAddress, - domain, - ...details - }); - } -} - -/** - * Default instance for backward compatibility - */ -export const smtpLogger = SmtpLogger; \ No newline at end of file diff --git a/ts/mail/delivery/smtpserver/utils/validation.ts b/ts/mail/delivery/smtpserver/utils/validation.ts deleted file mode 100644 index 5aae12b..0000000 --- a/ts/mail/delivery/smtpserver/utils/validation.ts +++ /dev/null @@ -1,436 +0,0 @@ -/** - * SMTP Validation Utilities - * Provides validation functions for SMTP server - */ - -import { SmtpState } from '../interfaces.js'; -import { SMTP_PATTERNS } from '../constants.js'; - -/** - * Header injection patterns to detect malicious input - * These patterns detect common header injection attempts - */ -const HEADER_INJECTION_PATTERNS = [ - /\r\n/, // CRLF sequence - /\n/, // LF alone - /\r/, // CR alone - /\x00/, // Null byte - /\x0A/, // Line feed hex - /\x0D/, // Carriage return hex - /%0A/i, // URL encoded LF - /%0D/i, // URL encoded CR - /%0a/i, // URL encoded LF lowercase - /%0d/i, // URL encoded CR lowercase - /\\\n/, // Escaped newline - /\\\r/, // Escaped carriage return - /(?:subject|from|to|cc|bcc|reply-to|return-path|received|delivered-to|x-.*?):/i // Email headers -]; - -/** - * Detects header injection attempts in input strings - * @param input - The input string to check - * @param context - The context where this input is being used ('smtp-command' or 'email-header') - * @returns true if header injection is detected, false otherwise - */ -export function detectHeaderInjection(input: string, context: 'smtp-command' | 'email-header' = 'smtp-command'): boolean { - if (!input || typeof input !== 'string') { - return false; - } - - // Check for control characters and CRLF sequences (always dangerous) - const controlCharPatterns = [ - /\r\n/, // CRLF sequence - /\n/, // LF alone - /\r/, // CR alone - /\x00/, // Null byte - /\x0A/, // Line feed hex - /\x0D/, // Carriage return hex - /%0A/i, // URL encoded LF - /%0D/i, // URL encoded CR - /%0a/i, // URL encoded LF lowercase - /%0d/i, // URL encoded CR lowercase - /\\\n/, // Escaped newline - /\\\r/, // Escaped carriage return - ]; - - // Check control characters (always dangerous in any context) - if (controlCharPatterns.some(pattern => pattern.test(input))) { - return true; - } - - // For email headers, also check for header injection patterns - if (context === 'email-header') { - const headerPatterns = [ - /(?:subject|from|to|cc|bcc|reply-to|return-path|received|delivered-to|x-.*?):/i // Email headers - ]; - return headerPatterns.some(pattern => pattern.test(input)); - } - - // For SMTP commands, don't flag normal command syntax like "TO:" as header injection - return false; -} - -/** - * Sanitizes input by removing or escaping potentially dangerous characters - * @param input - The input string to sanitize - * @returns Sanitized string - */ -export function sanitizeInput(input: string): string { - if (!input || typeof input !== 'string') { - return ''; - } - - // Remove control characters and potential injection sequences - return input - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control chars except \t, \n, \r - .replace(/\r\n/g, ' ') // Replace CRLF with space - .replace(/[\r\n]/g, ' ') // Replace individual CR/LF with space - .replace(/%0[aAdD]/gi, '') // Remove URL encoded CRLF - .trim(); -} -import { SmtpLogger } from './logging.js'; - -/** - * Validates an email address - * @param email - Email address to validate - * @returns Whether the email address is valid - */ -export function isValidEmail(email: string): boolean { - if (!email || typeof email !== 'string') { - return false; - } - - // Basic pattern check - if (!SMTP_PATTERNS.EMAIL.test(email)) { - return false; - } - - // Additional validation for common invalid patterns - const [localPart, domain] = email.split('@'); - - // Check for double dots - if (email.includes('..')) { - return false; - } - - // Check domain doesn't start or end with dot - if (domain && (domain.startsWith('.') || domain.endsWith('.'))) { - return false; - } - - // Check local part length (max 64 chars per RFC) - if (localPart && localPart.length > 64) { - return false; - } - - // Check domain length (max 253 chars per RFC - accounting for trailing dot) - if (domain && domain.length > 253) { - return false; - } - - return true; -} - -/** - * Validates the MAIL FROM command syntax - * @param args - Arguments string from the MAIL FROM command - * @returns Object with validation result and extracted data - */ -export function validateMailFrom(args: string): { - isValid: boolean; - address?: string; - params?: Record; - errorMessage?: string; -} { - if (!args) { - return { isValid: false, errorMessage: 'Missing arguments' }; - } - - // Check for header injection attempts - if (detectHeaderInjection(args)) { - SmtpLogger.warn('Header injection attempt detected in MAIL FROM command', { args }); - return { isValid: false, errorMessage: 'Invalid syntax - illegal characters detected' }; - } - - // Handle "MAIL FROM:" already in the args - let cleanArgs = args; - if (args.toUpperCase().startsWith('MAIL FROM')) { - const colonIndex = args.indexOf(':'); - if (colonIndex !== -1) { - cleanArgs = args.substring(colonIndex + 1).trim(); - } - } else if (args.toUpperCase().startsWith('FROM:')) { - const colonIndex = args.indexOf(':'); - if (colonIndex !== -1) { - cleanArgs = args.substring(colonIndex + 1).trim(); - } - } - - // Handle empty sender case '<>' - if (cleanArgs === '<>') { - return { isValid: true, address: '', params: {} }; - } - - // According to test expectations, validate that the address is enclosed in angle brackets - // Check for angle brackets and RFC-compliance - if (cleanArgs.includes('<') && cleanArgs.includes('>')) { - const startBracket = cleanArgs.indexOf('<'); - const endBracket = cleanArgs.indexOf('>', startBracket); - - if (startBracket !== -1 && endBracket !== -1 && startBracket < endBracket) { - const emailPart = cleanArgs.substring(startBracket + 1, endBracket).trim(); - const paramsString = cleanArgs.substring(endBracket + 1).trim(); - - // Handle empty sender case '<>' again - if (emailPart === '') { - return { isValid: true, address: '', params: {} }; - } - - // During testing, we should validate the email format - // Check for basic email format (something@somewhere) - if (!isValidEmail(emailPart)) { - return { isValid: false, errorMessage: 'Invalid email address format' }; - } - - // Parse parameters if they exist - const params: Record = {}; - if (paramsString) { - const paramRegex = /\s+([A-Za-z0-9][A-Za-z0-9\-]*)(?:=([^\s]+))?/g; - let match; - - while ((match = paramRegex.exec(paramsString)) !== null) { - const name = match[1].toUpperCase(); - const value = match[2] || ''; - params[name] = value; - } - } - - return { isValid: true, address: emailPart, params }; - } - } - - // If no angle brackets, the format is invalid for MAIL FROM - // Tests expect us to reject formats without angle brackets - - // For better compliance with tests, check if the argument might contain an email without brackets - if (isValidEmail(cleanArgs)) { - return { isValid: false, errorMessage: 'Invalid syntax - angle brackets required' }; - } - - return { isValid: false, errorMessage: 'Invalid syntax - angle brackets required' }; -} - -/** - * Validates the RCPT TO command syntax - * @param args - Arguments string from the RCPT TO command - * @returns Object with validation result and extracted data - */ -export function validateRcptTo(args: string): { - isValid: boolean; - address?: string; - params?: Record; - errorMessage?: string; -} { - if (!args) { - return { isValid: false, errorMessage: 'Missing arguments' }; - } - - // Check for header injection attempts - if (detectHeaderInjection(args)) { - SmtpLogger.warn('Header injection attempt detected in RCPT TO command', { args }); - return { isValid: false, errorMessage: 'Invalid syntax - illegal characters detected' }; - } - - // Handle "RCPT TO:" already in the args - let cleanArgs = args; - if (args.toUpperCase().startsWith('RCPT TO')) { - const colonIndex = args.indexOf(':'); - if (colonIndex !== -1) { - cleanArgs = args.substring(colonIndex + 1).trim(); - } - } else if (args.toUpperCase().startsWith('TO:')) { - cleanArgs = args.substring(3).trim(); - } - - // According to test expectations, validate that the address is enclosed in angle brackets - // Check for angle brackets and RFC-compliance - if (cleanArgs.includes('<') && cleanArgs.includes('>')) { - const startBracket = cleanArgs.indexOf('<'); - const endBracket = cleanArgs.indexOf('>', startBracket); - - if (startBracket !== -1 && endBracket !== -1 && startBracket < endBracket) { - const emailPart = cleanArgs.substring(startBracket + 1, endBracket).trim(); - const paramsString = cleanArgs.substring(endBracket + 1).trim(); - - // During testing, we should validate the email format - // Check for basic email format (something@somewhere) - if (!isValidEmail(emailPart)) { - return { isValid: false, errorMessage: 'Invalid email address format' }; - } - - // Parse parameters if they exist - const params: Record = {}; - if (paramsString) { - const paramRegex = /\s+([A-Za-z0-9][A-Za-z0-9\-]*)(?:=([^\s]+))?/g; - let match; - - while ((match = paramRegex.exec(paramsString)) !== null) { - const name = match[1].toUpperCase(); - const value = match[2] || ''; - params[name] = value; - } - } - - return { isValid: true, address: emailPart, params }; - } - } - - // If no angle brackets, the format is invalid for RCPT TO - // Tests expect us to reject formats without angle brackets - - // For better compliance with tests, check if the argument might contain an email without brackets - if (isValidEmail(cleanArgs)) { - return { isValid: false, errorMessage: 'Invalid syntax - angle brackets required' }; - } - - return { isValid: false, errorMessage: 'Invalid syntax - angle brackets required' }; -} - -/** - * Validates the EHLO command syntax - * @param args - Arguments string from the EHLO command - * @returns Object with validation result and extracted data - */ -export function validateEhlo(args: string): { - isValid: boolean; - hostname?: string; - errorMessage?: string; -} { - if (!args) { - return { isValid: false, errorMessage: 'Missing domain name' }; - } - - // Check for header injection attempts - if (detectHeaderInjection(args)) { - SmtpLogger.warn('Header injection attempt detected in EHLO command', { args }); - return { isValid: false, errorMessage: 'Invalid domain name format' }; - } - - // Extract hostname from EHLO command if present in args - let hostname = args; - const match = args.match(/^(?:EHLO|HELO)\s+([^\s]+)$/i); - if (match) { - hostname = match[1]; - } - - // Check for empty hostname - if (!hostname || hostname.trim() === '') { - return { isValid: false, errorMessage: 'Missing domain name' }; - } - - // Basic validation - Be very permissive with domain names to handle various client implementations - // RFC 5321 allows a broad range of clients to connect, so validation should be lenient - - // Only check for characters that would definitely cause issues - const invalidChars = ['<', '>', '"', '\'', '\\', '\n', '\r']; - if (invalidChars.some(char => hostname.includes(char))) { - // During automated testing, we check for invalid character validation - // For production we could consider accepting these with proper cleanup - return { isValid: false, errorMessage: 'Invalid domain name format' }; - } - - // Support IP addresses in square brackets (e.g., [127.0.0.1] or [IPv6:2001:db8::1]) - if (hostname.startsWith('[') && hostname.endsWith(']')) { - // Be permissive with IP literals - many clients use non-standard formats - // Just check for closing bracket and basic format - return { isValid: true, hostname }; - } - - // RFC 5321 states we should accept anything as a domain name for EHLO - // Clients may send domain literals, IP addresses, or any other identification - // As long as it follows the basic format and doesn't have clearly invalid characters - // we should accept it to be compatible with a wide range of clients - - // The test expects us to reject 'invalid@domain', but RFC doesn't strictly require this - // For testing purposes, we'll include a basic check to validate email-like formats - if (hostname.includes('@')) { - // Reject email-like formats for EHLO/HELO command - return { isValid: false, errorMessage: 'Invalid domain name format' }; - } - - // Special handling for test with special characters - // The test "EHLO spec!al@#$chars" is expected to pass with either response: - // 1. Accept it (since RFC doesn't prohibit special chars in domain names) - // 2. Reject it with a 501 error (for implementations with stricter validation) - if (/[!@#$%^&*()+=\[\]{}|;:',<>?~`]/.test(hostname)) { - // For test compatibility, let's be permissive and accept special characters - // RFC 5321 doesn't explicitly prohibit these characters, and some implementations accept them - SmtpLogger.debug(`Allowing hostname with special characters for test: ${hostname}`); - return { isValid: true, hostname }; - } - - // Hostname validation can be very tricky - many clients don't follow RFCs exactly - // Better to be permissive than to reject valid clients - return { isValid: true, hostname }; -} - -/** - * Validates command in the current SMTP state - * @param command - SMTP command - * @param currentState - Current SMTP state - * @returns Whether the command is valid in the current state - */ -export function isValidCommandSequence(command: string, currentState: SmtpState): boolean { - const upperCommand = command.toUpperCase(); - - // Some commands are valid in any state - if (upperCommand === 'QUIT' || upperCommand === 'RSET' || upperCommand === 'NOOP' || upperCommand === 'HELP') { - return true; - } - - // State-specific validation - switch (currentState) { - case SmtpState.GREETING: - return upperCommand === 'EHLO' || upperCommand === 'HELO'; - - case SmtpState.AFTER_EHLO: - return upperCommand === 'MAIL' || upperCommand === 'STARTTLS' || upperCommand === 'AUTH' || upperCommand === 'EHLO' || upperCommand === 'HELO'; - - case SmtpState.MAIL_FROM: - case SmtpState.RCPT_TO: - if (upperCommand === 'RCPT') { - return true; - } - return currentState === SmtpState.RCPT_TO && upperCommand === 'DATA'; - - case SmtpState.DATA: - // In DATA state, only the data content is accepted, not commands - return false; - - case SmtpState.DATA_RECEIVING: - // In DATA_RECEIVING state, only the data content is accepted, not commands - return false; - - case SmtpState.FINISHED: - // After data is received, only new transactions or session end - return upperCommand === 'MAIL' || upperCommand === 'QUIT' || upperCommand === 'RSET'; - - default: - return false; - } -} - -/** - * Validates if a hostname is valid according to RFC 5321 - * @param hostname - Hostname to validate - * @returns Whether the hostname is valid - */ -export function isValidHostname(hostname: string): boolean { - if (!hostname || typeof hostname !== 'string') { - return false; - } - - // Basic hostname validation - // This is a simplified check, full RFC compliance would be more complex - return /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/.test(hostname); -} \ No newline at end of file diff --git a/ts/mail/index.ts b/ts/mail/index.ts deleted file mode 100644 index 7da38c9..0000000 --- a/ts/mail/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Export all mail modules for simplified imports -export * from './routing/index.js'; -export * from './security/index.js'; - -// Make the core and delivery modules accessible -import * as Core from './core/index.js'; -import * as Delivery from './delivery/index.js'; - -export { Core, Delivery }; - -// For direct imports -import { Email } from './core/classes.email.js'; -import { DcRouter } from '../classes.dcrouter.js'; - -// Re-export commonly used classes -export { - Email, - DcRouter -}; \ No newline at end of file diff --git a/ts/mail/routing/classes.dns.manager.ts b/ts/mail/routing/classes.dns.manager.ts deleted file mode 100644 index 4512866..0000000 --- a/ts/mail/routing/classes.dns.manager.ts +++ /dev/null @@ -1,563 +0,0 @@ -import * as plugins from '../../plugins.js'; -import type { IEmailDomainConfig } from './interfaces.js'; -import { logger } from '../../logger.js'; -import type { DcRouter } from '../../classes.dcrouter.js'; -import type { StorageManager } from '../../storage/index.js'; - -/** - * DNS validation result - */ -export interface IDnsValidationResult { - valid: boolean; - errors: string[]; - warnings: string[]; - requiredChanges: string[]; -} - -/** - * DNS records found for a domain - */ -interface IDnsRecords { - mx?: string[]; - spf?: string; - dkim?: string; - dmarc?: string; - ns?: string[]; -} - -/** - * Manages DNS configuration for email domains - * Handles both validation and creation of DNS records - */ -export class DnsManager { - private dcRouter: DcRouter; - private storageManager: StorageManager; - - constructor(dcRouter: DcRouter) { - this.dcRouter = dcRouter; - this.storageManager = dcRouter.storageManager; - } - - /** - * Validate all domain configurations - */ - async validateAllDomains(domainConfigs: IEmailDomainConfig[]): Promise> { - const results = new Map(); - - for (const config of domainConfigs) { - const result = await this.validateDomain(config); - results.set(config.domain, result); - } - - return results; - } - - /** - * Validate a single domain configuration - */ - async validateDomain(config: IEmailDomainConfig): Promise { - switch (config.dnsMode) { - case 'forward': - return this.validateForwardMode(config); - case 'internal-dns': - return this.validateInternalDnsMode(config); - case 'external-dns': - return this.validateExternalDnsMode(config); - default: - return { - valid: false, - errors: [`Unknown DNS mode: ${config.dnsMode}`], - warnings: [], - requiredChanges: [] - }; - } - } - - /** - * Validate forward mode configuration - */ - private async validateForwardMode(config: IEmailDomainConfig): Promise { - const result: IDnsValidationResult = { - valid: true, - errors: [], - warnings: [], - requiredChanges: [] - }; - - // Forward mode doesn't require DNS validation by default - if (!config.dns?.forward?.skipDnsValidation) { - logger.log('info', `DNS validation skipped for forward mode domain: ${config.domain}`); - } - - // DKIM keys are still generated for consistency - result.warnings.push( - `Domain "${config.domain}" uses forward mode. DKIM keys will be generated but signing only happens if email is processed.` - ); - - return result; - } - - /** - * Validate internal DNS mode configuration - */ - private async validateInternalDnsMode(config: IEmailDomainConfig): Promise { - const result: IDnsValidationResult = { - valid: true, - errors: [], - warnings: [], - requiredChanges: [] - }; - - // Check if DNS configuration is set up - const dnsNsDomains = this.dcRouter.options?.dnsNsDomains; - const dnsScopes = this.dcRouter.options?.dnsScopes; - - if (!dnsNsDomains || dnsNsDomains.length === 0) { - result.valid = false; - result.errors.push( - `Domain "${config.domain}" is configured to use internal DNS, but dnsNsDomains is not set in DcRouter configuration.` - ); - console.error( - `❌ ERROR: Domain "${config.domain}" is configured to use internal DNS,\n` + - ' but dnsNsDomains is not set in DcRouter configuration.\n' + - ' Please configure dnsNsDomains to enable the DNS server.\n' + - ' Example: dnsNsDomains: ["ns1.myservice.com", "ns2.myservice.com"]' - ); - return result; - } - - if (!dnsScopes || dnsScopes.length === 0) { - result.valid = false; - result.errors.push( - `Domain "${config.domain}" is configured to use internal DNS, but dnsScopes is not set in DcRouter configuration.` - ); - console.error( - `❌ ERROR: Domain "${config.domain}" is configured to use internal DNS,\n` + - ' but dnsScopes is not set in DcRouter configuration.\n' + - ' Please configure dnsScopes to define authoritative domains.\n' + - ' Example: dnsScopes: ["myservice.com", "mail.myservice.com"]' - ); - return result; - } - - // Check if the email domain is in dnsScopes - if (!dnsScopes.includes(config.domain)) { - result.valid = false; - result.errors.push( - `Domain "${config.domain}" is configured to use internal DNS, but is not included in dnsScopes.` - ); - console.error( - `❌ ERROR: Domain "${config.domain}" is configured to use internal DNS,\n` + - ` but is not included in dnsScopes: [${dnsScopes.join(', ')}].\n` + - ' Please add this domain to dnsScopes to enable internal DNS.\n' + - ` Example: dnsScopes: [..., "${config.domain}"]` - ); - return result; - } - - const primaryNameserver = dnsNsDomains[0]; - - // Check NS delegation - try { - const nsRecords = await this.resolveNs(config.domain); - const delegatedNameservers = dnsNsDomains.filter(ns => nsRecords.includes(ns)); - const isDelegated = delegatedNameservers.length > 0; - - if (!isDelegated) { - result.warnings.push( - `NS delegation not found for ${config.domain}. Please add NS records at your registrar.` - ); - dnsNsDomains.forEach(ns => { - result.requiredChanges.push( - `Add NS record: ${config.domain}. NS ${ns}.` - ); - }); - - console.log( - `📋 DNS Delegation Required for ${config.domain}:\n` + - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' + - 'Please add these NS records at your domain registrar:\n' + - dnsNsDomains.map(ns => ` ${config.domain}. NS ${ns}.`).join('\n') + '\n' + - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' + - 'This delegation is required for internal DNS mode to work.' - ); - } else { - console.log( - `✅ NS delegation verified: ${config.domain} -> [${delegatedNameservers.join(', ')}]` - ); - } - } catch (error) { - result.warnings.push( - `Could not verify NS delegation for ${config.domain}: ${error.message}` - ); - } - - return result; - } - - /** - * Validate external DNS mode configuration - */ - private async validateExternalDnsMode(config: IEmailDomainConfig): Promise { - const result: IDnsValidationResult = { - valid: true, - errors: [], - warnings: [], - requiredChanges: [] - }; - - try { - // Get current DNS records - const records = await this.checkDnsRecords(config); - const requiredRecords = config.dns?.external?.requiredRecords || ['MX', 'SPF', 'DKIM', 'DMARC']; - - // Check MX record - if (requiredRecords.includes('MX') && !records.mx?.length) { - result.requiredChanges.push( - `Add MX record: ${this.getBaseDomain(config.domain)} -> ${config.domain} (priority 10)` - ); - } - - // Check SPF record - if (requiredRecords.includes('SPF') && !records.spf) { - result.requiredChanges.push( - `Add TXT record: ${this.getBaseDomain(config.domain)} -> "v=spf1 a mx ~all"` - ); - } - - // Check DKIM record - if (requiredRecords.includes('DKIM') && !records.dkim) { - const selector = config.dkim?.selector || 'default'; - const dkimPublicKey = await this.storageManager.get(`/email/dkim/${config.domain}/public.key`); - - if (dkimPublicKey) { - const publicKeyBase64 = dkimPublicKey - .replace(/-----BEGIN PUBLIC KEY-----/g, '') - .replace(/-----END PUBLIC KEY-----/g, '') - .replace(/\s/g, ''); - - result.requiredChanges.push( - `Add TXT record: ${selector}._domainkey.${config.domain} -> "v=DKIM1; k=rsa; p=${publicKeyBase64}"` - ); - } else { - result.warnings.push( - `DKIM public key not found for ${config.domain}. It will be generated on first use.` - ); - } - } - - // Check DMARC record - if (requiredRecords.includes('DMARC') && !records.dmarc) { - result.requiredChanges.push( - `Add TXT record: _dmarc.${this.getBaseDomain(config.domain)} -> "v=DMARC1; p=none; rua=mailto:dmarc@${config.domain}"` - ); - } - - // Show setup instructions if needed - if (result.requiredChanges.length > 0) { - console.log( - `📋 DNS Configuration Required for ${config.domain}:\n` + - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' + - result.requiredChanges.map((change, i) => `${i + 1}. ${change}`).join('\n') + - '\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' - ); - } - - } catch (error) { - result.errors.push(`DNS validation failed: ${error.message}`); - result.valid = false; - } - - return result; - } - - /** - * Check DNS records for a domain - */ - private async checkDnsRecords(config: IEmailDomainConfig): Promise { - const records: IDnsRecords = {}; - const baseDomain = this.getBaseDomain(config.domain); - const selector = config.dkim?.selector || 'default'; - - // Use custom DNS servers if specified - const resolver = new plugins.dns.promises.Resolver(); - if (config.dns?.external?.servers?.length) { - resolver.setServers(config.dns.external.servers); - } - - // Check MX records - try { - const mxRecords = await resolver.resolveMx(baseDomain); - records.mx = mxRecords.map(mx => mx.exchange); - } catch (error) { - logger.log('debug', `No MX records found for ${baseDomain}`); - } - - // Check SPF record - try { - const txtRecords = await resolver.resolveTxt(baseDomain); - const spfRecord = txtRecords.find(records => - records.some(record => record.startsWith('v=spf1')) - ); - if (spfRecord) { - records.spf = spfRecord.join(''); - } - } catch (error) { - logger.log('debug', `No SPF record found for ${baseDomain}`); - } - - // Check DKIM record - try { - const dkimRecords = await resolver.resolveTxt(`${selector}._domainkey.${config.domain}`); - const dkimRecord = dkimRecords.find(records => - records.some(record => record.includes('v=DKIM1')) - ); - if (dkimRecord) { - records.dkim = dkimRecord.join(''); - } - } catch (error) { - logger.log('debug', `No DKIM record found for ${selector}._domainkey.${config.domain}`); - } - - // Check DMARC record - try { - const dmarcRecords = await resolver.resolveTxt(`_dmarc.${baseDomain}`); - const dmarcRecord = dmarcRecords.find(records => - records.some(record => record.startsWith('v=DMARC1')) - ); - if (dmarcRecord) { - records.dmarc = dmarcRecord.join(''); - } - } catch (error) { - logger.log('debug', `No DMARC record found for _dmarc.${baseDomain}`); - } - - return records; - } - - /** - * Resolve NS records for a domain - */ - private async resolveNs(domain: string): Promise { - try { - const resolver = new plugins.dns.promises.Resolver(); - const nsRecords = await resolver.resolveNs(domain); - return nsRecords; - } catch (error) { - logger.log('warn', `Failed to resolve NS records for ${domain}: ${error.message}`); - return []; - } - } - - /** - * Get base domain from email domain (e.g., mail.example.com -> example.com) - */ - private getBaseDomain(domain: string): string { - const parts = domain.split('.'); - if (parts.length <= 2) { - return domain; - } - - // For subdomains like mail.example.com, return example.com - // But preserve domain structure for longer TLDs like .co.uk - if (parts[parts.length - 2].length <= 3 && parts[parts.length - 1].length === 2) { - // Likely a country code TLD like .co.uk - return parts.slice(-3).join('.'); - } - - return parts.slice(-2).join('.'); - } - - /** - * Ensure all DNS records are created for configured domains - * This is the main entry point for DNS record management - */ - async ensureDnsRecords(domainConfigs: IEmailDomainConfig[], dkimCreator?: any): Promise { - logger.log('info', `Ensuring DNS records for ${domainConfigs.length} domains`); - - // First, validate all domains - const validationResults = await this.validateAllDomains(domainConfigs); - - // Then create records for internal-dns domains - const internalDnsDomains = domainConfigs.filter(config => config.dnsMode === 'internal-dns'); - if (internalDnsDomains.length > 0) { - await this.createInternalDnsRecords(internalDnsDomains); - - // Create DKIM records if DKIMCreator is provided - if (dkimCreator) { - await this.createDkimRecords(domainConfigs, dkimCreator); - } - } - - // Log validation results for external-dns domains - for (const [domain, result] of validationResults) { - const config = domainConfigs.find(c => c.domain === domain); - if (config?.dnsMode === 'external-dns' && result.requiredChanges.length > 0) { - logger.log('warn', `External DNS configuration required for ${domain}`); - } - } - } - - /** - * Create DNS records for internal-dns mode domains - */ - private async createInternalDnsRecords(domainConfigs: IEmailDomainConfig[]): Promise { - // Check if DNS server is available - if (!this.dcRouter.dnsServer) { - logger.log('warn', 'DNS server not available, skipping internal DNS record creation'); - return; - } - - logger.log('info', `Creating DNS records for ${domainConfigs.length} internal-dns domains`); - - for (const domainConfig of domainConfigs) { - const domain = domainConfig.domain; - const ttl = domainConfig.dns?.internal?.ttl || 3600; - const mxPriority = domainConfig.dns?.internal?.mxPriority || 10; - - try { - // 1. Register MX record - points to the email domain itself - this.dcRouter.dnsServer.registerHandler( - domain, - ['MX'], - () => ({ - name: domain, - type: 'MX', - class: 'IN', - ttl: ttl, - data: { - priority: mxPriority, - exchange: domain - } - }) - ); - logger.log('info', `MX record registered for ${domain} -> ${domain} (priority ${mxPriority})`); - - // Store MX record in StorageManager - await this.storageManager.set( - `/email/dns/${domain}/mx`, - JSON.stringify({ - type: 'MX', - priority: mxPriority, - exchange: domain, - ttl: ttl - }) - ); - - // 2. Register SPF record - allows the domain to send emails - const spfRecord = `v=spf1 a mx ~all`; - this.dcRouter.dnsServer.registerHandler( - domain, - ['TXT'], - () => ({ - name: domain, - type: 'TXT', - class: 'IN', - ttl: ttl, - data: spfRecord - }) - ); - logger.log('info', `SPF record registered for ${domain}: "${spfRecord}"`); - - // Store SPF record in StorageManager - await this.storageManager.set( - `/email/dns/${domain}/spf`, - JSON.stringify({ - type: 'TXT', - data: spfRecord, - ttl: ttl - }) - ); - - // 3. Register DMARC record - policy for handling email authentication - const dmarcRecord = `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`; - this.dcRouter.dnsServer.registerHandler( - `_dmarc.${domain}`, - ['TXT'], - () => ({ - name: `_dmarc.${domain}`, - type: 'TXT', - class: 'IN', - ttl: ttl, - data: dmarcRecord - }) - ); - logger.log('info', `DMARC record registered for _dmarc.${domain}: "${dmarcRecord}"`); - - // Store DMARC record in StorageManager - await this.storageManager.set( - `/email/dns/${domain}/dmarc`, - JSON.stringify({ - type: 'TXT', - name: `_dmarc.${domain}`, - data: dmarcRecord, - ttl: ttl - }) - ); - - // Log summary of DNS records created - logger.log('info', `✅ DNS records created for ${domain}: - - MX: ${domain} (priority ${mxPriority}) - - SPF: ${spfRecord} - - DMARC: ${dmarcRecord} - - DKIM: Will be created when keys are generated`); - - } catch (error) { - logger.log('error', `Failed to create DNS records for ${domain}: ${error.message}`); - } - } - } - - /** - * Create DKIM DNS records for all domains - */ - private async createDkimRecords(domainConfigs: IEmailDomainConfig[], dkimCreator: any): Promise { - for (const domainConfig of domainConfigs) { - const domain = domainConfig.domain; - const selector = domainConfig.dkim?.selector || 'default'; - - try { - // Get DKIM DNS record from DKIMCreator - const dnsRecord = await dkimCreator.getDNSRecordForDomain(domain); - - // For internal-dns domains, register the DNS handler - if (domainConfig.dnsMode === 'internal-dns' && this.dcRouter.dnsServer) { - const ttl = domainConfig.dns?.internal?.ttl || 3600; - - this.dcRouter.dnsServer.registerHandler( - `${selector}._domainkey.${domain}`, - ['TXT'], - () => ({ - name: `${selector}._domainkey.${domain}`, - type: 'TXT', - class: 'IN', - ttl: ttl, - data: dnsRecord.value - }) - ); - - logger.log('info', `DKIM DNS record registered for ${selector}._domainkey.${domain}`); - - // Store DKIM record in StorageManager - await this.storageManager.set( - `/email/dns/${domain}/dkim`, - JSON.stringify({ - type: 'TXT', - name: `${selector}._domainkey.${domain}`, - data: dnsRecord.value, - ttl: ttl - }) - ); - } - - // For external-dns domains, just log what should be configured - if (domainConfig.dnsMode === 'external-dns') { - logger.log('info', `DKIM record for external DNS: ${dnsRecord.name} -> "${dnsRecord.value}"`); - } - - } catch (error) { - logger.log('warn', `Could not create DKIM DNS record for ${domain}: ${error.message}`); - } - } - } -} \ No newline at end of file diff --git a/ts/mail/routing/classes.dnsmanager.ts b/ts/mail/routing/classes.dnsmanager.ts deleted file mode 100644 index 062630d..0000000 --- a/ts/mail/routing/classes.dnsmanager.ts +++ /dev/null @@ -1,559 +0,0 @@ -import * as plugins from '../../plugins.js'; -import * as paths from '../../paths.js'; -import { DKIMCreator } from '../security/classes.dkimcreator.js'; - -/** - * Interface for DNS record information - */ -export interface IDnsRecord { - name: string; - type: string; - value: string; - ttl?: number; - dnsSecEnabled?: boolean; -} - -/** - * Interface for DNS lookup options - */ -export interface IDnsLookupOptions { - /** Cache time to live in milliseconds, 0 to disable caching */ - cacheTtl?: number; - /** Timeout for DNS queries in milliseconds */ - timeout?: number; -} - -/** - * Interface for DNS verification result - */ -export interface IDnsVerificationResult { - record: string; - found: boolean; - valid: boolean; - value?: string; - expectedValue?: string; - error?: string; -} - -/** - * Manager for DNS-related operations, including record lookups, verification, and generation - */ -export class DNSManager { - public dkimCreator: DKIMCreator; - private cache: Map = new Map(); - private defaultOptions: IDnsLookupOptions = { - cacheTtl: 300000, // 5 minutes - timeout: 5000 // 5 seconds - }; - - constructor(dkimCreatorArg: DKIMCreator, options?: IDnsLookupOptions) { - this.dkimCreator = dkimCreatorArg; - - if (options) { - this.defaultOptions = { - ...this.defaultOptions, - ...options - }; - } - - // Ensure the DNS records directory exists - plugins.fsUtils.ensureDirSync(paths.dnsRecordsDir); - } - - /** - * Lookup MX records for a domain - * @param domain Domain to look up - * @param options Lookup options - * @returns Array of MX records sorted by priority - */ - public async lookupMx(domain: string, options?: IDnsLookupOptions): Promise { - const lookupOptions = { ...this.defaultOptions, ...options }; - const cacheKey = `mx:${domain}`; - - // Check cache first - const cached = this.getFromCache(cacheKey); - if (cached) { - return cached; - } - - try { - const records = await this.dnsResolveMx(domain, lookupOptions.timeout); - - // Sort by priority - records.sort((a, b) => a.priority - b.priority); - - // Cache the result - this.setInCache(cacheKey, records, lookupOptions.cacheTtl); - - return records; - } catch (error) { - console.error(`Error looking up MX records for ${domain}:`, error); - throw new Error(`Failed to lookup MX records for ${domain}: ${error.message}`); - } - } - - /** - * Lookup TXT records for a domain - * @param domain Domain to look up - * @param options Lookup options - * @returns Array of TXT records - */ - public async lookupTxt(domain: string, options?: IDnsLookupOptions): Promise { - const lookupOptions = { ...this.defaultOptions, ...options }; - const cacheKey = `txt:${domain}`; - - // Check cache first - const cached = this.getFromCache(cacheKey); - if (cached) { - return cached; - } - - try { - const records = await this.dnsResolveTxt(domain, lookupOptions.timeout); - - // Cache the result - this.setInCache(cacheKey, records, lookupOptions.cacheTtl); - - return records; - } catch (error) { - console.error(`Error looking up TXT records for ${domain}:`, error); - throw new Error(`Failed to lookup TXT records for ${domain}: ${error.message}`); - } - } - - /** - * Find specific TXT record by subdomain and prefix - * @param domain Base domain - * @param subdomain Subdomain prefix (e.g., "dkim._domainkey") - * @param prefix Record prefix to match (e.g., "v=DKIM1") - * @param options Lookup options - * @returns Matching TXT record or null if not found - */ - public async findTxtRecord( - domain: string, - subdomain: string = '', - prefix: string = '', - options?: IDnsLookupOptions - ): Promise { - const fullDomain = subdomain ? `${subdomain}.${domain}` : domain; - - try { - const records = await this.lookupTxt(fullDomain, options); - - for (const recordArray of records) { - // TXT records can be split into chunks, join them - const record = recordArray.join(''); - - if (!prefix || record.startsWith(prefix)) { - return record; - } - } - - return null; - } catch (error) { - // Domain might not exist or no TXT records - console.log(`No matching TXT record found for ${fullDomain} with prefix ${prefix}`); - return null; - } - } - - /** - * Verify if a domain has a valid SPF record - * @param domain Domain to verify - * @returns Verification result - */ - public async verifySpfRecord(domain: string): Promise { - const result: IDnsVerificationResult = { - record: 'SPF', - found: false, - valid: false - }; - - try { - const spfRecord = await this.findTxtRecord(domain, '', 'v=spf1'); - - if (spfRecord) { - result.found = true; - result.value = spfRecord; - - // Basic validation - check if it contains all, include, ip4, ip6, or mx mechanisms - const isValid = /v=spf1\s+([-~?+]?(all|include:|ip4:|ip6:|mx|a|exists:))/.test(spfRecord); - result.valid = isValid; - - if (!isValid) { - result.error = 'SPF record format is invalid'; - } - } else { - result.error = 'No SPF record found'; - } - } catch (error) { - result.error = `Error verifying SPF: ${error.message}`; - } - - return result; - } - - /** - * Verify if a domain has a valid DKIM record - * @param domain Domain to verify - * @param selector DKIM selector (usually "mta" in our case) - * @returns Verification result - */ - public async verifyDkimRecord(domain: string, selector: string = 'mta'): Promise { - const result: IDnsVerificationResult = { - record: 'DKIM', - found: false, - valid: false - }; - - try { - const dkimSelector = `${selector}._domainkey`; - const dkimRecord = await this.findTxtRecord(domain, dkimSelector, 'v=DKIM1'); - - if (dkimRecord) { - result.found = true; - result.value = dkimRecord; - - // Basic validation - check for required fields - const hasP = dkimRecord.includes('p='); - result.valid = dkimRecord.includes('v=DKIM1') && hasP; - - if (!result.valid) { - result.error = 'DKIM record is missing required fields'; - } else if (dkimRecord.includes('p=') && !dkimRecord.match(/p=[a-zA-Z0-9+/]+/)) { - result.valid = false; - result.error = 'DKIM record has invalid public key format'; - } - } else { - result.error = `No DKIM record found for selector ${selector}`; - } - } catch (error) { - result.error = `Error verifying DKIM: ${error.message}`; - } - - return result; - } - - /** - * Verify if a domain has a valid DMARC record - * @param domain Domain to verify - * @returns Verification result - */ - public async verifyDmarcRecord(domain: string): Promise { - const result: IDnsVerificationResult = { - record: 'DMARC', - found: false, - valid: false - }; - - try { - const dmarcDomain = `_dmarc.${domain}`; - const dmarcRecord = await this.findTxtRecord(dmarcDomain, '', 'v=DMARC1'); - - if (dmarcRecord) { - result.found = true; - result.value = dmarcRecord; - - // Basic validation - check for required fields - const hasPolicy = dmarcRecord.includes('p='); - result.valid = dmarcRecord.includes('v=DMARC1') && hasPolicy; - - if (!result.valid) { - result.error = 'DMARC record is missing required fields'; - } - } else { - result.error = 'No DMARC record found'; - } - } catch (error) { - result.error = `Error verifying DMARC: ${error.message}`; - } - - return result; - } - - /** - * Check all email authentication records (SPF, DKIM, DMARC) for a domain - * @param domain Domain to check - * @param dkimSelector DKIM selector - * @returns Object with verification results for each record type - */ - public async verifyEmailAuthRecords(domain: string, dkimSelector: string = 'mta'): Promise<{ - spf: IDnsVerificationResult; - dkim: IDnsVerificationResult; - dmarc: IDnsVerificationResult; - }> { - const [spf, dkim, dmarc] = await Promise.all([ - this.verifySpfRecord(domain), - this.verifyDkimRecord(domain, dkimSelector), - this.verifyDmarcRecord(domain) - ]); - - return { spf, dkim, dmarc }; - } - - /** - * Generate a recommended SPF record for a domain - * @param domain Domain name - * @param options Configuration options for the SPF record - * @returns Generated SPF record - */ - public generateSpfRecord(domain: string, options: { - includeMx?: boolean; - includeA?: boolean; - includeIps?: string[]; - includeSpf?: string[]; - policy?: 'none' | 'neutral' | 'softfail' | 'fail' | 'reject'; - } = {}): IDnsRecord { - const { - includeMx = true, - includeA = true, - includeIps = [], - includeSpf = [], - policy = 'softfail' - } = options; - - let value = 'v=spf1'; - - if (includeMx) { - value += ' mx'; - } - - if (includeA) { - value += ' a'; - } - - // Add IP addresses - for (const ip of includeIps) { - if (ip.includes(':')) { - value += ` ip6:${ip}`; - } else { - value += ` ip4:${ip}`; - } - } - - // Add includes - for (const include of includeSpf) { - value += ` include:${include}`; - } - - // Add policy - const policyMap = { - 'none': '?all', - 'neutral': '~all', - 'softfail': '~all', - 'fail': '-all', - 'reject': '-all' - }; - - value += ` ${policyMap[policy]}`; - - return { - name: domain, - type: 'TXT', - value: value - }; - } - - /** - * Generate a recommended DMARC record for a domain - * @param domain Domain name - * @param options Configuration options for the DMARC record - * @returns Generated DMARC record - */ - public generateDmarcRecord(domain: string, options: { - policy?: 'none' | 'quarantine' | 'reject'; - subdomainPolicy?: 'none' | 'quarantine' | 'reject'; - pct?: number; - rua?: string; - ruf?: string; - daysInterval?: number; - } = {}): IDnsRecord { - const { - policy = 'none', - subdomainPolicy, - pct = 100, - rua, - ruf, - daysInterval = 1 - } = options; - - let value = 'v=DMARC1; p=' + policy; - - if (subdomainPolicy) { - value += `; sp=${subdomainPolicy}`; - } - - if (pct !== 100) { - value += `; pct=${pct}`; - } - - if (rua) { - value += `; rua=mailto:${rua}`; - } - - if (ruf) { - value += `; ruf=mailto:${ruf}`; - } - - if (daysInterval !== 1) { - value += `; ri=${daysInterval * 86400}`; - } - - // Add reporting format and ADKIM/ASPF alignment - value += '; fo=1; adkim=r; aspf=r'; - - return { - name: `_dmarc.${domain}`, - type: 'TXT', - value: value - }; - } - - /** - * Save DNS record recommendations to a file - * @param domain Domain name - * @param records DNS records to save - */ - public async saveDnsRecommendations(domain: string, records: IDnsRecord[]): Promise { - try { - const filePath = plugins.path.join(paths.dnsRecordsDir, `${domain}.recommendations.json`); - plugins.fsUtils.toFsSync(JSON.stringify(records, null, 2), filePath); - console.log(`DNS recommendations for ${domain} saved to ${filePath}`); - } catch (error) { - console.error(`Error saving DNS recommendations for ${domain}:`, error); - } - } - - /** - * Get cache key value - * @param key Cache key - * @returns Cached value or undefined if not found or expired - */ - private getFromCache(key: string): T | undefined { - const cached = this.cache.get(key); - - if (cached && cached.expires > Date.now()) { - return cached.data as T; - } - - // Remove expired entry - if (cached) { - this.cache.delete(key); - } - - return undefined; - } - - /** - * Set cache key value - * @param key Cache key - * @param data Data to cache - * @param ttl TTL in milliseconds - */ - private setInCache(key: string, data: T, ttl: number = this.defaultOptions.cacheTtl): void { - if (ttl <= 0) return; // Don't cache if TTL is disabled - - this.cache.set(key, { - data, - expires: Date.now() + ttl - }); - } - - /** - * Clear the DNS cache - * @param key Optional specific key to clear, or all cache if not provided - */ - public clearCache(key?: string): void { - if (key) { - this.cache.delete(key); - } else { - this.cache.clear(); - } - } - - /** - * Promise-based wrapper for dns.resolveMx - * @param domain Domain to resolve - * @param timeout Timeout in milliseconds - * @returns Promise resolving to MX records - */ - private dnsResolveMx(domain: string, timeout: number = 5000): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - reject(new Error(`DNS MX lookup timeout for ${domain}`)); - }, timeout); - - plugins.dns.resolveMx(domain, (err, addresses) => { - clearTimeout(timeoutId); - - if (err) { - reject(err); - } else { - resolve(addresses); - } - }); - }); - } - - /** - * Promise-based wrapper for dns.resolveTxt - * @param domain Domain to resolve - * @param timeout Timeout in milliseconds - * @returns Promise resolving to TXT records - */ - private dnsResolveTxt(domain: string, timeout: number = 5000): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - reject(new Error(`DNS TXT lookup timeout for ${domain}`)); - }, timeout); - - plugins.dns.resolveTxt(domain, (err, records) => { - clearTimeout(timeoutId); - - if (err) { - reject(err); - } else { - resolve(records); - } - }); - }); - } - - /** - * Generate all recommended DNS records for proper email authentication - * @param domain Domain to generate records for - * @returns Array of recommended DNS records - */ - public async generateAllRecommendedRecords(domain: string): Promise { - const records: IDnsRecord[] = []; - - // Get DKIM record (already created by DKIMCreator) - try { - // Call the DKIM creator directly - const dkimRecord = await this.dkimCreator.getDNSRecordForDomain(domain); - records.push(dkimRecord); - } catch (error) { - console.error(`Error getting DKIM record for ${domain}:`, error); - } - - // Generate SPF record - const spfRecord = this.generateSpfRecord(domain, { - includeMx: true, - includeA: true, - policy: 'softfail' - }); - records.push(spfRecord); - - // Generate DMARC record - const dmarcRecord = this.generateDmarcRecord(domain, { - policy: 'none', // Start with monitoring mode - rua: `dmarc@${domain}` // Replace with appropriate report address - }); - records.push(dmarcRecord); - - // Save recommendations - await this.saveDnsRecommendations(domain, records); - - return records; - } -} \ No newline at end of file diff --git a/ts/mail/routing/classes.domain.registry.ts b/ts/mail/routing/classes.domain.registry.ts deleted file mode 100644 index 1acc4d4..0000000 --- a/ts/mail/routing/classes.domain.registry.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { IEmailDomainConfig } from './interfaces.js'; -import { logger } from '../../logger.js'; - -/** - * Registry for email domain configurations - * Provides fast lookups and validation for domains - */ -export class DomainRegistry { - private domains: Map = new Map(); - private defaults: IEmailDomainConfig['dkim'] & { - dnsMode?: 'forward' | 'internal-dns' | 'external-dns'; - rateLimits?: IEmailDomainConfig['rateLimits']; - }; - - constructor( - domainConfigs: IEmailDomainConfig[], - defaults?: { - dnsMode?: 'forward' | 'internal-dns' | 'external-dns'; - dkim?: IEmailDomainConfig['dkim']; - rateLimits?: IEmailDomainConfig['rateLimits']; - } - ) { - // Set defaults - this.defaults = { - dnsMode: defaults?.dnsMode || 'external-dns', - ...this.getDefaultDkimConfig(), - ...defaults?.dkim, - rateLimits: defaults?.rateLimits - }; - - // Process and store domain configurations - for (const config of domainConfigs) { - const processedConfig = this.applyDefaults(config); - this.domains.set(config.domain.toLowerCase(), processedConfig); - logger.log('info', `Registered domain: ${config.domain} with DNS mode: ${processedConfig.dnsMode}`); - } - } - - /** - * Get default DKIM configuration - */ - private getDefaultDkimConfig(): IEmailDomainConfig['dkim'] { - return { - selector: 'default', - keySize: 2048, - rotateKeys: false, - rotationInterval: 90 - }; - } - - /** - * Apply defaults to a domain configuration - */ - private applyDefaults(config: IEmailDomainConfig): IEmailDomainConfig { - return { - ...config, - dnsMode: config.dnsMode || this.defaults.dnsMode!, - dkim: { - ...this.getDefaultDkimConfig(), - ...this.defaults, - ...config.dkim - }, - rateLimits: { - ...this.defaults.rateLimits, - ...config.rateLimits, - outbound: { - ...this.defaults.rateLimits?.outbound, - ...config.rateLimits?.outbound - }, - inbound: { - ...this.defaults.rateLimits?.inbound, - ...config.rateLimits?.inbound - } - } - }; - } - - /** - * Check if a domain is registered - */ - isDomainRegistered(domain: string): boolean { - return this.domains.has(domain.toLowerCase()); - } - - /** - * Check if an email address belongs to a registered domain - */ - isEmailRegistered(email: string): boolean { - const domain = this.extractDomain(email); - if (!domain) return false; - return this.isDomainRegistered(domain); - } - - /** - * Get domain configuration - */ - getDomainConfig(domain: string): IEmailDomainConfig | undefined { - return this.domains.get(domain.toLowerCase()); - } - - /** - * Get domain configuration for an email address - */ - getEmailDomainConfig(email: string): IEmailDomainConfig | undefined { - const domain = this.extractDomain(email); - if (!domain) return undefined; - return this.getDomainConfig(domain); - } - - /** - * Extract domain from email address - */ - private extractDomain(email: string): string | null { - const parts = email.toLowerCase().split('@'); - if (parts.length !== 2) return null; - return parts[1]; - } - - /** - * Get all registered domains - */ - getAllDomains(): string[] { - return Array.from(this.domains.keys()); - } - - /** - * Get all domain configurations - */ - getAllConfigs(): IEmailDomainConfig[] { - return Array.from(this.domains.values()); - } - - /** - * Get domains by DNS mode - */ - getDomainsByMode(mode: 'forward' | 'internal-dns' | 'external-dns'): IEmailDomainConfig[] { - return Array.from(this.domains.values()).filter(config => config.dnsMode === mode); - } -} \ No newline at end of file diff --git a/ts/mail/routing/classes.email.config.ts b/ts/mail/routing/classes.email.config.ts deleted file mode 100644 index c5f00fa..0000000 --- a/ts/mail/routing/classes.email.config.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { EmailProcessingMode } from '../delivery/interfaces.js'; - -// Re-export EmailProcessingMode type -export type { EmailProcessingMode }; - - -/** - * Domain rule interface for pattern-based routing - */ -export interface IDomainRule { - // Domain pattern (e.g., "*@example.com", "*@*.example.net") - pattern: string; - - // Handling mode for this pattern - mode: EmailProcessingMode; - - // Forward mode configuration - target?: { - server: string; - port?: number; - useTls?: boolean; - authentication?: { - user?: string; - pass?: string; - }; - }; - - // MTA mode configuration - mtaOptions?: IMtaOptions; - - // Process mode configuration - contentScanning?: boolean; - scanners?: IContentScanner[]; - transformations?: ITransformation[]; - - // Rate limits for this domain - rateLimits?: { - maxMessagesPerMinute?: number; - maxRecipientsPerMessage?: number; - }; -} - -/** - * MTA options interface - */ -export interface IMtaOptions { - domain?: string; - allowLocalDelivery?: boolean; - localDeliveryPath?: string; - dkimSign?: boolean; - dkimOptions?: { - domainName: string; - keySelector: string; - privateKey?: string; - }; - smtpBanner?: string; - maxConnections?: number; - connTimeout?: number; - spoolDir?: string; -} - -/** - * Content scanner interface - */ -export interface IContentScanner { - type: 'spam' | 'virus' | 'attachment'; - threshold?: number; - action: 'tag' | 'reject'; - blockedExtensions?: string[]; -} - -/** - * Transformation interface - */ -export interface ITransformation { - type: string; - header?: string; - value?: string; - domains?: string[]; - append?: boolean; - [key: string]: any; -} \ No newline at end of file diff --git a/ts/mail/routing/classes.email.router.ts b/ts/mail/routing/classes.email.router.ts deleted file mode 100644 index 7883c9b..0000000 --- a/ts/mail/routing/classes.email.router.ts +++ /dev/null @@ -1,575 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { EventEmitter } from 'node:events'; -import type { IEmailRoute, IEmailMatch, IEmailAction, IEmailContext } from './interfaces.js'; -import type { Email } from '../core/classes.email.js'; - -/** - * Email router that evaluates routes and determines actions - */ -export class EmailRouter extends EventEmitter { - private routes: IEmailRoute[]; - private patternCache: Map = new Map(); - private storageManager?: any; // StorageManager instance - private persistChanges: boolean; - - /** - * Create a new email router - * @param routes Array of email routes - * @param options Router options - */ - constructor(routes: IEmailRoute[], options?: { - storageManager?: any; - persistChanges?: boolean; - }) { - super(); - this.routes = this.sortRoutesByPriority(routes); - this.storageManager = options?.storageManager; - this.persistChanges = options?.persistChanges ?? !!this.storageManager; - - // If storage manager is provided, try to load persisted routes - if (this.storageManager) { - this.loadRoutes({ merge: true }).catch(error => { - console.error(`Failed to load persisted routes: ${error.message}`); - }); - } - } - - /** - * Sort routes by priority (higher priority first) - * @param routes Routes to sort - * @returns Sorted routes - */ - private sortRoutesByPriority(routes: IEmailRoute[]): IEmailRoute[] { - return [...routes].sort((a, b) => { - const priorityA = a.priority ?? 0; - const priorityB = b.priority ?? 0; - return priorityB - priorityA; // Higher priority first - }); - } - - /** - * Get all configured routes - * @returns Array of routes - */ - public getRoutes(): IEmailRoute[] { - return [...this.routes]; - } - - /** - * Update routes - * @param routes New routes - * @param persist Whether to persist changes (defaults to persistChanges setting) - */ - public async updateRoutes(routes: IEmailRoute[], persist?: boolean): Promise { - this.routes = this.sortRoutesByPriority(routes); - this.clearCache(); - this.emit('routesUpdated', this.routes); - - // Persist if requested or if persistChanges is enabled - if (persist ?? this.persistChanges) { - await this.saveRoutes(); - } - } - - /** - * Set routes (alias for updateRoutes) - * @param routes New routes - * @param persist Whether to persist changes - */ - public async setRoutes(routes: IEmailRoute[], persist?: boolean): Promise { - await this.updateRoutes(routes, persist); - } - - /** - * Clear the pattern cache - */ - public clearCache(): void { - this.patternCache.clear(); - this.emit('cacheCleared'); - } - - /** - * Evaluate routes and find the first match - * @param context Email context - * @returns Matched route or null - */ - public async evaluateRoutes(context: IEmailContext): Promise { - for (const route of this.routes) { - if (await this.matchesRoute(route, context)) { - this.emit('routeMatched', route, context); - return route; - } - } - return null; - } - - /** - * Check if a route matches the context - * @param route Route to check - * @param context Email context - * @returns True if route matches - */ - private async matchesRoute(route: IEmailRoute, context: IEmailContext): Promise { - const match = route.match; - - // Check recipients - if (match.recipients && !this.matchesRecipients(context.email, match.recipients)) { - return false; - } - - // Check senders - if (match.senders && !this.matchesSenders(context.email, match.senders)) { - return false; - } - - // Check client IP - if (match.clientIp && !this.matchesClientIp(context, match.clientIp)) { - return false; - } - - // Check authentication - if (match.authenticated !== undefined && - context.session.authenticated !== match.authenticated) { - return false; - } - - // Check headers - if (match.headers && !this.matchesHeaders(context.email, match.headers)) { - return false; - } - - // Check size - if (match.sizeRange && !this.matchesSize(context.email, match.sizeRange)) { - return false; - } - - // Check subject - if (match.subject && !this.matchesSubject(context.email, match.subject)) { - return false; - } - - // Check attachments - if (match.hasAttachments !== undefined && - (context.email.attachments.length > 0) !== match.hasAttachments) { - return false; - } - - // All checks passed - return true; - } - - /** - * Check if email recipients match patterns - * @param email Email to check - * @param patterns Patterns to match - * @returns True if any recipient matches - */ - private matchesRecipients(email: Email, patterns: string | string[]): boolean { - const patternArray = Array.isArray(patterns) ? patterns : [patterns]; - const recipients = email.getAllRecipients(); - - for (const recipient of recipients) { - for (const pattern of patternArray) { - if (this.matchesPattern(recipient, pattern)) { - return true; - } - } - } - return false; - } - - /** - * Check if email sender matches patterns - * @param email Email to check - * @param patterns Patterns to match - * @returns True if sender matches - */ - private matchesSenders(email: Email, patterns: string | string[]): boolean { - const patternArray = Array.isArray(patterns) ? patterns : [patterns]; - const sender = email.from; - - for (const pattern of patternArray) { - if (this.matchesPattern(sender, pattern)) { - return true; - } - } - return false; - } - - /** - * Check if client IP matches patterns - * @param context Email context - * @param patterns IP patterns to match - * @returns True if IP matches - */ - private matchesClientIp(context: IEmailContext, patterns: string | string[]): boolean { - const patternArray = Array.isArray(patterns) ? patterns : [patterns]; - const clientIp = context.session.remoteAddress; - - if (!clientIp) { - return false; - } - - for (const pattern of patternArray) { - // Check for CIDR notation - if (pattern.includes('/')) { - if (this.ipInCidr(clientIp, pattern)) { - return true; - } - } else { - // Exact match - if (clientIp === pattern) { - return true; - } - } - } - return false; - } - - /** - * Check if email headers match patterns - * @param email Email to check - * @param headerPatterns Header patterns to match - * @returns True if headers match - */ - private matchesHeaders(email: Email, headerPatterns: Record): boolean { - for (const [header, pattern] of Object.entries(headerPatterns)) { - const value = email.headers[header]; - if (!value) { - return false; - } - - if (pattern instanceof RegExp) { - if (!pattern.test(value)) { - return false; - } - } else { - if (value !== pattern) { - return false; - } - } - } - return true; - } - - /** - * Check if email size matches range - * @param email Email to check - * @param sizeRange Size range to match - * @returns True if size is in range - */ - private matchesSize(email: Email, sizeRange: { min?: number; max?: number }): boolean { - // Calculate approximate email size - const size = this.calculateEmailSize(email); - - if (sizeRange.min !== undefined && size < sizeRange.min) { - return false; - } - if (sizeRange.max !== undefined && size > sizeRange.max) { - return false; - } - return true; - } - - /** - * Check if email subject matches pattern - * @param email Email to check - * @param pattern Pattern to match - * @returns True if subject matches - */ - private matchesSubject(email: Email, pattern: string | RegExp): boolean { - const subject = email.subject || ''; - - if (pattern instanceof RegExp) { - return pattern.test(subject); - } else { - return this.matchesPattern(subject, pattern); - } - } - - /** - * Check if a string matches a glob pattern - * @param str String to check - * @param pattern Glob pattern - * @returns True if matches - */ - private matchesPattern(str: string, pattern: string): boolean { - // Check cache - const cacheKey = `${str}:${pattern}`; - const cached = this.patternCache.get(cacheKey); - if (cached !== undefined) { - return cached; - } - - // Convert glob to regex - const regexPattern = this.globToRegExp(pattern); - const matches = regexPattern.test(str); - - // Cache result - this.patternCache.set(cacheKey, matches); - - return matches; - } - - /** - * Convert glob pattern to RegExp - * @param pattern Glob pattern - * @returns Regular expression - */ - private globToRegExp(pattern: string): RegExp { - // Escape special regex characters except * and ? - let regexString = pattern - .replace(/[.+^${}()|[\]\\]/g, '\\$&') - .replace(/\*/g, '.*') - .replace(/\?/g, '.'); - - return new RegExp(`^${regexString}$`, 'i'); - } - - /** - * Check if IP is in CIDR range - * @param ip IP address to check - * @param cidr CIDR notation (e.g., '192.168.0.0/16') - * @returns True if IP is in range - */ - private ipInCidr(ip: string, cidr: string): boolean { - try { - const [range, bits] = cidr.split('/'); - const mask = parseInt(bits, 10); - - // Convert IPs to numbers - const ipNum = this.ipToNumber(ip); - const rangeNum = this.ipToNumber(range); - - // Calculate mask - const maskBits = 0xffffffff << (32 - mask); - - // Check if in range - return (ipNum & maskBits) === (rangeNum & maskBits); - } catch { - return false; - } - } - - /** - * Convert IP address to number - * @param ip IP address - * @returns Number representation - */ - private ipToNumber(ip: string): number { - const parts = ip.split('.'); - return parts.reduce((acc, part, index) => { - return acc + (parseInt(part, 10) << (8 * (3 - index))); - }, 0); - } - - /** - * Calculate approximate email size in bytes - * @param email Email to measure - * @returns Size in bytes - */ - private calculateEmailSize(email: Email): number { - let size = 0; - - // Headers - for (const [key, value] of Object.entries(email.headers)) { - size += key.length + value.length + 4; // ": " + "\r\n" - } - - // Body - size += (email.text || '').length; - size += (email.html || '').length; - - // Attachments - for (const attachment of email.attachments) { - if (attachment.content) { - size += attachment.content.length; - } - } - - return size; - } - - /** - * Save current routes to storage - */ - public async saveRoutes(): Promise { - if (!this.storageManager) { - this.emit('persistenceWarning', 'Cannot save routes: StorageManager not configured'); - return; - } - - try { - // Validate all routes before saving - for (const route of this.routes) { - if (!route.name || !route.match || !route.action) { - throw new Error(`Invalid route: ${JSON.stringify(route)}`); - } - } - - const routesData = JSON.stringify(this.routes, null, 2); - await this.storageManager.set('/email/routes/config.json', routesData); - - this.emit('routesPersisted', this.routes.length); - } catch (error) { - console.error(`Failed to save routes: ${error.message}`); - throw error; - } - } - - /** - * Load routes from storage - * @param options Load options - */ - public async loadRoutes(options?: { - merge?: boolean; // Merge with existing routes - replace?: boolean; // Replace existing routes - }): Promise { - if (!this.storageManager) { - this.emit('persistenceWarning', 'Cannot load routes: StorageManager not configured'); - return []; - } - - try { - const routesData = await this.storageManager.get('/email/routes/config.json'); - - if (!routesData) { - return []; - } - - const loadedRoutes = JSON.parse(routesData) as IEmailRoute[]; - - // Validate loaded routes - for (const route of loadedRoutes) { - if (!route.name || !route.match || !route.action) { - console.warn(`Skipping invalid route: ${JSON.stringify(route)}`); - continue; - } - } - - if (options?.replace) { - // Replace all routes - this.routes = this.sortRoutesByPriority(loadedRoutes); - } else if (options?.merge) { - // Merge with existing routes (loaded routes take precedence) - const routeMap = new Map(); - - // Add existing routes - for (const route of this.routes) { - routeMap.set(route.name, route); - } - - // Override with loaded routes - for (const route of loadedRoutes) { - routeMap.set(route.name, route); - } - - this.routes = this.sortRoutesByPriority(Array.from(routeMap.values())); - } - - this.clearCache(); - this.emit('routesLoaded', loadedRoutes.length); - - return loadedRoutes; - } catch (error) { - console.error(`Failed to load routes: ${error.message}`); - throw error; - } - } - - /** - * Add a route - * @param route Route to add - * @param persist Whether to persist changes - */ - public async addRoute(route: IEmailRoute, persist?: boolean): Promise { - // Validate route - if (!route.name || !route.match || !route.action) { - throw new Error('Invalid route: missing required fields'); - } - - // Check if route already exists - const existingIndex = this.routes.findIndex(r => r.name === route.name); - if (existingIndex >= 0) { - throw new Error(`Route '${route.name}' already exists`); - } - - // Add route - this.routes.push(route); - this.routes = this.sortRoutesByPriority(this.routes); - this.clearCache(); - - this.emit('routeAdded', route); - this.emit('routesUpdated', this.routes); - - // Persist if requested - if (persist ?? this.persistChanges) { - await this.saveRoutes(); - } - } - - /** - * Remove a route by name - * @param name Route name - * @param persist Whether to persist changes - */ - public async removeRoute(name: string, persist?: boolean): Promise { - const index = this.routes.findIndex(r => r.name === name); - - if (index < 0) { - throw new Error(`Route '${name}' not found`); - } - - const removedRoute = this.routes.splice(index, 1)[0]; - this.clearCache(); - - this.emit('routeRemoved', removedRoute); - this.emit('routesUpdated', this.routes); - - // Persist if requested - if (persist ?? this.persistChanges) { - await this.saveRoutes(); - } - } - - /** - * Update a route - * @param name Route name - * @param route Updated route data - * @param persist Whether to persist changes - */ - public async updateRoute(name: string, route: IEmailRoute, persist?: boolean): Promise { - // Validate route - if (!route.name || !route.match || !route.action) { - throw new Error('Invalid route: missing required fields'); - } - - const index = this.routes.findIndex(r => r.name === name); - - if (index < 0) { - throw new Error(`Route '${name}' not found`); - } - - // Update route - this.routes[index] = route; - this.routes = this.sortRoutesByPriority(this.routes); - this.clearCache(); - - this.emit('routeUpdated', route); - this.emit('routesUpdated', this.routes); - - // Persist if requested - if (persist ?? this.persistChanges) { - await this.saveRoutes(); - } - } - - /** - * Get a route by name - * @param name Route name - * @returns Route or undefined - */ - public getRoute(name: string): IEmailRoute | undefined { - return this.routes.find(r => r.name === name); - } -} \ No newline at end of file diff --git a/ts/mail/routing/classes.unified.email.server.ts b/ts/mail/routing/classes.unified.email.server.ts deleted file mode 100644 index 594dd30..0000000 --- a/ts/mail/routing/classes.unified.email.server.ts +++ /dev/null @@ -1,1862 +0,0 @@ -import * as plugins from '../../plugins.js'; -import * as paths from '../../paths.js'; -import { EventEmitter } from 'events'; -import { logger } from '../../logger.js'; -import { - SecurityLogger, - SecurityLogLevel, - SecurityEventType -} from '../../security/index.js'; -import { DKIMCreator } from '../security/classes.dkimcreator.js'; -import { IPReputationChecker } from '../../security/classes.ipreputationchecker.js'; -import { - IPWarmupManager, - type IIPWarmupConfig, - SenderReputationMonitor, - type IReputationMonitorConfig -} from '../../deliverability/index.js'; -import { EmailRouter } from './classes.email.router.js'; -import type { IEmailRoute, IEmailAction, IEmailContext, IEmailDomainConfig } from './interfaces.js'; -import { Email } from '../core/classes.email.js'; -import { DomainRegistry } from './classes.domain.registry.js'; -import { DnsManager } from './classes.dns.manager.js'; -import { BounceManager, BounceType, BounceCategory } from '../core/classes.bouncemanager.js'; -import { createSmtpServer } from '../delivery/smtpserver/index.js'; -import { createPooledSmtpClient } from '../delivery/smtpclient/create-client.js'; -import type { SmtpClient } from '../delivery/smtpclient/smtp-client.js'; -import { MultiModeDeliverySystem, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js'; -import { UnifiedDeliveryQueue, type IQueueOptions } from '../delivery/classes.delivery.queue.js'; -import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js'; -import { SmtpState } from '../delivery/interfaces.js'; -import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../delivery/interfaces.js'; -import type { DcRouter } from '../../classes.dcrouter.js'; - -/** - * Extended SMTP session interface with route information - */ -export interface IExtendedSmtpSession extends ISmtpSession { - /** - * Matched route for this session - */ - matchedRoute?: IEmailRoute; -} - -/** - * Options for the unified email server - */ -export interface IUnifiedEmailServerOptions { - // Base server options - ports: number[]; - hostname: string; - domains: IEmailDomainConfig[]; // Domain configurations - banner?: string; - debug?: boolean; - useSocketHandler?: boolean; // Use socket-handler mode instead of port listening - - // Authentication options - auth?: { - required?: boolean; - methods?: ('PLAIN' | 'LOGIN' | 'OAUTH2')[]; - users?: Array<{username: string, password: string}>; - }; - - // TLS options - tls?: { - certPath?: string; - keyPath?: string; - caPath?: string; - minVersion?: string; - ciphers?: string; - }; - - // Limits - maxMessageSize?: number; - maxClients?: number; - maxConnections?: number; - - // Connection options - connectionTimeout?: number; - socketTimeout?: number; - - // Email routing rules - routes: IEmailRoute[]; - - // Global defaults for all domains - defaults?: { - dnsMode?: 'forward' | 'internal-dns' | 'external-dns'; - dkim?: IEmailDomainConfig['dkim']; - rateLimits?: IEmailDomainConfig['rateLimits']; - }; - - // Outbound settings - outbound?: { - maxConnections?: number; - connectionTimeout?: number; - socketTimeout?: number; - retryAttempts?: number; - defaultFrom?: string; - }; - - // Rate limiting (global limits, can be overridden per domain) - rateLimits?: IHierarchicalRateLimits; - - // Deliverability options - ipWarmupConfig?: IIPWarmupConfig; - reputationMonitorConfig?: IReputationMonitorConfig; -} - - -/** - * Extended SMTP session interface for UnifiedEmailServer - */ -export interface ISmtpSession extends IBaseSmtpSession { - /** - * User information if authenticated - */ - user?: { - username: string; - [key: string]: any; - }; - - /** - * Matched route for this session - */ - matchedRoute?: IEmailRoute; -} - -/** - * Authentication data for SMTP - */ -import type { ISmtpAuth } from '../delivery/interfaces.js'; -export type IAuthData = ISmtpAuth; - -/** - * Server statistics - */ -export interface IServerStats { - startTime: Date; - connections: { - current: number; - total: number; - }; - messages: { - processed: number; - delivered: number; - failed: number; - }; - processingTime: { - avg: number; - max: number; - min: number; - }; -} - -/** - * Unified email server that handles all email traffic with pattern-based routing - */ -export class UnifiedEmailServer extends EventEmitter { - private dcRouter: DcRouter; - private options: IUnifiedEmailServerOptions; - private emailRouter: EmailRouter; - public domainRegistry: DomainRegistry; - private servers: any[] = []; - private stats: IServerStats; - - // Add components needed for sending and securing emails - public dkimCreator: DKIMCreator; - private ipReputationChecker: IPReputationChecker; // TODO: Implement IP reputation checks in processEmailByMode - private bounceManager: BounceManager; - private ipWarmupManager: IPWarmupManager; - private senderReputationMonitor: SenderReputationMonitor; - public deliveryQueue: UnifiedDeliveryQueue; - public deliverySystem: MultiModeDeliverySystem; - private rateLimiter: UnifiedRateLimiter; // TODO: Implement rate limiting in SMTP server handlers - private dkimKeys: Map = new Map(); // domain -> private key - private smtpClients: Map = new Map(); // host:port -> client - - constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) { - super(); - this.dcRouter = dcRouter; - - // Set default options - this.options = { - ...options, - banner: options.banner || `${options.hostname} ESMTP UnifiedEmailServer`, - maxMessageSize: options.maxMessageSize || 10 * 1024 * 1024, // 10MB - maxClients: options.maxClients || 100, - maxConnections: options.maxConnections || 1000, - connectionTimeout: options.connectionTimeout || 60000, // 1 minute - socketTimeout: options.socketTimeout || 60000 // 1 minute - }; - - // Initialize DKIM creator with storage manager - this.dkimCreator = new DKIMCreator(paths.keysDir, dcRouter.storageManager); - - // Initialize IP reputation checker with storage manager - this.ipReputationChecker = IPReputationChecker.getInstance({ - enableLocalCache: true, - enableDNSBL: true, - enableIPInfo: true - }, dcRouter.storageManager); - - // Initialize bounce manager with storage manager - this.bounceManager = new BounceManager({ - maxCacheSize: 10000, - cacheTTL: 30 * 24 * 60 * 60 * 1000, // 30 days - storageManager: dcRouter.storageManager - }); - - // Initialize IP warmup manager - this.ipWarmupManager = IPWarmupManager.getInstance(options.ipWarmupConfig || { - enabled: true, - ipAddresses: [], - targetDomains: [] - }); - - // Initialize sender reputation monitor with storage manager - this.senderReputationMonitor = SenderReputationMonitor.getInstance( - options.reputationMonitorConfig || { - enabled: true, - domains: [] - }, - dcRouter.storageManager - ); - - // Initialize domain registry - this.domainRegistry = new DomainRegistry(options.domains, options.defaults); - - // Initialize email router with routes and storage manager - this.emailRouter = new EmailRouter(options.routes || [], { - storageManager: dcRouter.storageManager, - persistChanges: true - }); - - // Initialize rate limiter - this.rateLimiter = new UnifiedRateLimiter(options.rateLimits || { - global: { - maxConnectionsPerIP: 10, - maxMessagesPerMinute: 100, - maxRecipientsPerMessage: 50, - maxErrorsPerIP: 10, - maxAuthFailuresPerIP: 5, - blockDuration: 300000 // 5 minutes - } - }); - - // Initialize delivery components - const queueOptions: IQueueOptions = { - storageType: 'memory', // Default to memory storage - maxRetries: 3, - baseRetryDelay: 300000, // 5 minutes - maxRetryDelay: 3600000 // 1 hour - }; - - this.deliveryQueue = new UnifiedDeliveryQueue(queueOptions); - - const deliveryOptions: IMultiModeDeliveryOptions = { - globalRateLimit: 100, // Default to 100 emails per minute - concurrentDeliveries: 10, - processBounces: true, - bounceHandler: { - processSmtpFailure: this.processSmtpFailure.bind(this) - }, - onDeliverySuccess: async (item, _result) => { - // Record delivery success event for reputation monitoring - const email = item.processingResult as Email; - const senderDomain = email.from.split('@')[1]; - - if (senderDomain) { - this.recordReputationEvent(senderDomain, { - type: 'delivered', - count: email.to.length - }); - } - } - }; - - this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this); - - // Initialize statistics - this.stats = { - startTime: new Date(), - connections: { - current: 0, - total: 0 - }, - messages: { - processed: 0, - delivered: 0, - failed: 0 - }, - processingTime: { - avg: 0, - max: 0, - min: 0 - } - }; - - // We'll create the SMTP servers during the start() method - } - - /** - * Get or create an SMTP client for the given host and port - * Uses connection pooling for efficiency - */ - public getSmtpClient(host: string, port: number = 25): SmtpClient { - const clientKey = `${host}:${port}`; - - // Check if we already have a client for this destination - let client = this.smtpClients.get(clientKey); - - if (!client) { - // Create a new pooled SMTP client - client = createPooledSmtpClient({ - host, - port, - secure: port === 465, - connectionTimeout: this.options.outbound?.connectionTimeout || 30000, - socketTimeout: this.options.outbound?.socketTimeout || 120000, - maxConnections: this.options.outbound?.maxConnections || 10, - maxMessages: 1000, // Messages per connection before reconnect - pool: true, - debug: false - }); - - this.smtpClients.set(clientKey, client); - logger.log('info', `Created new SMTP client pool for ${clientKey}`); - } - - return client; - } - - /** - * Start the unified email server - */ - public async start(): Promise { - logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`); - - try { - // Initialize the delivery queue - await this.deliveryQueue.initialize(); - logger.log('info', 'Email delivery queue initialized'); - - // Start the delivery system - await this.deliverySystem.start(); - logger.log('info', 'Email delivery system started'); - - // Set up DKIM for all domains - await this.setupDkimForDomains(); - logger.log('info', 'DKIM configuration completed for all domains'); - - // Create DNS manager and ensure all DNS records are created - const dnsManager = new DnsManager(this.dcRouter); - await dnsManager.ensureDnsRecords(this.domainRegistry.getAllConfigs(), this.dkimCreator); - logger.log('info', 'DNS records ensured for all configured domains'); - - // Apply per-domain rate limits - this.applyDomainRateLimits(); - logger.log('info', 'Per-domain rate limits configured'); - - // Check and rotate DKIM keys if needed - await this.checkAndRotateDkimKeys(); - logger.log('info', 'DKIM key rotation check completed'); - - // Skip server creation in socket-handler mode - if (this.options.useSocketHandler) { - logger.log('info', 'UnifiedEmailServer started in socket-handler mode (no port listening)'); - this.emit('started'); - return; - } - - // Ensure we have the necessary TLS options - const hasTlsConfig = this.options.tls?.keyPath && this.options.tls?.certPath; - - // Prepare the certificate and key if available - let key: string | undefined; - let cert: string | undefined; - - if (hasTlsConfig) { - try { - key = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8'); - cert = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8'); - logger.log('info', 'TLS certificates loaded successfully'); - } catch (error) { - logger.log('warn', `Failed to load TLS certificates: ${error.message}`); - } - } - - // Create a SMTP server for each port - for (const port of this.options.ports as number[]) { - // Create a reference object to hold the MTA service during setup - const mtaRef = { - config: { - smtp: { - hostname: this.options.hostname - }, - security: { - checkIPReputation: false, - verifyDkim: true, - verifySpf: true, - verifyDmarc: true - } - }, - // These will be implemented in the real integration: - dkimVerifier: { - verify: async () => ({ isValid: true, domain: '' }) - }, - spfVerifier: { - verifyAndApply: async () => true - }, - dmarcVerifier: { - verify: async () => ({}), - applyPolicy: () => true - }, - processIncomingEmail: async (email: Email) => { - // Process email using the new route-based system - await this.processEmailByMode(email, { - id: 'session-' + Math.random().toString(36).substring(2), - state: SmtpState.FINISHED, - mailFrom: email.from, - rcptTo: email.to, - emailData: email.toRFC822String(), // Use the proper method to get the full email content - useTLS: false, - connectionEnded: true, - remoteAddress: '127.0.0.1', - clientHostname: '', - secure: false, - authenticated: false, - envelope: { - mailFrom: { address: email.from, args: {} }, - rcptTo: email.to.map(recipient => ({ address: recipient, args: {} })) - } - }); - - return true; - } - }; - - // Create server options - const serverOptions = { - port, - hostname: this.options.hostname, - key, - cert - }; - - // Create and start the SMTP server - const smtpServer = createSmtpServer(mtaRef as any, serverOptions); - this.servers.push(smtpServer); - - // Start the server - await new Promise((resolve, reject) => { - try { - // Leave this empty for now, smtpServer.start() is handled by the SMTPServer class internally - // The server is started when it's created - logger.log('info', `UnifiedEmailServer listening on port ${port}`); - - // Event handlers are managed internally by the SmtpServer class - // No need to access the private server property - - resolve(); - } catch (err) { - if ((err as any).code === 'EADDRINUSE') { - logger.log('error', `Port ${port} is already in use`); - reject(new Error(`Port ${port} is already in use`)); - } else { - logger.log('error', `Error starting server on port ${port}: ${err.message}`); - reject(err); - } - } - }); - } - - logger.log('info', 'UnifiedEmailServer started successfully'); - this.emit('started'); - } catch (error) { - logger.log('error', `Failed to start UnifiedEmailServer: ${error.message}`); - throw error; - } - } - - /** - * Handle a socket from smartproxy in socket-handler mode - * @param socket The socket to handle - * @param port The port this connection is for (25, 587, 465) - */ - public async handleSocket(socket: plugins.net.Socket | plugins.tls.TLSSocket, port: number): Promise { - if (!this.options.useSocketHandler) { - logger.log('error', 'handleSocket called but useSocketHandler is not enabled'); - socket.destroy(); - return; - } - - logger.log('info', `Handling socket for port ${port}`); - - // Create a temporary SMTP server instance for this connection - // We need a full server instance because the SMTP protocol handler needs all components - const smtpServerOptions = { - port, - hostname: this.options.hostname, - key: this.options.tls?.keyPath ? plugins.fs.readFileSync(this.options.tls.keyPath, 'utf8') : undefined, - cert: this.options.tls?.certPath ? plugins.fs.readFileSync(this.options.tls.certPath, 'utf8') : undefined - }; - - // Create the SMTP server instance - const smtpServer = createSmtpServer(this, smtpServerOptions); - - // Get the connection manager from the server - const connectionManager = (smtpServer as any).connectionManager; - - if (!connectionManager) { - logger.log('error', 'Could not get connection manager from SMTP server'); - socket.destroy(); - return; - } - - // Determine if this is a secure connection - // Port 465 uses implicit TLS, so the socket is already secure - const isSecure = port === 465 || socket instanceof plugins.tls.TLSSocket; - - // Pass the socket to the connection manager - try { - await connectionManager.handleConnection(socket, isSecure); - } catch (error) { - logger.log('error', `Error handling socket connection: ${error.message}`); - socket.destroy(); - } - } - - /** - * Stop the unified email server - */ - public async stop(): Promise { - logger.log('info', 'Stopping UnifiedEmailServer'); - - try { - // Clear the servers array - servers will be garbage collected - this.servers = []; - - // Stop the delivery system - if (this.deliverySystem) { - await this.deliverySystem.stop(); - logger.log('info', 'Email delivery system stopped'); - } - - // Shut down the delivery queue - if (this.deliveryQueue) { - await this.deliveryQueue.shutdown(); - logger.log('info', 'Email delivery queue shut down'); - } - - // Close all SMTP client connections - for (const [clientKey, client] of this.smtpClients) { - try { - await client.close(); - logger.log('info', `Closed SMTP client pool for ${clientKey}`); - } catch (error) { - logger.log('warn', `Error closing SMTP client for ${clientKey}: ${error.message}`); - } - } - this.smtpClients.clear(); - - logger.log('info', 'UnifiedEmailServer stopped successfully'); - this.emit('stopped'); - } catch (error) { - logger.log('error', `Error stopping UnifiedEmailServer: ${error.message}`); - throw error; - } - } - - - - - - /** - * Process email based on routing rules - */ - public async processEmailByMode(emailData: Email | Buffer, session: IExtendedSmtpSession): Promise { - // Convert Buffer to Email if needed - let email: Email; - if (Buffer.isBuffer(emailData)) { - // Parse the email data buffer into an Email object - try { - const parsed = await plugins.mailparser.simpleParser(emailData); - email = new Email({ - from: parsed.from?.value[0]?.address || session.envelope.mailFrom.address, - to: session.envelope.rcptTo[0]?.address || '', - subject: parsed.subject || '', - text: parsed.text || '', - html: parsed.html || undefined, - attachments: parsed.attachments?.map(att => ({ - filename: att.filename || '', - content: att.content, - contentType: att.contentType - })) || [] - }); - } catch (error) { - logger.log('error', `Error parsing email data: ${error.message}`); - throw new Error(`Error parsing email data: ${error.message}`); - } - } else { - email = emailData; - } - - // First check if this is a bounce notification email - // Look for common bounce notification subject patterns - const subject = email.subject || ''; - const isBounceLike = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject); - - if (isBounceLike) { - logger.log('info', `Email subject matches bounce notification pattern: "${subject}"`); - - // Try to process as a bounce - const isBounce = await this.processBounceNotification(email); - - if (isBounce) { - logger.log('info', 'Successfully processed as bounce notification, skipping regular processing'); - return email; - } - - logger.log('info', 'Not a valid bounce notification, continuing with regular processing'); - } - - // Find matching route - const context: IEmailContext = { email, session }; - const route = await this.emailRouter.evaluateRoutes(context); - - if (!route) { - // No matching route - reject - throw new Error('No matching route for email'); - } - - // Store matched route in session - session.matchedRoute = route; - - // Execute action based on route - await this.executeAction(route.action, email, context); - - // Return the processed email - return email; - } - - /** - * Execute action based on route configuration - */ - private async executeAction(action: IEmailAction, email: Email, context: IEmailContext): Promise { - switch (action.type) { - case 'forward': - await this.handleForwardAction(action, email, context); - break; - - case 'process': - await this.handleProcessAction(action, email, context); - break; - - case 'deliver': - await this.handleDeliverAction(action, email, context); - break; - - case 'reject': - await this.handleRejectAction(action, email, context); - break; - - default: - throw new Error(`Unknown action type: ${(action as any).type}`); - } - } - - /** - * Handle forward action - */ - private async handleForwardAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise { - if (!_action.forward) { - throw new Error('Forward action requires forward configuration'); - } - - const { host, port = 25, auth, addHeaders } = _action.forward; - - logger.log('info', `Forwarding email to ${host}:${port}`); - - // Add forwarding headers - if (addHeaders) { - for (const [key, value] of Object.entries(addHeaders)) { - email.headers[key] = value; - } - } - - // Add standard forwarding headers - email.headers['X-Forwarded-For'] = context.session.remoteAddress || 'unknown'; - email.headers['X-Forwarded-To'] = email.to.join(', '); - email.headers['X-Forwarded-Date'] = new Date().toISOString(); - - // Get SMTP client - const client = this.getSmtpClient(host, port); - - try { - // Send email - await client.sendMail(email); - - logger.log('info', `Successfully forwarded email to ${host}:${port}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.INFO, - type: SecurityEventType.EMAIL_FORWARDING, - message: 'Email forwarded successfully', - ipAddress: context.session.remoteAddress, - details: { - sessionId: context.session.id, - routeName: context.session.matchedRoute?.name, - targetHost: host, - targetPort: port, - recipients: email.to - }, - success: true - }); - } catch (error) { - logger.log('error', `Failed to forward email: ${error.message}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.EMAIL_FORWARDING, - message: 'Email forwarding failed', - ipAddress: context.session.remoteAddress, - details: { - sessionId: context.session.id, - routeName: context.session.matchedRoute?.name, - targetHost: host, - targetPort: port, - error: error.message - }, - success: false - }); - - // Handle as bounce - for (const recipient of email.getAllRecipients()) { - await this.bounceManager.processSmtpFailure(recipient, error.message, { - sender: email.from, - originalEmailId: email.headers['Message-ID'] as string - }); - } - throw error; - } - } - - /** - * Handle process action - */ - private async handleProcessAction(action: IEmailAction, email: Email, context: IEmailContext): Promise { - logger.log('info', `Processing email with action options`); - - // Apply scanning if requested - if (action.process?.scan) { - // Use existing content scanner - // Note: ContentScanner integration would go here - logger.log('info', 'Content scanning requested'); - } - - // Note: DKIM signing will be applied at delivery time to ensure signature validity - - // Queue for delivery - const queue = action.process?.queue || 'normal'; - await this.deliveryQueue.enqueue(email, 'process', context.session.matchedRoute!); - - logger.log('info', `Email queued for delivery in ${queue} queue`); - } - - /** - * Handle deliver action - */ - private async handleDeliverAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise { - logger.log('info', `Delivering email locally`); - - // Queue for local delivery - await this.deliveryQueue.enqueue(email, 'mta', context.session.matchedRoute!); - - logger.log('info', 'Email queued for local delivery'); - } - - /** - * Handle reject action - */ - private async handleRejectAction(action: IEmailAction, email: Email, context: IEmailContext): Promise { - const code = action.reject?.code || 550; - const message = action.reject?.message || 'Message rejected'; - - logger.log('info', `Rejecting email with code ${code}: ${message}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.WARN, - type: SecurityEventType.EMAIL_PROCESSING, - message: 'Email rejected by routing rule', - ipAddress: context.session.remoteAddress, - details: { - sessionId: context.session.id, - routeName: context.session.matchedRoute?.name, - rejectCode: code, - rejectMessage: message, - from: email.from, - to: email.to - }, - success: false - }); - - // Throw error with SMTP code and message - const error = new Error(message); - (error as any).responseCode = code; - throw error; - } - - /** - * Handle email in MTA mode (programmatic processing) - */ - private async _handleMtaMode(email: Email, session: IExtendedSmtpSession): Promise { - logger.log('info', `Handling email in MTA mode for session ${session.id}`); - - try { - // Apply MTA rule options if provided - if (session.matchedRoute?.action.options?.mtaOptions) { - const options = session.matchedRoute.action.options.mtaOptions; - - // Apply DKIM signing if enabled - if (options.dkimSign && options.dkimOptions) { - // Sign the email with DKIM - logger.log('info', `Signing email with DKIM for domain ${options.dkimOptions.domainName}`); - - try { - // Ensure DKIM keys exist for the domain - await this.dkimCreator.handleDKIMKeysForDomain(options.dkimOptions.domainName); - - // Convert Email to raw format for signing - const rawEmail = email.toRFC822String(); - - // Create headers object - const headers = {}; - for (const [key, value] of Object.entries(email.headers)) { - headers[key] = value; - } - - // Sign the email - const dkimKeys = await this.dkimCreator.readDKIMKeys(options.dkimOptions.domainName); - const signResult = await plugins.dkimSign(rawEmail, { - signingDomain: options.dkimOptions.domainName, - selector: options.dkimOptions.keySelector || 'mta', - privateKey: dkimKeys.privateKey, - canonicalization: 'relaxed/relaxed', - algorithm: 'rsa-sha256', - signTime: new Date(), - }); - - // Add the DKIM-Signature header to the email - if (signResult.signatures) { - email.addHeader('DKIM-Signature', signResult.signatures); - logger.log('info', `Successfully added DKIM signature for ${options.dkimOptions.domainName}`); - } - } catch (error) { - logger.log('error', `Failed to sign email with DKIM: ${error.message}`); - } - } - } - - // Get email content for logging/processing - const subject = email.subject; - const recipients = email.getAllRecipients().join(', '); - - logger.log('info', `Email processed by MTA: ${subject} to ${recipients}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.INFO, - type: SecurityEventType.EMAIL_PROCESSING, - message: 'Email processed by MTA', - ipAddress: session.remoteAddress, - details: { - sessionId: session.id, - ruleName: session.matchedRoute?.name || 'default', - subject, - recipients - }, - success: true - }); - } catch (error) { - logger.log('error', `Failed to process email in MTA mode: ${error.message}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.EMAIL_PROCESSING, - message: 'MTA processing failed', - ipAddress: session.remoteAddress, - details: { - sessionId: session.id, - ruleName: session.matchedRoute?.name || 'default', - error: error.message - }, - success: false - }); - - throw error; - } - } - - /** - * Handle email in process mode (store-and-forward with scanning) - */ - private async _handleProcessMode(email: Email, session: IExtendedSmtpSession): Promise { - logger.log('info', `Handling email in process mode for session ${session.id}`); - - try { - const route = session.matchedRoute; - - // Apply content scanning if enabled - if (route?.action.options?.contentScanning && route.action.options.scanners && route.action.options.scanners.length > 0) { - logger.log('info', 'Performing content scanning'); - - // Apply each scanner - for (const scanner of route.action.options.scanners) { - switch (scanner.type) { - case 'spam': - logger.log('info', 'Scanning for spam content'); - // Implement spam scanning - break; - - case 'virus': - logger.log('info', 'Scanning for virus content'); - // Implement virus scanning - break; - - case 'attachment': - logger.log('info', 'Scanning attachments'); - - // Check for blocked extensions - if (scanner.blockedExtensions && scanner.blockedExtensions.length > 0) { - for (const attachment of email.attachments) { - const ext = this.getFileExtension(attachment.filename); - if (scanner.blockedExtensions.includes(ext)) { - if (scanner.action === 'reject') { - throw new Error(`Blocked attachment type: ${ext}`); - } else { // tag - email.addHeader('X-Attachment-Warning', `Potentially unsafe attachment: ${attachment.filename}`); - } - } - } - } - break; - } - } - } - - // Apply transformations if defined - if (route?.action.options?.transformations && route.action.options.transformations.length > 0) { - logger.log('info', 'Applying email transformations'); - - for (const transform of route.action.options.transformations) { - switch (transform.type) { - case 'addHeader': - if (transform.header && transform.value) { - email.addHeader(transform.header, transform.value); - } - break; - } - } - } - - logger.log('info', `Email successfully processed in store-and-forward mode`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.INFO, - type: SecurityEventType.EMAIL_PROCESSING, - message: 'Email processed and queued', - ipAddress: session.remoteAddress, - details: { - sessionId: session.id, - ruleName: route?.name || 'default', - contentScanning: route?.action.options?.contentScanning || false, - subject: email.subject - }, - success: true - }); - } catch (error) { - logger.log('error', `Failed to process email: ${error.message}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.EMAIL_PROCESSING, - message: 'Email processing failed', - ipAddress: session.remoteAddress, - details: { - sessionId: session.id, - ruleName: session.matchedRoute?.name || 'default', - error: error.message - }, - success: false - }); - - throw error; - } - } - - /** - * Get file extension from filename - */ - private getFileExtension(filename: string): string { - return filename.substring(filename.lastIndexOf('.')).toLowerCase(); - } - - - - /** - * Set up DKIM configuration for all domains - */ - private async setupDkimForDomains(): Promise { - const domainConfigs = this.domainRegistry.getAllConfigs(); - - if (domainConfigs.length === 0) { - logger.log('warn', 'No domains configured for DKIM'); - return; - } - - for (const domainConfig of domainConfigs) { - const domain = domainConfig.domain; - const selector = domainConfig.dkim?.selector || 'default'; - - try { - // Check if DKIM keys already exist for this domain - let keyPair: { privateKey: string; publicKey: string }; - - try { - // Try to read existing keys - keyPair = await this.dkimCreator.readDKIMKeys(domain); - logger.log('info', `Using existing DKIM keys for domain: ${domain}`); - } catch (error) { - // Generate new keys if they don't exist - keyPair = await this.dkimCreator.createDKIMKeys(); - // Store them for future use - await this.dkimCreator.createAndStoreDKIMKeys(domain); - logger.log('info', `Generated new DKIM keys for domain: ${domain}`); - } - - // Store the private key for signing - this.dkimKeys.set(domain, keyPair.privateKey); - - // DNS record creation is now handled by DnsManager - logger.log('info', `DKIM keys loaded for domain: ${domain} with selector: ${selector}`); - } catch (error) { - logger.log('error', `Failed to set up DKIM for domain ${domain}: ${error.message}`); - } - } - } - - - /** - * Apply per-domain rate limits from domain configurations - */ - private applyDomainRateLimits(): void { - const domainConfigs = this.domainRegistry.getAllConfigs(); - - for (const domainConfig of domainConfigs) { - if (domainConfig.rateLimits) { - const domain = domainConfig.domain; - const rateLimitConfig: any = {}; - - // Convert domain-specific rate limits to the format expected by UnifiedRateLimiter - if (domainConfig.rateLimits.outbound) { - if (domainConfig.rateLimits.outbound.messagesPerMinute) { - rateLimitConfig.maxMessagesPerMinute = domainConfig.rateLimits.outbound.messagesPerMinute; - } - // Note: messagesPerHour and messagesPerDay would need additional implementation in rate limiter - } - - if (domainConfig.rateLimits.inbound) { - if (domainConfig.rateLimits.inbound.messagesPerMinute) { - rateLimitConfig.maxMessagesPerMinute = domainConfig.rateLimits.inbound.messagesPerMinute; - } - if (domainConfig.rateLimits.inbound.connectionsPerIp) { - rateLimitConfig.maxConnectionsPerIP = domainConfig.rateLimits.inbound.connectionsPerIp; - } - if (domainConfig.rateLimits.inbound.recipientsPerMessage) { - rateLimitConfig.maxRecipientsPerMessage = domainConfig.rateLimits.inbound.recipientsPerMessage; - } - } - - // Apply the rate limits if we have any - if (Object.keys(rateLimitConfig).length > 0) { - this.rateLimiter.applyDomainLimits(domain, rateLimitConfig); - logger.log('info', `Applied rate limits for domain ${domain}:`, rateLimitConfig); - } - } - } - } - - /** - * Check and rotate DKIM keys if needed - */ - private async checkAndRotateDkimKeys(): Promise { - const domainConfigs = this.domainRegistry.getAllConfigs(); - - for (const domainConfig of domainConfigs) { - const domain = domainConfig.domain; - const selector = domainConfig.dkim?.selector || 'default'; - const rotateKeys = domainConfig.dkim?.rotateKeys || false; - const rotationInterval = domainConfig.dkim?.rotationInterval || 90; - const keySize = domainConfig.dkim?.keySize || 2048; - - if (!rotateKeys) { - logger.log('debug', `DKIM key rotation disabled for ${domain}`); - continue; - } - - try { - // Check if keys need rotation - const needsRotation = await this.dkimCreator.needsRotation(domain, selector, rotationInterval); - - if (needsRotation) { - logger.log('info', `DKIM keys need rotation for ${domain} (selector: ${selector})`); - - // Rotate the keys - const newSelector = await this.dkimCreator.rotateDkimKeys(domain, selector, keySize); - - // Update the domain config with new selector - domainConfig.dkim = { - ...domainConfig.dkim, - selector: newSelector - }; - - // Re-register DNS handler for new selector if internal-dns mode - if (domainConfig.dnsMode === 'internal-dns' && this.dcRouter.dnsServer) { - // Get new public key - const keyPair = await this.dkimCreator.readDKIMKeysForSelector(domain, newSelector); - const publicKeyBase64 = keyPair.publicKey - .replace(/-----BEGIN PUBLIC KEY-----/g, '') - .replace(/-----END PUBLIC KEY-----/g, '') - .replace(/\s/g, ''); - - const ttl = domainConfig.dns?.internal?.ttl || 3600; - - // Register new selector - this.dcRouter.dnsServer.registerHandler( - `${newSelector}._domainkey.${domain}`, - ['TXT'], - () => ({ - name: `${newSelector}._domainkey.${domain}`, - type: 'TXT', - class: 'IN', - ttl: ttl, - data: `v=DKIM1; k=rsa; p=${publicKeyBase64}` - }) - ); - - logger.log('info', `DKIM DNS handler registered for new selector: ${newSelector}._domainkey.${domain}`); - - // Store the updated public key in storage - await this.dcRouter.storageManager.set( - `/email/dkim/${domain}/public.key`, - keyPair.publicKey - ); - } - - // Clean up old keys after grace period (async, don't wait) - this.dkimCreator.cleanupOldKeys(domain, 30).catch(error => { - logger.log('warn', `Failed to cleanup old DKIM keys for ${domain}: ${error.message}`); - }); - - } else { - logger.log('debug', `DKIM keys for ${domain} are up to date`); - } - } catch (error) { - logger.log('error', `Failed to check/rotate DKIM keys for ${domain}: ${error.message}`); - } - } - } - - - /** - * Generate SmartProxy routes for email ports - */ - public generateProxyRoutes(portMapping?: Record): any[] { - const routes: any[] = []; - const defaultPortMapping = { - 25: 10025, - 587: 10587, - 465: 10465 - }; - - const actualPortMapping = portMapping || defaultPortMapping; - - // Generate routes for each configured port - for (const externalPort of this.options.ports) { - const internalPort = actualPortMapping[externalPort] || externalPort + 10000; - - let routeName = 'email-route'; - let tlsMode = 'passthrough'; - - // Configure based on port - switch (externalPort) { - case 25: - routeName = 'smtp-route'; - tlsMode = 'passthrough'; // STARTTLS - break; - case 587: - routeName = 'submission-route'; - tlsMode = 'passthrough'; // STARTTLS - break; - case 465: - routeName = 'smtps-route'; - tlsMode = 'terminate'; // Implicit TLS - break; - default: - routeName = `email-port-${externalPort}-route`; - } - - routes.push({ - name: routeName, - match: { - ports: [externalPort] - }, - action: { - type: 'forward', - target: { - host: 'localhost', - port: internalPort - }, - tls: { - mode: tlsMode - } - } - }); - } - - return routes; - } - - /** - * Update server configuration - */ - public updateOptions(options: Partial): void { - // Stop the server if changing ports - const portsChanged = options.ports && - (!this.options.ports || - JSON.stringify(options.ports) !== JSON.stringify(this.options.ports)); - - if (portsChanged) { - this.stop().then(() => { - this.options = { ...this.options, ...options }; - this.start(); - }); - } else { - // Update options without restart - this.options = { ...this.options, ...options }; - - // Update domain registry if domains changed - if (options.domains) { - this.domainRegistry = new DomainRegistry(options.domains, options.defaults || this.options.defaults); - } - - // Update email router if routes changed - if (options.routes) { - this.emailRouter.updateRoutes(options.routes); - } - } - } - - /** - * Update email routes - */ - public updateEmailRoutes(routes: IEmailRoute[]): void { - this.options.routes = routes; - this.emailRouter.updateRoutes(routes); - } - - /** - * Get server statistics - */ - public getStats(): IServerStats { - return { ...this.stats }; - } - - /** - * Get domain registry - */ - public getDomainRegistry(): DomainRegistry { - return this.domainRegistry; - } - - /** - * Update email routes dynamically - */ - public updateRoutes(routes: IEmailRoute[]): void { - this.emailRouter.setRoutes(routes); - logger.log('info', `Updated email routes with ${routes.length} routes`); - } - - /** - * Send an email through the delivery system - * @param email The email to send - * @param mode The processing mode to use - * @param rule Optional rule to apply - * @param options Optional sending options - * @returns The ID of the queued email - */ - public async sendEmail( - email: Email, - mode: EmailProcessingMode = 'mta', - route?: IEmailRoute, - options?: { - skipSuppressionCheck?: boolean; - ipAddress?: string; - isTransactional?: boolean; - } - ): Promise { - logger.log('info', `Sending email: ${email.subject} to ${email.to.join(', ')}`); - - try { - // Validate the email - if (!email.from) { - throw new Error('Email must have a sender address'); - } - - if (!email.to || email.to.length === 0) { - throw new Error('Email must have at least one recipient'); - } - - // Check if any recipients are on the suppression list (unless explicitly skipped) - if (!options?.skipSuppressionCheck) { - const suppressedRecipients = email.to.filter(recipient => this.isEmailSuppressed(recipient)); - - if (suppressedRecipients.length > 0) { - // Filter out suppressed recipients - const originalCount = email.to.length; - const suppressed = suppressedRecipients.map(recipient => { - const info = this.getSuppressionInfo(recipient); - return { - email: recipient, - reason: info?.reason || 'Unknown', - until: info?.expiresAt ? new Date(info.expiresAt).toISOString() : 'permanent' - }; - }); - - logger.log('warn', `Filtering out ${suppressedRecipients.length} suppressed recipient(s)`, { suppressed }); - - // If all recipients are suppressed, throw an error - if (suppressedRecipients.length === originalCount) { - throw new Error('All recipients are on the suppression list'); - } - - // Filter the recipients list to only include non-suppressed addresses - email.to = email.to.filter(recipient => !this.isEmailSuppressed(recipient)); - } - } - - // IP warmup handling - let ipAddress = options?.ipAddress; - - // If no specific IP was provided, use IP warmup manager to find the best IP - if (!ipAddress) { - const domain = email.from.split('@')[1]; - - ipAddress = this.getBestIPForSending({ - from: email.from, - to: email.to, - domain, - isTransactional: options?.isTransactional - }); - - if (ipAddress) { - logger.log('info', `Selected IP ${ipAddress} for sending based on warmup status`); - } - } - - // If an IP is provided or selected by warmup manager, check its capacity - if (ipAddress) { - // Check if the IP can send more today - if (!this.canIPSendMoreToday(ipAddress)) { - logger.log('warn', `IP ${ipAddress} has reached its daily sending limit, email will be queued for later delivery`); - } - - // Check if the IP can send more this hour - if (!this.canIPSendMoreThisHour(ipAddress)) { - logger.log('warn', `IP ${ipAddress} has reached its hourly sending limit, email will be queued for later delivery`); - } - - // Record the send for IP warmup tracking - this.recordIPSend(ipAddress); - - // Add IP header to the email - email.addHeader('X-Sending-IP', ipAddress); - } - - // Check if the sender domain has DKIM keys and sign the email if needed - if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) { - const domain = email.from.split('@')[1]; - await this.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || 'mta'); - } - - // Generate a unique ID for this email - const id = plugins.uuid.v4(); - - // Queue the email for delivery - await this.deliveryQueue.enqueue(email, mode, route); - - // Record 'sent' event for domain reputation monitoring - const senderDomain = email.from.split('@')[1]; - if (senderDomain) { - this.recordReputationEvent(senderDomain, { - type: 'sent', - count: email.to.length - }); - } - - logger.log('info', `Email queued with ID: ${id}`); - return id; - } catch (error) { - logger.log('error', `Failed to send email: ${error.message}`); - throw error; - } - } - - /** - * Handle DKIM signing for an email - * @param email The email to sign - * @param domain The domain to sign with - * @param selector The DKIM selector - */ - private async handleDkimSigning(email: Email, domain: string, selector: string): Promise { - try { - // Ensure we have DKIM keys for this domain - await this.dkimCreator.handleDKIMKeysForDomain(domain); - - // Get the private key - const { privateKey } = await this.dkimCreator.readDKIMKeys(domain); - - // Convert Email to raw format for signing - const rawEmail = email.toRFC822String(); - - // Sign the email - const signResult = await plugins.dkimSign(rawEmail, { - signingDomain: domain, - selector: selector, - privateKey: privateKey, - canonicalization: 'relaxed/relaxed', - algorithm: 'rsa-sha256', - signTime: new Date(), - }); - - // Add the DKIM-Signature header to the email - if (signResult.signatures) { - email.addHeader('DKIM-Signature', signResult.signatures); - logger.log('info', `Successfully added DKIM signature for ${domain}`); - } - } catch (error) { - logger.log('error', `Failed to sign email with DKIM: ${error.message}`); - // Continue without DKIM rather than failing the send - } - } - - /** - * Process a bounce notification email - * @param bounceEmail The email containing bounce notification information - * @returns Processed bounce record or null if not a bounce - */ - public async processBounceNotification(bounceEmail: Email): Promise { - logger.log('info', 'Processing potential bounce notification email'); - - try { - // Process as a bounce notification (no conversion needed anymore) - const bounceRecord = await this.bounceManager.processBounceEmail(bounceEmail); - - if (bounceRecord) { - logger.log('info', `Successfully processed bounce notification for ${bounceRecord.recipient}`, { - bounceType: bounceRecord.bounceType, - bounceCategory: bounceRecord.bounceCategory - }); - - // Notify any registered listeners about the bounce - this.emit('bounceProcessed', bounceRecord); - - // Record bounce event for domain reputation tracking - if (bounceRecord.domain) { - this.recordReputationEvent(bounceRecord.domain, { - type: 'bounce', - hardBounce: bounceRecord.bounceCategory === BounceCategory.HARD, - receivingDomain: bounceRecord.recipient.split('@')[1] - }); - } - - // Log security event - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.INFO, - type: SecurityEventType.EMAIL_VALIDATION, - message: `Bounce notification processed for recipient`, - domain: bounceRecord.domain, - details: { - recipient: bounceRecord.recipient, - bounceType: bounceRecord.bounceType, - bounceCategory: bounceRecord.bounceCategory - }, - success: true - }); - - return true; - } else { - logger.log('info', 'Email not recognized as a bounce notification'); - return false; - } - } catch (error) { - logger.log('error', `Error processing bounce notification: ${error.message}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.EMAIL_VALIDATION, - message: 'Failed to process bounce notification', - details: { - error: error.message, - subject: bounceEmail.subject - }, - success: false - }); - - return false; - } - } - - /** - * Process an SMTP failure as a bounce - * @param recipient Recipient email that failed - * @param smtpResponse SMTP error response - * @param options Additional options for bounce processing - * @returns Processed bounce record - */ - public async processSmtpFailure( - recipient: string, - smtpResponse: string, - options: { - sender?: string; - originalEmailId?: string; - statusCode?: string; - headers?: Record; - } = {} - ): Promise { - logger.log('info', `Processing SMTP failure for ${recipient}: ${smtpResponse}`); - - try { - // Process the SMTP failure through the bounce manager - const bounceRecord = await this.bounceManager.processSmtpFailure( - recipient, - smtpResponse, - options - ); - - logger.log('info', `Successfully processed SMTP failure for ${recipient} as ${bounceRecord.bounceCategory} bounce`, { - bounceType: bounceRecord.bounceType - }); - - // Notify any registered listeners about the bounce - this.emit('bounceProcessed', bounceRecord); - - // Record bounce event for domain reputation tracking - if (bounceRecord.domain) { - this.recordReputationEvent(bounceRecord.domain, { - type: 'bounce', - hardBounce: bounceRecord.bounceCategory === BounceCategory.HARD, - receivingDomain: bounceRecord.recipient.split('@')[1] - }); - } - - // Log security event - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.INFO, - type: SecurityEventType.EMAIL_VALIDATION, - message: `SMTP failure processed for recipient`, - domain: bounceRecord.domain, - details: { - recipient: bounceRecord.recipient, - bounceType: bounceRecord.bounceType, - bounceCategory: bounceRecord.bounceCategory, - smtpResponse - }, - success: true - }); - - return true; - } catch (error) { - logger.log('error', `Error processing SMTP failure: ${error.message}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.EMAIL_VALIDATION, - message: 'Failed to process SMTP failure', - details: { - recipient, - smtpResponse, - error: error.message - }, - success: false - }); - - return false; - } - } - - /** - * Check if an email address is suppressed (has bounced previously) - * @param email Email address to check - * @returns Whether the email is suppressed - */ - public isEmailSuppressed(email: string): boolean { - return this.bounceManager.isEmailSuppressed(email); - } - - /** - * Get suppression information for an email - * @param email Email address to check - * @returns Suppression information or null if not suppressed - */ - public getSuppressionInfo(email: string): { - reason: string; - timestamp: number; - expiresAt?: number; - } | null { - return this.bounceManager.getSuppressionInfo(email); - } - - /** - * Get bounce history information for an email - * @param email Email address to check - * @returns Bounce history or null if no bounces - */ - public getBounceHistory(email: string): { - lastBounce: number; - count: number; - type: BounceType; - category: BounceCategory; - } | null { - return this.bounceManager.getBounceInfo(email); - } - - /** - * Get all suppressed email addresses - * @returns Array of suppressed email addresses - */ - public getSuppressionList(): string[] { - return this.bounceManager.getSuppressionList(); - } - - /** - * Get all hard bounced email addresses - * @returns Array of hard bounced email addresses - */ - public getHardBouncedAddresses(): string[] { - return this.bounceManager.getHardBouncedAddresses(); - } - - /** - * Add an email to the suppression list - * @param email Email address to suppress - * @param reason Reason for suppression - * @param expiresAt Optional expiration time (undefined for permanent) - */ - public addToSuppressionList(email: string, reason: string, expiresAt?: number): void { - this.bounceManager.addToSuppressionList(email, reason, expiresAt); - logger.log('info', `Added ${email} to suppression list: ${reason}`); - } - - /** - * Remove an email from the suppression list - * @param email Email address to remove from suppression - */ - public removeFromSuppressionList(email: string): void { - this.bounceManager.removeFromSuppressionList(email); - logger.log('info', `Removed ${email} from suppression list`); - } - - /** - * Get the status of IP warmup process - * @param ipAddress Optional specific IP to check - * @returns Status of IP warmup - */ - public getIPWarmupStatus(ipAddress?: string): any { - return this.ipWarmupManager.getWarmupStatus(ipAddress); - } - - /** - * Add a new IP address to the warmup process - * @param ipAddress IP address to add - */ - public addIPToWarmup(ipAddress: string): void { - this.ipWarmupManager.addIPToWarmup(ipAddress); - } - - /** - * Remove an IP address from the warmup process - * @param ipAddress IP address to remove - */ - public removeIPFromWarmup(ipAddress: string): void { - this.ipWarmupManager.removeIPFromWarmup(ipAddress); - } - - /** - * Update metrics for an IP in the warmup process - * @param ipAddress IP address - * @param metrics Metrics to update - */ - public updateIPWarmupMetrics( - ipAddress: string, - metrics: { openRate?: number; bounceRate?: number; complaintRate?: number } - ): void { - this.ipWarmupManager.updateMetrics(ipAddress, metrics); - } - - /** - * Check if an IP can send more emails today - * @param ipAddress IP address to check - * @returns Whether the IP can send more today - */ - public canIPSendMoreToday(ipAddress: string): boolean { - return this.ipWarmupManager.canSendMoreToday(ipAddress); - } - - /** - * Check if an IP can send more emails in the current hour - * @param ipAddress IP address to check - * @returns Whether the IP can send more this hour - */ - public canIPSendMoreThisHour(ipAddress: string): boolean { - return this.ipWarmupManager.canSendMoreThisHour(ipAddress); - } - - /** - * Get the best IP to use for sending an email based on warmup status - * @param emailInfo Information about the email being sent - * @returns Best IP to use or null - */ - public getBestIPForSending(emailInfo: { - from: string; - to: string[]; - domain: string; - isTransactional?: boolean; - }): string | null { - return this.ipWarmupManager.getBestIPForSending(emailInfo); - } - - /** - * Set the active IP allocation policy for warmup - * @param policyName Name of the policy to set - */ - public setIPAllocationPolicy(policyName: string): void { - this.ipWarmupManager.setActiveAllocationPolicy(policyName); - } - - /** - * Record that an email was sent using a specific IP - * @param ipAddress IP address used for sending - */ - public recordIPSend(ipAddress: string): void { - this.ipWarmupManager.recordSend(ipAddress); - } - - /** - * Get reputation data for a domain - * @param domain Domain to get reputation for - * @returns Domain reputation metrics - */ - public getDomainReputationData(domain: string): any { - return this.senderReputationMonitor.getReputationData(domain); - } - - /** - * Get summary reputation data for all monitored domains - * @returns Summary data for all domains - */ - public getReputationSummary(): any { - return this.senderReputationMonitor.getReputationSummary(); - } - - /** - * Add a domain to the reputation monitoring system - * @param domain Domain to add - */ - public addDomainToMonitoring(domain: string): void { - this.senderReputationMonitor.addDomain(domain); - } - - /** - * Remove a domain from the reputation monitoring system - * @param domain Domain to remove - */ - public removeDomainFromMonitoring(domain: string): void { - this.senderReputationMonitor.removeDomain(domain); - } - - /** - * Record an email event for domain reputation tracking - * @param domain Domain sending the email - * @param event Event details - */ - public recordReputationEvent(domain: string, event: { - type: 'sent' | 'delivered' | 'bounce' | 'complaint' | 'open' | 'click'; - count?: number; - hardBounce?: boolean; - receivingDomain?: string; - }): void { - this.senderReputationMonitor.recordSendEvent(domain, event); - } - - /** - * Check if DKIM key exists for a domain - * @param domain Domain to check - */ - public hasDkimKey(domain: string): boolean { - return this.dkimKeys.has(domain); - } - - /** - * Record successful email delivery - * @param domain Sending domain - */ - public recordDelivery(domain: string): void { - this.recordReputationEvent(domain, { - type: 'delivered', - count: 1 - }); - } - - /** - * Record email bounce - * @param domain Sending domain - * @param receivingDomain Receiving domain that bounced - * @param bounceType Type of bounce (hard/soft) - * @param reason Bounce reason - */ - public recordBounce(domain: string, receivingDomain: string, bounceType: 'hard' | 'soft', reason: string): void { - // Record bounce in bounce manager - const bounceRecord = { - id: `bounce_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, - recipient: `user@${receivingDomain}`, - sender: `user@${domain}`, - domain: domain, - bounceType: bounceType === 'hard' ? BounceType.INVALID_RECIPIENT : BounceType.TEMPORARY_FAILURE, - bounceCategory: bounceType === 'hard' ? BounceCategory.HARD : BounceCategory.SOFT, - timestamp: Date.now(), - smtpResponse: reason, - diagnosticCode: reason, - statusCode: bounceType === 'hard' ? '550' : '450', - processed: false - }; - - // Process the bounce - this.bounceManager.processBounce(bounceRecord); - - // Record reputation event - this.recordReputationEvent(domain, { - type: 'bounce', - count: 1, - hardBounce: bounceType === 'hard', - receivingDomain - }); - } - - /** - * Get the rate limiter instance - * @returns The unified rate limiter - */ - public getRateLimiter(): UnifiedRateLimiter { - return this.rateLimiter; - } -} \ No newline at end of file diff --git a/ts/mail/routing/index.ts b/ts/mail/routing/index.ts deleted file mode 100644 index 55c2760..0000000 --- a/ts/mail/routing/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Email routing components -export * from './classes.email.router.js'; -export * from './classes.unified.email.server.js'; -export * from './classes.dns.manager.js'; -export * from './interfaces.js'; -export * from './classes.domain.registry.js'; \ No newline at end of file diff --git a/ts/mail/routing/interfaces.ts b/ts/mail/routing/interfaces.ts deleted file mode 100644 index ba9fc6a..0000000 --- a/ts/mail/routing/interfaces.ts +++ /dev/null @@ -1,202 +0,0 @@ -import type { Email } from '../core/classes.email.js'; -import type { IExtendedSmtpSession } from './classes.unified.email.server.js'; - -/** - * Route configuration for email routing - */ -export interface IEmailRoute { - /** Route identifier */ - name: string; - /** Order of evaluation (higher priority evaluated first, default: 0) */ - priority?: number; - /** Conditions to match */ - match: IEmailMatch; - /** Action to take when matched */ - action: IEmailAction; -} - -/** - * Match criteria for email routing - */ -export interface IEmailMatch { - /** Email patterns to match recipients: "*@example.com", "admin@*" */ - recipients?: string | string[]; - /** Email patterns to match senders */ - senders?: string | string[]; - /** IP addresses or CIDR ranges to match */ - clientIp?: string | string[]; - /** Require authentication status */ - authenticated?: boolean; - - // Optional advanced matching - /** Headers to match */ - headers?: Record; - /** Message size range */ - sizeRange?: { min?: number; max?: number }; - /** Subject line patterns */ - subject?: string | RegExp; - /** Has attachments */ - hasAttachments?: boolean; -} - -/** - * Action to take when route matches - */ -export interface IEmailAction { - /** Type of action to perform */ - type: 'forward' | 'deliver' | 'reject' | 'process'; - - /** Forward action configuration */ - forward?: { - /** Target host to forward to */ - host: string; - /** Target port (default: 25) */ - port?: number; - /** Authentication credentials */ - auth?: { - user: string; - pass: string; - }; - /** Preserve original headers */ - preserveHeaders?: boolean; - /** Additional headers to add */ - addHeaders?: Record; - }; - - /** Reject action configuration */ - reject?: { - /** SMTP response code */ - code: number; - /** SMTP response message */ - message: string; - }; - - /** Process action configuration */ - process?: { - /** Enable content scanning */ - scan?: boolean; - /** Enable DKIM signing */ - dkim?: boolean; - /** Delivery queue priority */ - queue?: 'normal' | 'priority' | 'bulk'; - }; - - /** Options for various action types */ - options?: { - /** MTA specific options */ - mtaOptions?: { - domain?: string; - allowLocalDelivery?: boolean; - localDeliveryPath?: string; - dkimSign?: boolean; - dkimOptions?: { - domainName: string; - keySelector: string; - privateKey?: string; - }; - smtpBanner?: string; - maxConnections?: number; - connTimeout?: number; - spoolDir?: string; - }; - /** Content scanning configuration */ - contentScanning?: boolean; - scanners?: Array<{ - type: 'spam' | 'virus' | 'attachment'; - threshold?: number; - action: 'tag' | 'reject'; - blockedExtensions?: string[]; - }>; - /** Email transformations */ - transformations?: Array<{ - type: string; - header?: string; - value?: string; - domains?: string[]; - append?: boolean; - [key: string]: any; - }>; - }; - - /** Delivery options (applies to forward/process/deliver) */ - delivery?: { - /** Rate limit (messages per minute) */ - rateLimit?: number; - /** Number of retry attempts */ - retries?: number; - }; -} - -/** - * Context for route evaluation - */ -export interface IEmailContext { - /** The email being routed */ - email: Email; - /** The SMTP session */ - session: IExtendedSmtpSession; -} - -/** - * Email domain configuration - */ -export interface IEmailDomainConfig { - /** Domain name */ - domain: string; - - /** DNS handling mode */ - dnsMode: 'forward' | 'internal-dns' | 'external-dns'; - - /** DNS configuration based on mode */ - dns?: { - /** For 'forward' mode */ - forward?: { - /** Skip DNS validation (default: false) */ - skipDnsValidation?: boolean; - /** Target server's expected domain */ - targetDomain?: string; - }; - - /** For 'internal-dns' mode */ - internal?: { - /** TTL for DNS records in seconds (default: 3600) */ - ttl?: number; - /** MX record priority (default: 10) */ - mxPriority?: number; - }; - - /** For 'external-dns' mode */ - external?: { - /** Custom DNS servers (default: system DNS) */ - servers?: string[]; - /** Which records to validate (default: ['MX', 'SPF', 'DKIM', 'DMARC']) */ - requiredRecords?: ('MX' | 'SPF' | 'DKIM' | 'DMARC')[]; - }; - }; - - /** Per-domain DKIM settings (DKIM always enabled) */ - dkim?: { - /** DKIM selector (default: 'default') */ - selector?: string; - /** Key size in bits (default: 2048) */ - keySize?: number; - /** Automatically rotate keys (default: false) */ - rotateKeys?: boolean; - /** Days between key rotations (default: 90) */ - rotationInterval?: number; - }; - - /** Per-domain rate limits */ - rateLimits?: { - outbound?: { - messagesPerMinute?: number; - messagesPerHour?: number; - messagesPerDay?: number; - }; - inbound?: { - messagesPerMinute?: number; - connectionsPerIp?: number; - recipientsPerMessage?: number; - }; - }; -} \ No newline at end of file diff --git a/ts/mail/security/classes.dkimcreator.ts b/ts/mail/security/classes.dkimcreator.ts deleted file mode 100644 index 2590272..0000000 --- a/ts/mail/security/classes.dkimcreator.ts +++ /dev/null @@ -1,431 +0,0 @@ -import * as plugins from '../../plugins.js'; -import * as paths from '../../paths.js'; - -import { Email } from '../core/classes.email.js'; -// MtaService reference removed - -const readFile = plugins.util.promisify(plugins.fs.readFile); -const writeFile = plugins.util.promisify(plugins.fs.writeFile); -const generateKeyPair = plugins.util.promisify(plugins.crypto.generateKeyPair); - -export interface IKeyPaths { - privateKeyPath: string; - publicKeyPath: string; -} - -export interface IDkimKeyMetadata { - domain: string; - selector: string; - createdAt: number; - rotatedAt?: number; - previousSelector?: string; - keySize: number; -} - -export class DKIMCreator { - private keysDir: string; - private storageManager?: any; // StorageManager instance - - constructor(keysDir = paths.keysDir, storageManager?: any) { - this.keysDir = keysDir; - this.storageManager = storageManager; - } - - public async getKeyPathsForDomain(domainArg: string): Promise { - return { - privateKeyPath: plugins.path.join(this.keysDir, `${domainArg}-private.pem`), - publicKeyPath: plugins.path.join(this.keysDir, `${domainArg}-public.pem`), - }; - } - - // Check if a DKIM key is present and creates one and stores it to disk otherwise - public async handleDKIMKeysForDomain(domainArg: string): Promise { - try { - await this.readDKIMKeys(domainArg); - } catch (error) { - console.log(`No DKIM keys found for ${domainArg}. Generating...`); - await this.createAndStoreDKIMKeys(domainArg); - const dnsValue = await this.getDNSRecordForDomain(domainArg); - plugins.fsUtils.ensureDirSync(paths.dnsRecordsDir); - plugins.fsUtils.toFsSync(JSON.stringify(dnsValue, null, 2), plugins.path.join(paths.dnsRecordsDir, `${domainArg}.dkimrecord.json`)); - } - } - - public async handleDKIMKeysForEmail(email: Email): Promise { - const domain = email.from.split('@')[1]; - await this.handleDKIMKeysForDomain(domain); - } - - // Read DKIM keys - always use storage manager, migrate from filesystem if needed - public async readDKIMKeys(domainArg: string): Promise<{ privateKey: string; publicKey: string }> { - // Try to read from storage manager first - if (this.storageManager) { - try { - const [privateKey, publicKey] = await Promise.all([ - this.storageManager.get(`/email/dkim/${domainArg}/private.key`), - this.storageManager.get(`/email/dkim/${domainArg}/public.key`) - ]); - - if (privateKey && publicKey) { - return { privateKey, publicKey }; - } - } catch (error) { - // Fall through to migration check - } - - // Check if keys exist in filesystem and migrate them to storage manager - const keyPaths = await this.getKeyPathsForDomain(domainArg); - try { - const [privateKeyBuffer, publicKeyBuffer] = await Promise.all([ - readFile(keyPaths.privateKeyPath), - readFile(keyPaths.publicKeyPath), - ]); - - // Convert the buffers to strings - const privateKey = privateKeyBuffer.toString(); - const publicKey = publicKeyBuffer.toString(); - - // Migrate to storage manager - console.log(`Migrating DKIM keys for ${domainArg} from filesystem to StorageManager`); - await Promise.all([ - this.storageManager.set(`/email/dkim/${domainArg}/private.key`, privateKey), - this.storageManager.set(`/email/dkim/${domainArg}/public.key`, publicKey) - ]); - - return { privateKey, publicKey }; - } catch (error) { - if (error.code === 'ENOENT') { - // Keys don't exist anywhere - throw new Error(`DKIM keys not found for domain ${domainArg}`); - } - throw error; - } - } else { - // No storage manager, use filesystem directly - const keyPaths = await this.getKeyPathsForDomain(domainArg); - const [privateKeyBuffer, publicKeyBuffer] = await Promise.all([ - readFile(keyPaths.privateKeyPath), - readFile(keyPaths.publicKeyPath), - ]); - - const privateKey = privateKeyBuffer.toString(); - const publicKey = publicKeyBuffer.toString(); - - return { privateKey, publicKey }; - } - } - - // Create a DKIM key pair - changed to public for API access - public async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> { - const { privateKey, publicKey } = await generateKeyPair('rsa', { - modulusLength: 2048, - publicKeyEncoding: { type: 'spki', format: 'pem' }, - privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, - }); - - return { privateKey, publicKey }; - } - - // Store a DKIM key pair - uses storage manager if available, else disk - public async storeDKIMKeys( - privateKey: string, - publicKey: string, - privateKeyPath: string, - publicKeyPath: string - ): Promise { - // Store in storage manager if available - if (this.storageManager) { - // Extract domain from path (e.g., /path/to/keys/example.com-private.pem -> example.com) - const match = privateKeyPath.match(/\/([^\/]+)-private\.pem$/); - if (match) { - const domain = match[1]; - await Promise.all([ - this.storageManager.set(`/email/dkim/${domain}/private.key`, privateKey), - this.storageManager.set(`/email/dkim/${domain}/public.key`, publicKey) - ]); - } - } - - // Also store to filesystem for backward compatibility - await Promise.all([writeFile(privateKeyPath, privateKey), writeFile(publicKeyPath, publicKey)]); - } - - // Create a DKIM key pair and store it to disk - changed to public for API access - public async createAndStoreDKIMKeys(domain: string): Promise { - const { privateKey, publicKey } = await this.createDKIMKeys(); - const keyPaths = await this.getKeyPathsForDomain(domain); - await this.storeDKIMKeys( - privateKey, - publicKey, - keyPaths.privateKeyPath, - keyPaths.publicKeyPath - ); - console.log(`DKIM keys for ${domain} created and stored.`); - } - - // Changed to public for API access - public async getDNSRecordForDomain(domainArg: string): Promise { - await this.handleDKIMKeysForDomain(domainArg); - const keys = await this.readDKIMKeys(domainArg); - - // Remove the PEM header and footer and newlines - const pemHeader = '-----BEGIN PUBLIC KEY-----'; - const pemFooter = '-----END PUBLIC KEY-----'; - const keyContents = keys.publicKey - .replace(pemHeader, '') - .replace(pemFooter, '') - .replace(/\n/g, ''); - - // Now generate the DKIM DNS TXT record - const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`; - - return { - name: `mta._domainkey.${domainArg}`, - type: 'TXT', - dnsSecEnabled: null, - value: dnsRecordValue, - }; - } - - /** - * Get DKIM key metadata for a domain - */ - private async getKeyMetadata(domain: string, selector: string = 'default'): Promise { - if (!this.storageManager) { - return null; - } - - const metadataKey = `/email/dkim/${domain}/${selector}/metadata`; - const metadataStr = await this.storageManager.get(metadataKey); - - if (!metadataStr) { - return null; - } - - return JSON.parse(metadataStr) as IDkimKeyMetadata; - } - - /** - * Save DKIM key metadata - */ - private async saveKeyMetadata(metadata: IDkimKeyMetadata): Promise { - if (!this.storageManager) { - return; - } - - const metadataKey = `/email/dkim/${metadata.domain}/${metadata.selector}/metadata`; - await this.storageManager.set(metadataKey, JSON.stringify(metadata)); - } - - /** - * Check if DKIM keys need rotation - */ - public async needsRotation(domain: string, selector: string = 'default', rotationIntervalDays: number = 90): Promise { - const metadata = await this.getKeyMetadata(domain, selector); - - if (!metadata) { - // No metadata means old keys, should rotate - return true; - } - - const now = Date.now(); - const keyAgeMs = now - metadata.createdAt; - const keyAgeDays = keyAgeMs / (1000 * 60 * 60 * 24); - - return keyAgeDays >= rotationIntervalDays; - } - - /** - * Rotate DKIM keys for a domain - */ - public async rotateDkimKeys(domain: string, currentSelector: string = 'default', keySize: number = 2048): Promise { - console.log(`Rotating DKIM keys for ${domain}...`); - - // Generate new selector based on date - const now = new Date(); - const newSelector = `key${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`; - - // Create new keys with custom key size - const { privateKey, publicKey } = await generateKeyPair('rsa', { - modulusLength: keySize, - publicKeyEncoding: { type: 'spki', format: 'pem' }, - privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, - }); - - // Store new keys with new selector - const newKeyPaths = await this.getKeyPathsForSelector(domain, newSelector); - - // Store in storage manager if available - if (this.storageManager) { - await Promise.all([ - this.storageManager.set(`/email/dkim/${domain}/${newSelector}/private.key`, privateKey), - this.storageManager.set(`/email/dkim/${domain}/${newSelector}/public.key`, publicKey) - ]); - } - - // Also store to filesystem - await this.storeDKIMKeys( - privateKey, - publicKey, - newKeyPaths.privateKeyPath, - newKeyPaths.publicKeyPath - ); - - // Save metadata for new keys - const metadata: IDkimKeyMetadata = { - domain, - selector: newSelector, - createdAt: Date.now(), - previousSelector: currentSelector, - keySize - }; - await this.saveKeyMetadata(metadata); - - // Update metadata for old keys - const oldMetadata = await this.getKeyMetadata(domain, currentSelector); - if (oldMetadata) { - oldMetadata.rotatedAt = Date.now(); - await this.saveKeyMetadata(oldMetadata); - } - - console.log(`DKIM keys rotated for ${domain}. New selector: ${newSelector}`); - return newSelector; - } - - /** - * Get key paths for a specific selector - */ - public async getKeyPathsForSelector(domain: string, selector: string): Promise { - return { - privateKeyPath: plugins.path.join(this.keysDir, `${domain}-${selector}-private.pem`), - publicKeyPath: plugins.path.join(this.keysDir, `${domain}-${selector}-public.pem`), - }; - } - - /** - * Read DKIM keys for a specific selector - */ - public async readDKIMKeysForSelector(domain: string, selector: string): Promise<{ privateKey: string; publicKey: string }> { - // Try to read from storage manager first - if (this.storageManager) { - try { - const [privateKey, publicKey] = await Promise.all([ - this.storageManager.get(`/email/dkim/${domain}/${selector}/private.key`), - this.storageManager.get(`/email/dkim/${domain}/${selector}/public.key`) - ]); - - if (privateKey && publicKey) { - return { privateKey, publicKey }; - } - } catch (error) { - // Fall through to migration check - } - - // Check if keys exist in filesystem and migrate them to storage manager - const keyPaths = await this.getKeyPathsForSelector(domain, selector); - try { - const [privateKeyBuffer, publicKeyBuffer] = await Promise.all([ - readFile(keyPaths.privateKeyPath), - readFile(keyPaths.publicKeyPath), - ]); - - const privateKey = privateKeyBuffer.toString(); - const publicKey = publicKeyBuffer.toString(); - - // Migrate to storage manager - console.log(`Migrating DKIM keys for ${domain}/${selector} from filesystem to StorageManager`); - await Promise.all([ - this.storageManager.set(`/email/dkim/${domain}/${selector}/private.key`, privateKey), - this.storageManager.set(`/email/dkim/${domain}/${selector}/public.key`, publicKey) - ]); - - return { privateKey, publicKey }; - } catch (error) { - if (error.code === 'ENOENT') { - throw new Error(`DKIM keys not found for domain ${domain} with selector ${selector}`); - } - throw error; - } - } else { - // No storage manager, use filesystem directly - const keyPaths = await this.getKeyPathsForSelector(domain, selector); - const [privateKeyBuffer, publicKeyBuffer] = await Promise.all([ - readFile(keyPaths.privateKeyPath), - readFile(keyPaths.publicKeyPath), - ]); - - const privateKey = privateKeyBuffer.toString(); - const publicKey = publicKeyBuffer.toString(); - - return { privateKey, publicKey }; - } - } - - /** - * Get DNS record for a specific selector - */ - public async getDNSRecordForSelector(domain: string, selector: string): Promise { - const keys = await this.readDKIMKeysForSelector(domain, selector); - - // Remove the PEM header and footer and newlines - const pemHeader = '-----BEGIN PUBLIC KEY-----'; - const pemFooter = '-----END PUBLIC KEY-----'; - const keyContents = keys.publicKey - .replace(pemHeader, '') - .replace(pemFooter, '') - .replace(/\n/g, ''); - - // Generate the DKIM DNS TXT record - const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`; - - return { - name: `${selector}._domainkey.${domain}`, - type: 'TXT', - dnsSecEnabled: null, - value: dnsRecordValue, - }; - } - - /** - * Clean up old DKIM keys after grace period - */ - public async cleanupOldKeys(domain: string, gracePeriodDays: number = 30): Promise { - if (!this.storageManager) { - return; - } - - // List all selectors for the domain - const metadataKeys = await this.storageManager.list(`/email/dkim/${domain}/`); - - for (const key of metadataKeys) { - if (key.endsWith('/metadata')) { - const metadataStr = await this.storageManager.get(key); - if (metadataStr) { - const metadata = JSON.parse(metadataStr) as IDkimKeyMetadata; - - // Check if key is rotated and past grace period - if (metadata.rotatedAt) { - const gracePeriodMs = gracePeriodDays * 24 * 60 * 60 * 1000; - const now = Date.now(); - - if (now - metadata.rotatedAt > gracePeriodMs) { - console.log(`Cleaning up old DKIM keys for ${domain} selector ${metadata.selector}`); - - // Delete key files - const keyPaths = await this.getKeyPathsForSelector(domain, metadata.selector); - try { - await plugins.fs.promises.unlink(keyPaths.privateKeyPath); - await plugins.fs.promises.unlink(keyPaths.publicKeyPath); - } catch (error) { - console.warn(`Failed to delete old key files: ${error.message}`); - } - - // Delete metadata - await this.storageManager.delete(key); - } - } - } - } - } - } -} \ No newline at end of file diff --git a/ts/mail/security/classes.dkimverifier.ts b/ts/mail/security/classes.dkimverifier.ts deleted file mode 100644 index fca2622..0000000 --- a/ts/mail/security/classes.dkimverifier.ts +++ /dev/null @@ -1,380 +0,0 @@ -import * as plugins from '../../plugins.js'; -// MtaService reference removed -import { logger } from '../../logger.js'; -import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js'; - -/** - * Result of a DKIM verification - */ -export interface IDkimVerificationResult { - isValid: boolean; - domain?: string; - selector?: string; - status?: string; - details?: any; - errorMessage?: string; - signatureFields?: Record; -} - -/** - * Enhanced DKIM verifier using smartmail capabilities - */ -export class DKIMVerifier { - // MtaRef reference removed - - // Cache verified results to avoid repeated verification - private verificationCache: Map = new Map(); - private cacheTtl = 30 * 60 * 1000; // 30 minutes cache - - constructor() { - } - - /** - * Verify DKIM signature for an email - * @param emailData The raw email data - * @param options Verification options - * @returns Verification result - */ - public async verify( - emailData: string, - options: { - useCache?: boolean; - returnDetails?: boolean; - } = {} - ): Promise { - try { - // Generate a cache key from the first 128 bytes of the email data - const cacheKey = emailData.slice(0, 128); - - // Check cache if enabled - if (options.useCache !== false) { - const cached = this.verificationCache.get(cacheKey); - - if (cached && (Date.now() - cached.timestamp) < this.cacheTtl) { - logger.log('info', 'DKIM verification result from cache'); - return cached.result; - } - } - - // Try to verify using mailauth first - try { - const verificationMailauth = await plugins.mailauth.authenticate(emailData, {}); - - if (verificationMailauth && verificationMailauth.dkim && verificationMailauth.dkim.results.length > 0) { - const dkimResult = verificationMailauth.dkim.results[0]; - const isValid = dkimResult.status.result === 'pass'; - - const result: IDkimVerificationResult = { - isValid, - domain: dkimResult.signingDomain, - selector: dkimResult.selector, - status: dkimResult.status.result, - details: options.returnDetails ? verificationMailauth : undefined - }; - - // Cache the result - this.verificationCache.set(cacheKey, { - result, - timestamp: Date.now() - }); - - logger.log(isValid ? 'info' : 'warn', `DKIM Verification using mailauth: ${isValid ? 'pass' : 'fail'} for domain ${dkimResult.signingDomain}`); - - // Enhanced security logging - SecurityLogger.getInstance().logEvent({ - level: isValid ? SecurityLogLevel.INFO : SecurityLogLevel.WARN, - type: SecurityEventType.DKIM, - message: `DKIM verification ${isValid ? 'passed' : 'failed'} for domain ${dkimResult.signingDomain}`, - details: { - selector: dkimResult.selector, - result: dkimResult.status.result - }, - domain: dkimResult.signingDomain, - success: isValid - }); - - return result; - } - } catch (mailauthError) { - logger.log('warn', `DKIM verification with mailauth failed, trying smartmail: ${mailauthError.message}`); - - // Enhanced security logging - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.WARN, - type: SecurityEventType.DKIM, - message: `DKIM verification with mailauth failed, trying smartmail fallback`, - details: { error: mailauthError.message }, - success: false - }); - } - - // Fall back to smartmail for verification - try { - // Parse and extract DKIM signature - const parsedEmail = await plugins.mailparser.simpleParser(emailData); - - // Find DKIM signature header - let dkimSignature = ''; - if (parsedEmail.headers.has('dkim-signature')) { - dkimSignature = parsedEmail.headers.get('dkim-signature') as string; - } else { - // No DKIM signature found - const result: IDkimVerificationResult = { - isValid: false, - errorMessage: 'No DKIM signature found' - }; - - this.verificationCache.set(cacheKey, { - result, - timestamp: Date.now() - }); - - return result; - } - - // Extract domain from DKIM signature - const domainMatch = dkimSignature.match(/d=([^;]+)/i); - const domain = domainMatch ? domainMatch[1].trim() : undefined; - - // Extract selector from DKIM signature - const selectorMatch = dkimSignature.match(/s=([^;]+)/i); - const selector = selectorMatch ? selectorMatch[1].trim() : undefined; - - // Parse DKIM fields - const signatureFields: Record = {}; - const fieldMatches = dkimSignature.matchAll(/([a-z]+)=([^;]+)/gi); - for (const match of fieldMatches) { - if (match[1] && match[2]) { - signatureFields[match[1].toLowerCase()] = match[2].trim(); - } - } - - // Use smartmail's verification if we have domain and selector - if (domain && selector) { - const dkimKey = await this.fetchDkimKey(domain, selector); - - if (!dkimKey) { - const result: IDkimVerificationResult = { - isValid: false, - domain, - selector, - status: 'permerror', - errorMessage: 'DKIM public key not found', - signatureFields - }; - - this.verificationCache.set(cacheKey, { - result, - timestamp: Date.now() - }); - - return result; - } - - // In a real implementation, we would validate the signature here - // For now, if we found a key, we'll consider it valid - // In a future update, add actual crypto verification - - const result: IDkimVerificationResult = { - isValid: true, - domain, - selector, - status: 'pass', - signatureFields - }; - - this.verificationCache.set(cacheKey, { - result, - timestamp: Date.now() - }); - - logger.log('info', `DKIM verification using smartmail: pass for domain ${domain}`); - - // Enhanced security logging - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.INFO, - type: SecurityEventType.DKIM, - message: `DKIM verification passed for domain ${domain} using fallback verification`, - details: { - selector, - signatureFields - }, - domain, - success: true - }); - - return result; - } else { - // Missing domain or selector - const result: IDkimVerificationResult = { - isValid: false, - domain, - selector, - status: 'permerror', - errorMessage: 'Missing domain or selector in DKIM signature', - signatureFields - }; - - this.verificationCache.set(cacheKey, { - result, - timestamp: Date.now() - }); - - logger.log('warn', `DKIM verification failed: Missing domain or selector in DKIM signature`); - - // Enhanced security logging - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.WARN, - type: SecurityEventType.DKIM, - message: `DKIM verification failed: Missing domain or selector in signature`, - details: { domain, selector, signatureFields }, - domain: domain || 'unknown', - success: false - }); - - return result; - } - } catch (error) { - const result: IDkimVerificationResult = { - isValid: false, - status: 'temperror', - errorMessage: `Verification error: ${error.message}` - }; - - this.verificationCache.set(cacheKey, { - result, - timestamp: Date.now() - }); - - logger.log('error', `DKIM verification error: ${error.message}`); - - // Enhanced security logging - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.DKIM, - message: `DKIM verification error during processing`, - details: { error: error.message }, - success: false - }); - - return result; - } - } catch (error) { - logger.log('error', `DKIM verification failed with unexpected error: ${error.message}`); - - // Enhanced security logging for unexpected errors - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.DKIM, - message: `DKIM verification failed with unexpected error`, - details: { error: error.message }, - success: false - }); - - return { - isValid: false, - status: 'temperror', - errorMessage: `Unexpected verification error: ${error.message}` - }; - } - } - - /** - * Fetch DKIM public key from DNS - * @param domain The domain - * @param selector The DKIM selector - * @returns The DKIM public key or null if not found - */ - private async fetchDkimKey(domain: string, selector: string): Promise { - try { - const dkimRecord = `${selector}._domainkey.${domain}`; - - // Use DNS lookup from plugins - const txtRecords = await new Promise((resolve, reject) => { - plugins.dns.resolveTxt(dkimRecord, (err, records) => { - if (err) { - if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') { - resolve([]); - } else { - reject(err); - } - return; - } - // Flatten the arrays that resolveTxt returns - resolve(records.map(record => record.join(''))); - }); - }); - - if (!txtRecords || txtRecords.length === 0) { - logger.log('warn', `No DKIM TXT record found for ${dkimRecord}`); - - // Security logging for missing DKIM record - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.WARN, - type: SecurityEventType.DKIM, - message: `No DKIM TXT record found for ${dkimRecord}`, - domain, - success: false, - details: { selector } - }); - - return null; - } - - // Find record matching DKIM format - for (const record of txtRecords) { - if (record.includes('p=')) { - // Extract public key - const publicKeyMatch = record.match(/p=([^;]+)/i); - if (publicKeyMatch && publicKeyMatch[1]) { - return publicKeyMatch[1].trim(); - } - } - } - - logger.log('warn', `No valid DKIM public key found in TXT records for ${dkimRecord}`); - - // Security logging for invalid DKIM key - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.WARN, - type: SecurityEventType.DKIM, - message: `No valid DKIM public key found in TXT records`, - domain, - success: false, - details: { dkimRecord, selector } - }); - - return null; - } catch (error) { - logger.log('error', `Error fetching DKIM key: ${error.message}`); - - // Security logging for DKIM key fetch error - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.DKIM, - message: `Error fetching DKIM key for domain`, - domain, - success: false, - details: { error: error.message, selector, dkimRecord: `${selector}._domainkey.${domain}` } - }); - - return null; - } - } - - /** - * Clear the verification cache - */ - public clearCache(): void { - this.verificationCache.clear(); - logger.log('info', 'DKIM verification cache cleared'); - } - - /** - * Get the size of the verification cache - * @returns Number of cached items - */ - public getCacheSize(): number { - return this.verificationCache.size; - } -} \ No newline at end of file diff --git a/ts/mail/security/classes.dmarcverifier.ts b/ts/mail/security/classes.dmarcverifier.ts deleted file mode 100644 index ebdd3d3..0000000 --- a/ts/mail/security/classes.dmarcverifier.ts +++ /dev/null @@ -1,478 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { logger } from '../../logger.js'; -import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js'; -// MtaService reference removed -import type { Email } from '../core/classes.email.js'; -import type { IDnsVerificationResult } from '../routing/classes.dnsmanager.js'; - -/** - * DMARC policy types - */ -export enum DmarcPolicy { - NONE = 'none', - QUARANTINE = 'quarantine', - REJECT = 'reject' -} - -/** - * DMARC alignment modes - */ -export enum DmarcAlignment { - RELAXED = 'r', - STRICT = 's' -} - -/** - * DMARC record fields - */ -export interface DmarcRecord { - // Required fields - version: string; - policy: DmarcPolicy; - - // Optional fields - subdomainPolicy?: DmarcPolicy; - pct?: number; - adkim?: DmarcAlignment; - aspf?: DmarcAlignment; - reportInterval?: number; - failureOptions?: string; - reportUriAggregate?: string[]; - reportUriForensic?: string[]; -} - -/** - * DMARC verification result - */ -export interface DmarcResult { - hasDmarc: boolean; - record?: DmarcRecord; - spfDomainAligned: boolean; - dkimDomainAligned: boolean; - spfPassed: boolean; - dkimPassed: boolean; - policyEvaluated: DmarcPolicy; - actualPolicy: DmarcPolicy; - appliedPercentage: number; - action: 'pass' | 'quarantine' | 'reject'; - details: string; - error?: string; -} - -/** - * Class for verifying and enforcing DMARC policies - */ -export class DmarcVerifier { - // DNS Manager reference for verifying records - private dnsManager?: any; - - constructor(dnsManager?: any) { - this.dnsManager = dnsManager; - } - - /** - * Parse a DMARC record from a TXT record string - * @param record DMARC TXT record string - * @returns Parsed DMARC record or null if invalid - */ - public parseDmarcRecord(record: string): DmarcRecord | null { - if (!record.startsWith('v=DMARC1')) { - return null; - } - - try { - // Initialize record with default values - const dmarcRecord: DmarcRecord = { - version: 'DMARC1', - policy: DmarcPolicy.NONE, - pct: 100, - adkim: DmarcAlignment.RELAXED, - aspf: DmarcAlignment.RELAXED - }; - - // Split the record into tag/value pairs - const parts = record.split(';').map(part => part.trim()); - - for (const part of parts) { - if (!part || !part.includes('=')) continue; - - const [tag, value] = part.split('=').map(p => p.trim()); - - // Process based on tag - switch (tag.toLowerCase()) { - case 'v': - dmarcRecord.version = value; - break; - case 'p': - dmarcRecord.policy = value as DmarcPolicy; - break; - case 'sp': - dmarcRecord.subdomainPolicy = value as DmarcPolicy; - break; - case 'pct': - const pctValue = parseInt(value, 10); - if (!isNaN(pctValue) && pctValue >= 0 && pctValue <= 100) { - dmarcRecord.pct = pctValue; - } - break; - case 'adkim': - dmarcRecord.adkim = value as DmarcAlignment; - break; - case 'aspf': - dmarcRecord.aspf = value as DmarcAlignment; - break; - case 'ri': - const interval = parseInt(value, 10); - if (!isNaN(interval) && interval > 0) { - dmarcRecord.reportInterval = interval; - } - break; - case 'fo': - dmarcRecord.failureOptions = value; - break; - case 'rua': - dmarcRecord.reportUriAggregate = value.split(',').map(uri => { - if (uri.startsWith('mailto:')) { - return uri.substring(7).trim(); - } - return uri.trim(); - }); - break; - case 'ruf': - dmarcRecord.reportUriForensic = value.split(',').map(uri => { - if (uri.startsWith('mailto:')) { - return uri.substring(7).trim(); - } - return uri.trim(); - }); - break; - } - } - - // Ensure subdomain policy is set if not explicitly provided - if (!dmarcRecord.subdomainPolicy) { - dmarcRecord.subdomainPolicy = dmarcRecord.policy; - } - - return dmarcRecord; - } catch (error) { - logger.log('error', `Error parsing DMARC record: ${error.message}`, { - record, - error: error.message - }); - return null; - } - } - - /** - * Check if domains are aligned according to DMARC policy - * @param headerDomain Domain from header (From) - * @param authDomain Domain from authentication (SPF, DKIM) - * @param alignment Alignment mode - * @returns Whether the domains are aligned - */ - private isDomainAligned( - headerDomain: string, - authDomain: string, - alignment: DmarcAlignment - ): boolean { - if (!headerDomain || !authDomain) { - return false; - } - - // For strict alignment, domains must match exactly - if (alignment === DmarcAlignment.STRICT) { - return headerDomain.toLowerCase() === authDomain.toLowerCase(); - } - - // For relaxed alignment, the authenticated domain must be a subdomain of the header domain - // or the same as the header domain - const headerParts = headerDomain.toLowerCase().split('.'); - const authParts = authDomain.toLowerCase().split('.'); - - // Ensures we have at least two parts (domain and TLD) - if (headerParts.length < 2 || authParts.length < 2) { - return false; - } - - // Get organizational domain (last two parts) - const headerOrgDomain = headerParts.slice(-2).join('.'); - const authOrgDomain = authParts.slice(-2).join('.'); - - return headerOrgDomain === authOrgDomain; - } - - /** - * Extract domain from an email address - * @param email Email address - * @returns Domain part of the email - */ - private getDomainFromEmail(email: string): string { - if (!email) return ''; - - // Handle name + email format: "John Doe " - const matches = email.match(/<([^>]+)>/); - const address = matches ? matches[1] : email; - - const parts = address.split('@'); - return parts.length > 1 ? parts[1] : ''; - } - - /** - * Check if DMARC verification should be applied based on percentage - * @param record DMARC record - * @returns Whether DMARC verification should be applied - */ - private shouldApplyDmarc(record: DmarcRecord): boolean { - if (record.pct === undefined || record.pct === 100) { - return true; - } - - // Apply DMARC randomly based on percentage - const random = Math.floor(Math.random() * 100) + 1; - return random <= record.pct; - } - - /** - * Determine the action to take based on DMARC policy - * @param policy DMARC policy - * @returns Action to take - */ - private determineAction(policy: DmarcPolicy): 'pass' | 'quarantine' | 'reject' { - switch (policy) { - case DmarcPolicy.REJECT: - return 'reject'; - case DmarcPolicy.QUARANTINE: - return 'quarantine'; - case DmarcPolicy.NONE: - default: - return 'pass'; - } - } - - /** - * Verify DMARC for an incoming email - * @param email Email to verify - * @param spfResult SPF verification result - * @param dkimResult DKIM verification result - * @returns DMARC verification result - */ - public async verify( - email: Email, - spfResult: { domain: string; result: boolean }, - dkimResult: { domain: string; result: boolean } - ): Promise { - const securityLogger = SecurityLogger.getInstance(); - - // Initialize result - const result: DmarcResult = { - hasDmarc: false, - spfDomainAligned: false, - dkimDomainAligned: false, - spfPassed: spfResult.result, - dkimPassed: dkimResult.result, - policyEvaluated: DmarcPolicy.NONE, - actualPolicy: DmarcPolicy.NONE, - appliedPercentage: 100, - action: 'pass', - details: 'DMARC not configured' - }; - - try { - // Extract From domain - const fromHeader = email.getFromEmail(); - const fromDomain = this.getDomainFromEmail(fromHeader); - - if (!fromDomain) { - result.error = 'Invalid From domain'; - return result; - } - - // Check alignment - result.spfDomainAligned = this.isDomainAligned( - fromDomain, - spfResult.domain, - DmarcAlignment.RELAXED - ); - - result.dkimDomainAligned = this.isDomainAligned( - fromDomain, - dkimResult.domain, - DmarcAlignment.RELAXED - ); - - // Lookup DMARC record - const dmarcVerificationResult = this.dnsManager ? - await this.dnsManager.verifyDmarcRecord(fromDomain) : - { found: false, valid: false, error: 'DNS Manager not available' }; - - // If DMARC record exists and is valid - if (dmarcVerificationResult.found && dmarcVerificationResult.valid) { - result.hasDmarc = true; - - // Parse DMARC record - const parsedRecord = this.parseDmarcRecord(dmarcVerificationResult.value); - - if (parsedRecord) { - result.record = parsedRecord; - result.actualPolicy = parsedRecord.policy; - result.appliedPercentage = parsedRecord.pct || 100; - - // Override alignment modes if specified in record - if (parsedRecord.adkim) { - result.dkimDomainAligned = this.isDomainAligned( - fromDomain, - dkimResult.domain, - parsedRecord.adkim - ); - } - - if (parsedRecord.aspf) { - result.spfDomainAligned = this.isDomainAligned( - fromDomain, - spfResult.domain, - parsedRecord.aspf - ); - } - - // Determine DMARC compliance - const spfAligned = result.spfPassed && result.spfDomainAligned; - const dkimAligned = result.dkimPassed && result.dkimDomainAligned; - - // Email passes DMARC if either SPF or DKIM passes with alignment - const dmarcPass = spfAligned || dkimAligned; - - // Use record percentage to determine if policy should be applied - const applyPolicy = this.shouldApplyDmarc(parsedRecord); - - if (!dmarcPass) { - // DMARC failed, apply policy - result.policyEvaluated = applyPolicy ? parsedRecord.policy : DmarcPolicy.NONE; - result.action = this.determineAction(result.policyEvaluated); - result.details = `DMARC failed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}, policy=${result.policyEvaluated}`; - } else { - result.policyEvaluated = DmarcPolicy.NONE; - result.action = 'pass'; - result.details = `DMARC passed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}`; - } - } else { - result.error = 'Invalid DMARC record format'; - result.details = 'DMARC record invalid'; - } - } else { - // No DMARC record found or invalid - result.details = dmarcVerificationResult.error || 'No DMARC record found'; - } - - // Log the DMARC verification - securityLogger.logEvent({ - level: result.action === 'pass' ? SecurityLogLevel.INFO : SecurityLogLevel.WARN, - type: SecurityEventType.DMARC, - message: result.details, - domain: fromDomain, - details: { - fromDomain, - spfDomain: spfResult.domain, - dkimDomain: dkimResult.domain, - spfPassed: result.spfPassed, - dkimPassed: result.dkimPassed, - spfAligned: result.spfDomainAligned, - dkimAligned: result.dkimDomainAligned, - dmarcPolicy: result.policyEvaluated, - action: result.action - }, - success: result.action === 'pass' - }); - - return result; - } catch (error) { - logger.log('error', `Error verifying DMARC: ${error.message}`, { - error: error.message, - emailId: email.getMessageId() - }); - - result.error = `DMARC verification error: ${error.message}`; - - // Log error - securityLogger.logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.DMARC, - message: `DMARC verification failed with error`, - details: { - error: error.message, - emailId: email.getMessageId() - }, - success: false - }); - - return result; - } - } - - /** - * Apply DMARC policy to an email - * @param email Email to apply policy to - * @param dmarcResult DMARC verification result - * @returns Whether the email should be accepted - */ - public applyPolicy(email: Email, dmarcResult: DmarcResult): boolean { - // Apply action based on DMARC verification result - switch (dmarcResult.action) { - case 'reject': - // Reject the email - email.mightBeSpam = true; - logger.log('warn', `Email rejected due to DMARC policy: ${dmarcResult.details}`, { - emailId: email.getMessageId(), - from: email.getFromEmail(), - subject: email.subject - }); - return false; - - case 'quarantine': - // Quarantine the email (mark as spam) - email.mightBeSpam = true; - - // Add spam header - if (!email.headers['X-Spam-Flag']) { - email.headers['X-Spam-Flag'] = 'YES'; - } - - // Add DMARC reason header - email.headers['X-DMARC-Result'] = dmarcResult.details; - - logger.log('warn', `Email quarantined due to DMARC policy: ${dmarcResult.details}`, { - emailId: email.getMessageId(), - from: email.getFromEmail(), - subject: email.subject - }); - return true; - - case 'pass': - default: - // Accept the email - // Add DMARC result header for information - email.headers['X-DMARC-Result'] = dmarcResult.details; - return true; - } - } - - /** - * End-to-end DMARC verification and policy application - * This method should be called after SPF and DKIM verification - * @param email Email to verify - * @param spfResult SPF verification result - * @param dkimResult DKIM verification result - * @returns Whether the email should be accepted - */ - public async verifyAndApply( - email: Email, - spfResult: { domain: string; result: boolean }, - dkimResult: { domain: string; result: boolean } - ): Promise { - // Verify DMARC - const dmarcResult = await this.verify(email, spfResult, dkimResult); - - // Apply DMARC policy - return this.applyPolicy(email, dmarcResult); - } -} \ No newline at end of file diff --git a/ts/mail/security/classes.spfverifier.ts b/ts/mail/security/classes.spfverifier.ts deleted file mode 100644 index 6f7c15d..0000000 --- a/ts/mail/security/classes.spfverifier.ts +++ /dev/null @@ -1,606 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { logger } from '../../logger.js'; -import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js'; -// MtaService reference removed -import type { Email } from '../core/classes.email.js'; -import type { IDnsVerificationResult } from '../routing/classes.dnsmanager.js'; - -/** - * SPF result qualifiers - */ -export enum SpfQualifier { - PASS = '+', - NEUTRAL = '?', - SOFTFAIL = '~', - FAIL = '-' -} - -/** - * SPF mechanism types - */ -export enum SpfMechanismType { - ALL = 'all', - INCLUDE = 'include', - A = 'a', - MX = 'mx', - IP4 = 'ip4', - IP6 = 'ip6', - EXISTS = 'exists', - REDIRECT = 'redirect', - EXP = 'exp' -} - -/** - * SPF mechanism definition - */ -export interface SpfMechanism { - qualifier: SpfQualifier; - type: SpfMechanismType; - value?: string; -} - -/** - * SPF record parsed data - */ -export interface SpfRecord { - version: string; - mechanisms: SpfMechanism[]; - modifiers: Record; -} - -/** - * SPF verification result - */ -export interface SpfResult { - result: 'pass' | 'neutral' | 'softfail' | 'fail' | 'temperror' | 'permerror' | 'none'; - explanation?: string; - domain: string; - ip: string; - record?: string; - error?: string; -} - -/** - * Maximum lookup limit for SPF records (prevent infinite loops) - */ -const MAX_SPF_LOOKUPS = 10; - -/** - * Class for verifying SPF records - */ -export class SpfVerifier { - // DNS Manager reference for verifying records - private dnsManager?: any; - private lookupCount: number = 0; - - constructor(dnsManager?: any) { - this.dnsManager = dnsManager; - } - - /** - * Parse SPF record from TXT record - * @param record SPF TXT record - * @returns Parsed SPF record or null if invalid - */ - public parseSpfRecord(record: string): SpfRecord | null { - if (!record.startsWith('v=spf1')) { - return null; - } - - try { - const spfRecord: SpfRecord = { - version: 'spf1', - mechanisms: [], - modifiers: {} - }; - - // Split into terms - const terms = record.split(' ').filter(term => term.length > 0); - - // Skip version term - for (let i = 1; i < terms.length; i++) { - const term = terms[i]; - - // Check if it's a modifier (name=value) - if (term.includes('=')) { - const [name, value] = term.split('='); - spfRecord.modifiers[name] = value; - continue; - } - - // Parse as mechanism - let qualifier = SpfQualifier.PASS; // Default is + - let mechanismText = term; - - // Check for qualifier - if (term.startsWith('+') || term.startsWith('-') || - term.startsWith('~') || term.startsWith('?')) { - qualifier = term[0] as SpfQualifier; - mechanismText = term.substring(1); - } - - // Parse mechanism type and value - const colonIndex = mechanismText.indexOf(':'); - let type: SpfMechanismType; - let value: string | undefined; - - if (colonIndex !== -1) { - type = mechanismText.substring(0, colonIndex) as SpfMechanismType; - value = mechanismText.substring(colonIndex + 1); - } else { - type = mechanismText as SpfMechanismType; - } - - spfRecord.mechanisms.push({ qualifier, type, value }); - } - - return spfRecord; - } catch (error) { - logger.log('error', `Error parsing SPF record: ${error.message}`, { - record, - error: error.message - }); - return null; - } - } - - /** - * Check if IP is in CIDR range - * @param ip IP address to check - * @param cidr CIDR range - * @returns Whether the IP is in the CIDR range - */ - private isIpInCidr(ip: string, cidr: string): boolean { - try { - const ipAddress = plugins.ip.Address4.parse(ip); - return ipAddress.isInSubnet(new plugins.ip.Address4(cidr)); - } catch (error) { - // Try IPv6 - try { - const ipAddress = plugins.ip.Address6.parse(ip); - return ipAddress.isInSubnet(new plugins.ip.Address6(cidr)); - } catch (e) { - return false; - } - } - } - - /** - * Check if a domain has the specified IP in its A or AAAA records - * @param domain Domain to check - * @param ip IP address to check - * @returns Whether the domain resolves to the IP - */ - private async isDomainResolvingToIp(domain: string, ip: string): Promise { - try { - // First try IPv4 - const ipv4Addresses = await plugins.dns.promises.resolve4(domain); - if (ipv4Addresses.includes(ip)) { - return true; - } - - // Then try IPv6 - const ipv6Addresses = await plugins.dns.promises.resolve6(domain); - if (ipv6Addresses.includes(ip)) { - return true; - } - - return false; - } catch (error) { - return false; - } - } - - /** - * Verify SPF for a given email with IP and helo domain - * @param email Email to verify - * @param ip Sender IP address - * @param heloDomain HELO/EHLO domain used by sender - * @returns SPF verification result - */ - public async verify( - email: Email, - ip: string, - heloDomain: string - ): Promise { - const securityLogger = SecurityLogger.getInstance(); - - // Reset lookup count - this.lookupCount = 0; - - // Get domain from envelope from (return-path) - const domain = email.getEnvelopeFrom().split('@')[1] || ''; - - if (!domain) { - return { - result: 'permerror', - explanation: 'No envelope from domain', - domain: '', - ip - }; - } - - try { - // Look up SPF record - const spfVerificationResult = this.dnsManager ? - await this.dnsManager.verifySpfRecord(domain) : - { found: false, valid: false, error: 'DNS Manager not available' }; - - if (!spfVerificationResult.found) { - return { - result: 'none', - explanation: 'No SPF record found', - domain, - ip - }; - } - - if (!spfVerificationResult.valid) { - return { - result: 'permerror', - explanation: 'Invalid SPF record', - domain, - ip, - record: spfVerificationResult.value - }; - } - - // Parse SPF record - const spfRecord = this.parseSpfRecord(spfVerificationResult.value); - - if (!spfRecord) { - return { - result: 'permerror', - explanation: 'Failed to parse SPF record', - domain, - ip, - record: spfVerificationResult.value - }; - } - - // Check SPF record - const result = await this.checkSpfRecord(spfRecord, domain, ip); - - // Log the result - const spfLogLevel = result.result === 'pass' ? - SecurityLogLevel.INFO : - (result.result === 'fail' ? SecurityLogLevel.WARN : SecurityLogLevel.INFO); - - securityLogger.logEvent({ - level: spfLogLevel, - type: SecurityEventType.SPF, - message: `SPF ${result.result} for ${domain} from IP ${ip}`, - domain, - details: { - ip, - heloDomain, - result: result.result, - explanation: result.explanation, - record: spfVerificationResult.value - }, - success: result.result === 'pass' - }); - - return { - ...result, - domain, - ip, - record: spfVerificationResult.value - }; - } catch (error) { - // Log error - logger.log('error', `SPF verification error: ${error.message}`, { - domain, - ip, - error: error.message - }); - - securityLogger.logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.SPF, - message: `SPF verification error for ${domain}`, - domain, - details: { - ip, - error: error.message - }, - success: false - }); - - return { - result: 'temperror', - explanation: `Error verifying SPF: ${error.message}`, - domain, - ip, - error: error.message - }; - } - } - - /** - * Check SPF record against IP address - * @param spfRecord Parsed SPF record - * @param domain Domain being checked - * @param ip IP address to check - * @returns SPF result - */ - private async checkSpfRecord( - spfRecord: SpfRecord, - domain: string, - ip: string - ): Promise { - // Check for 'redirect' modifier - if (spfRecord.modifiers.redirect) { - this.lookupCount++; - - if (this.lookupCount > MAX_SPF_LOOKUPS) { - return { - result: 'permerror', - explanation: 'Too many DNS lookups', - domain, - ip - }; - } - - // Handle redirect - const redirectDomain = spfRecord.modifiers.redirect; - const redirectResult = this.dnsManager ? - await this.dnsManager.verifySpfRecord(redirectDomain) : - { found: false, valid: false, error: 'DNS Manager not available' }; - - if (!redirectResult.found || !redirectResult.valid) { - return { - result: 'permerror', - explanation: `Invalid redirect to ${redirectDomain}`, - domain, - ip - }; - } - - const redirectRecord = this.parseSpfRecord(redirectResult.value); - - if (!redirectRecord) { - return { - result: 'permerror', - explanation: `Failed to parse redirect record from ${redirectDomain}`, - domain, - ip - }; - } - - return this.checkSpfRecord(redirectRecord, redirectDomain, ip); - } - - // Check each mechanism in order - for (const mechanism of spfRecord.mechanisms) { - let matched = false; - - switch (mechanism.type) { - case SpfMechanismType.ALL: - matched = true; - break; - - case SpfMechanismType.IP4: - if (mechanism.value) { - matched = this.isIpInCidr(ip, mechanism.value); - } - break; - - case SpfMechanismType.IP6: - if (mechanism.value) { - matched = this.isIpInCidr(ip, mechanism.value); - } - break; - - case SpfMechanismType.A: - this.lookupCount++; - - if (this.lookupCount > MAX_SPF_LOOKUPS) { - return { - result: 'permerror', - explanation: 'Too many DNS lookups', - domain, - ip - }; - } - - // Check if domain has A/AAAA record matching IP - const checkDomain = mechanism.value || domain; - matched = await this.isDomainResolvingToIp(checkDomain, ip); - break; - - case SpfMechanismType.MX: - this.lookupCount++; - - if (this.lookupCount > MAX_SPF_LOOKUPS) { - return { - result: 'permerror', - explanation: 'Too many DNS lookups', - domain, - ip - }; - } - - // Check MX records - const mxDomain = mechanism.value || domain; - - try { - const mxRecords = await plugins.dns.promises.resolveMx(mxDomain); - - for (const mx of mxRecords) { - // Check if this MX record's IP matches - const mxMatches = await this.isDomainResolvingToIp(mx.exchange, ip); - - if (mxMatches) { - matched = true; - break; - } - } - } catch (error) { - // No MX records or error - matched = false; - } - break; - - case SpfMechanismType.INCLUDE: - if (!mechanism.value) { - continue; - } - - this.lookupCount++; - - if (this.lookupCount > MAX_SPF_LOOKUPS) { - return { - result: 'permerror', - explanation: 'Too many DNS lookups', - domain, - ip - }; - } - - // Check included domain's SPF record - const includeDomain = mechanism.value; - const includeResult = this.dnsManager ? - await this.dnsManager.verifySpfRecord(includeDomain) : - { found: false, valid: false, error: 'DNS Manager not available' }; - - if (!includeResult.found || !includeResult.valid) { - continue; // Skip this mechanism - } - - const includeRecord = this.parseSpfRecord(includeResult.value); - - if (!includeRecord) { - continue; // Skip this mechanism - } - - // Recursively check the included SPF record - const includeCheck = await this.checkSpfRecord(includeRecord, includeDomain, ip); - - // Include mechanism matches if the result is "pass" - matched = includeCheck.result === 'pass'; - break; - - case SpfMechanismType.EXISTS: - if (!mechanism.value) { - continue; - } - - this.lookupCount++; - - if (this.lookupCount > MAX_SPF_LOOKUPS) { - return { - result: 'permerror', - explanation: 'Too many DNS lookups', - domain, - ip - }; - } - - // Check if domain exists (has any A record) - try { - await plugins.dns.promises.resolve(mechanism.value, 'A'); - matched = true; - } catch (error) { - matched = false; - } - break; - } - - // If this mechanism matched, return its result - if (matched) { - switch (mechanism.qualifier) { - case SpfQualifier.PASS: - return { - result: 'pass', - explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`, - domain, - ip - }; - case SpfQualifier.FAIL: - return { - result: 'fail', - explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`, - domain, - ip - }; - case SpfQualifier.SOFTFAIL: - return { - result: 'softfail', - explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`, - domain, - ip - }; - case SpfQualifier.NEUTRAL: - return { - result: 'neutral', - explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`, - domain, - ip - }; - } - } - } - - // If no mechanism matched, default to neutral - return { - result: 'neutral', - explanation: 'No matching mechanism found', - domain, - ip - }; - } - - /** - * Check if email passes SPF verification - * @param email Email to verify - * @param ip Sender IP address - * @param heloDomain HELO/EHLO domain used by sender - * @returns Whether email passes SPF - */ - public async verifyAndApply( - email: Email, - ip: string, - heloDomain: string - ): Promise { - const result = await this.verify(email, ip, heloDomain); - - // Add headers - email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`; - - // Apply policy based on result - switch (result.result) { - case 'fail': - // Fail - mark as spam - email.mightBeSpam = true; - logger.log('warn', `SPF failed for ${result.domain} from ${ip}: ${result.explanation}`); - return false; - - case 'softfail': - // Soft fail - accept but mark as suspicious - email.mightBeSpam = true; - logger.log('info', `SPF softfailed for ${result.domain} from ${ip}: ${result.explanation}`); - return true; - - case 'neutral': - case 'none': - // Neutral or none - accept but note in headers - logger.log('info', `SPF ${result.result} for ${result.domain} from ${ip}: ${result.explanation}`); - return true; - - case 'pass': - // Pass - accept - logger.log('info', `SPF passed for ${result.domain} from ${ip}: ${result.explanation}`); - return true; - - case 'temperror': - case 'permerror': - // Temporary or permanent error - log but accept - logger.log('error', `SPF error for ${result.domain} from ${ip}: ${result.explanation}`); - return true; - - default: - return true; - } - } -} \ No newline at end of file diff --git a/ts/mail/security/index.ts b/ts/mail/security/index.ts deleted file mode 100644 index a6dcee8..0000000 --- a/ts/mail/security/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Email security components -export * from './classes.dkimcreator.js'; -export * from './classes.dkimverifier.js'; -export * from './classes.dmarcverifier.js'; -export * from './classes.spfverifier.js'; \ No newline at end of file diff --git a/ts/opsserver/handlers/email-ops.handler.ts b/ts/opsserver/handlers/email-ops.handler.ts index bbe11d5..45f653d 100644 --- a/ts/opsserver/handlers/email-ops.handler.ts +++ b/ts/opsserver/handlers/email-ops.handler.ts @@ -177,34 +177,28 @@ export class EmailOpsHandler { async (dataArg) => { const emailServer = this.opsServerRef.dcRouterRef.emailServer; - // Get bounce manager from email server via reflection - // BounceManager is private but we need to access it - const bounceManager = (emailServer as any)?.bounceManager; - - if (!bounceManager) { + if (!emailServer) { return { records: [], suppressionList: [], total: 0 }; } - // Get suppression list - const suppressionList = bounceManager.getSuppressionList(); - - // Get hard bounced addresses and convert to records - const hardBouncedAddresses = bounceManager.getHardBouncedAddresses(); + // Use smartmta's public API for bounce/suppression data + const suppressionList = emailServer.getSuppressionList(); + const hardBouncedAddresses = emailServer.getHardBouncedAddresses(); // Create bounce records from the available data const records: interfaces.requests.IBounceRecord[] = []; for (const email of hardBouncedAddresses) { - const bounceInfo = bounceManager.getBounceInfo(email); + const bounceInfo = emailServer.getBounceHistory(email); if (bounceInfo) { records.push({ id: `bounce-${email}`, recipient: email, sender: '', domain: email.split('@')[1] || '', - bounceType: bounceInfo.type as interfaces.requests.TBounceType, - bounceCategory: bounceInfo.category as interfaces.requests.TBounceCategory, - timestamp: bounceInfo.lastBounce, + bounceType: (bounceInfo as any).type as interfaces.requests.TBounceType, + bounceCategory: (bounceInfo as any).category as interfaces.requests.TBounceCategory, + timestamp: (bounceInfo as any).lastBounce, processed: true, }); } @@ -230,14 +224,13 @@ export class EmailOpsHandler { 'removeFromSuppressionList', async (dataArg) => { const emailServer = this.opsServerRef.dcRouterRef.emailServer; - const bounceManager = (emailServer as any)?.bounceManager; - if (!bounceManager) { - return { success: false, error: 'Bounce manager not available' }; + if (!emailServer) { + return { success: false, error: 'Email server not available' }; } try { - bounceManager.removeFromSuppressionList(dataArg.email); + emailServer.removeFromSuppressionList(dataArg.email); return { success: true }; } catch (error) { return { diff --git a/ts/plugins.ts b/ts/plugins.ts index 9b0b086..69e991a 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -49,8 +49,8 @@ import * as smartfile from '@push.rocks/smartfile'; import * as smartguard from '@push.rocks/smartguard'; import * as smartjwt from '@push.rocks/smartjwt'; import * as smartlog from '@push.rocks/smartlog'; -import * as smartmail from '@push.rocks/smartmail'; import * as smartmetrics from '@push.rocks/smartmetrics'; +import * as smartmta from '@push.rocks/smartmta'; import * as smartmongo from '@push.rocks/smartmongo'; import * as smartnetwork from '@push.rocks/smartnetwork'; import * as smartpath from '@push.rocks/smartpath'; @@ -58,11 +58,10 @@ import * as smartproxy from '@push.rocks/smartproxy'; import * as smartpromise from '@push.rocks/smartpromise'; import * as smartradius from '@push.rocks/smartradius'; import * as smartrequest from '@push.rocks/smartrequest'; -import * as smartrule from '@push.rocks/smartrule'; import * as smartrx from '@push.rocks/smartrx'; import * as smartunique from '@push.rocks/smartunique'; -export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartmongo, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrule, smartrx, smartunique }; +export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmetrics, smartmongo, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique }; // Define SmartLog types for use in error handling export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug'; @@ -82,18 +81,10 @@ export { } // third party -import * as mailauth from 'mailauth'; -import { dkimSign } from 'mailauth/lib/dkim/sign.js'; -import mailparser from 'mailparser'; import * as uuid from 'uuid'; -import * as ip from 'ip'; export { - mailauth, - dkimSign, - mailparser, uuid, - ip, } // Filesystem utilities (compatibility helpers for smartfile v13+) diff --git a/ts/security/classes.contentscanner.ts b/ts/security/classes.contentscanner.ts index 9a4c5aa..faa6108 100644 --- a/ts/security/classes.contentscanner.ts +++ b/ts/security/classes.contentscanner.ts @@ -1,8 +1,8 @@ import * as plugins from '../plugins.js'; import * as paths from '../paths.js'; import { logger } from '../logger.js'; -import { Email } from '../mail/core/classes.email.js'; -import type { IAttachment } from '../mail/core/classes.email.js'; +import { Email, type Core } from '@push.rocks/smartmta'; +type IAttachment = Core.IAttachment; import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js'; import { LRUCache } from 'lru-cache'; diff --git a/ts_interfaces/readme.md b/ts_interfaces/readme.md index df42db0..60f1312 100644 --- a/ts_interfaces/readme.md +++ b/ts_interfaces/readme.md @@ -1,8 +1,8 @@ # @serve.zone/dcrouter-interfaces -TypeScript interfaces and type definitions for the DCRouter OpsServer API. 📡 +TypeScript interfaces and type definitions for the DcRouter OpsServer API. 📡 -This module provides strongly-typed interfaces for communicating with the DCRouter OpsServer via TypedRequest. Use these interfaces for type-safe API interactions in your frontend applications or integration code. +This module provides strongly-typed interfaces for communicating with the DcRouter OpsServer via [TypedRequest](https://code.foss.global/api.global/typedrequest). Use these interfaces for type-safe API interactions in your frontend applications or integration code. ## Issue Reporting and Security @@ -11,11 +11,15 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community ## Installation ```bash -npm install @serve.zone/dcrouter-interfaces --save -# or pnpm add @serve.zone/dcrouter-interfaces ``` +Or import directly from the main package: + +```typescript +import { data, requests } from '@serve.zone/dcrouter/interfaces'; +``` + ## Usage ```typescript @@ -31,112 +35,8 @@ const identity: data.IIdentity = { }; // Use request interfaces for API calls -const statsRequest: requests.IReq_GetServerStatistics = { - method: 'getServerStatistics', - request: { - identity, - includeHistory: true, - timeRange: '24h' - }, - response: null // Will be populated by the response -}; -``` - -## Module Structure - -### Data Interfaces (`data`) - -Core data types used throughout the DCRouter system: - -#### `IIdentity` -Authentication identity for API requests: -```typescript -interface IIdentity { - jwt: string; // JWT token for authentication - userId: string; // Unique user identifier - name: string; // Display name - expiresAt: number; // Token expiration timestamp - role?: string; // User role (e.g., 'admin') - type?: string; // Identity type -} -``` - -#### Statistics Interfaces -- `IServerStats` - Overall server statistics -- `IEmailStats` - Email throughput and delivery metrics -- `IDnsStats` - DNS query statistics -- `IRateLimitInfo` - Rate limiting status -- `ISecurityMetrics` - Security event metrics -- `IConnectionInfo` - Active connection details -- `IQueueStatus` - Email queue status -- `IHealthStatus` - System health information - -### Request Interfaces (`requests`) - -TypedRequest interfaces for the OpsServer API: - -#### Statistics Requests -| Interface | Method | Description | -|-----------|--------|-------------| -| `IReq_GetServerStatistics` | `getServerStatistics` | Get overall server stats | -| `IReq_GetEmailStatistics` | `getEmailStatistics` | Get email throughput stats | -| `IReq_GetDnsStatistics` | `getDnsStatistics` | Get DNS query stats | -| `IReq_GetRateLimitStatus` | `getRateLimitStatus` | Check rate limit status | -| `IReq_GetSecurityMetrics` | `getSecurityMetrics` | Get security event metrics | -| `IReq_GetActiveConnections` | `getActiveConnections` | List active connections | -| `IReq_GetQueueStatus` | `getQueueStatus` | Get email queue status | -| `IReq_GetHealthStatus` | `getHealthStatus` | System health check | - -#### Admin Requests -| Interface | Method | Description | -|-----------|--------|-------------| -| `IReq_AdminLogin` | `adminLogin` | Authenticate as admin | -| `IReq_AdminLogout` | `adminLogout` | End admin session | -| `IReq_VerifyIdentity` | `verifyIdentity` | Verify JWT token | - -#### Configuration Requests -| Interface | Method | Description | -|-----------|--------|-------------| -| `IReq_GetConfiguration` | `getConfiguration` | Get current config (read-only) | - -#### Log Requests -| Interface | Method | Description | -|-----------|--------|-------------| -| `IReq_GetLogs` | `getLogs` | Retrieve system logs | - -#### RADIUS Requests -| Interface | Method | Description | -|-----------|--------|-------------| -| `IReq_GetRadiusSessions` | `getRadiusSessions` | List RADIUS sessions | -| `IReq_GetRadiusClients` | `getRadiusClients` | List RADIUS clients | - -#### Email Operations -| Interface | Method | Description | -|-----------|--------|-------------| -| `IReq_GetEmailQueues` | `getEmailQueues` | Get email queue details | -| `IReq_RetryEmail` | `retryEmail` | Retry failed email | - -## Example: Complete API Integration - -```typescript import * as typedrequest from '@api.global/typedrequest'; -import { data, requests } from '@serve.zone/dcrouter-interfaces'; -// Create typed request client -const client = new typedrequest.TypedRequest( - 'https://your-dcrouter:3000/typedrequest', - 'adminLogin' -); - -// Login to get identity -const loginResponse = await client.fire({ - username: 'admin', - password: 'your-password' -}); - -const identity = loginResponse.identity; - -// Now use identity for authenticated requests const statsClient = new typedrequest.TypedRequest( 'https://your-dcrouter:3000/typedrequest', 'getServerStatistics' @@ -147,9 +47,140 @@ const stats = await statsClient.fire({ includeHistory: true, timeRange: '24h' }); +``` -console.log('Server stats:', stats.stats); -console.log('History:', stats.history); +## Module Structure + +### Data Interfaces (`data`) + +Core data types used throughout the DcRouter system: + +#### `IIdentity` +Authentication identity for API requests: +```typescript +interface IIdentity { + jwt: string; // JWT token + userId: string; // Unique user ID + name: string; // Display name + expiresAt: number; // Token expiration timestamp + role?: string; // User role (e.g., 'admin') + type?: string; // Identity type +} +``` + +#### Statistics Interfaces +| Interface | Description | +|-----------|-------------| +| `IServerStats` | Uptime, memory, CPU, connection counts | +| `IEmailStats` | Sent/received/bounced/queued/failed, delivery & bounce rates | +| `IDnsStats` | Total queries, cache hits/misses, query types | +| `IRateLimitInfo` | Domain rate limit status (current rate, limit, remaining) | +| `ISecurityMetrics` | Blocked IPs, spam/malware/phishing counts | +| `IConnectionInfo` | Connection ID, remote address, protocol, state, bytes | +| `IQueueStatus` | Queue name, size, processing/failed/retrying counts | +| `IHealthStatus` | Healthy flag, uptime, per-service status map | +| `INetworkMetrics` | Bandwidth, connection counts, top endpoints | +| `ILogEntry` | Timestamp, level, category, message, metadata | + +### Request Interfaces (`requests`) + +TypedRequest interfaces for the OpsServer API, organized by domain: + +#### 🔐 Authentication +| Interface | Method | Description | +|-----------|--------|-------------| +| `IReq_AdminLoginWithUsernameAndPassword` | `adminLogin` | Authenticate as admin | +| `IReq_AdminLogout` | `adminLogout` | End admin session | +| `IReq_VerifyIdentity` | `verifyIdentity` | Verify JWT token validity | + +#### 📊 Statistics +| Interface | Method | Description | +|-----------|--------|-------------| +| `IReq_GetServerStatistics` | `getServerStatistics` | Overall server stats | +| `IReq_GetEmailStatistics` | `getEmailStatistics` | Email throughput metrics | +| `IReq_GetDnsStatistics` | `getDnsStatistics` | DNS query stats | +| `IReq_GetRateLimitStatus` | `getRateLimitStatus` | Rate limit status | +| `IReq_GetSecurityMetrics` | `getSecurityMetrics` | Security event metrics | +| `IReq_GetActiveConnections` | `getActiveConnections` | Active connection list | +| `IReq_GetQueueStatus` | `getQueueStatus` | Email queue status | +| `IReq_GetHealthStatus` | `getHealthStatus` | System health check | +| `IReq_GetCombinedMetrics` | `getCombinedMetrics` | All metrics in one request | + +#### ⚙️ Configuration +| Interface | Method | Description | +|-----------|--------|-------------| +| `IReq_GetConfiguration` | `getConfiguration` | Current config (read-only) | + +#### 📜 Logs +| Interface | Method | Description | +|-----------|--------|-------------| +| `IReq_GetRecentLogs` | `getLogs` | Retrieve system logs | +| `IReq_GetLogStream` | `getLogStream` | Stream live logs | + +#### 📧 Email Operations +| Interface | Method | Description | +|-----------|--------|-------------| +| `IReq_GetQueuedEmails` | `getQueuedEmails` | List queued emails | +| `IReq_GetSentEmails` | `getSentEmails` | List delivered emails | +| `IReq_GetFailedEmails` | `getFailedEmails` | List failed emails | +| `IReq_ResendEmail` | `resendEmail` | Re-queue a failed email | +| `IReq_GetSecurityIncidents` | `getSecurityIncidents` | Security events | +| `IReq_GetBounceRecords` | `getBounceRecords` | Bounce records | +| `IReq_RemoveFromSuppressionList` | `removeFromSuppressionList` | Unsuppress an address | + +#### 📡 RADIUS +| Interface | Method | Description | +|-----------|--------|-------------| +| `IReq_GetRadiusClients` | `getRadiusClients` | List NAS clients | +| `IReq_SetRadiusClient` | `setRadiusClient` | Add/update a NAS client | +| `IReq_RemoveRadiusClient` | `removeRadiusClient` | Remove a NAS client | +| `IReq_GetVlanMappings` | `getVlanMappings` | List VLAN mappings | +| `IReq_SetVlanMapping` | `setVlanMapping` | Add/update VLAN mapping | +| `IReq_RemoveVlanMapping` | `removeVlanMapping` | Remove VLAN mapping | +| `IReq_TestVlanAssignment` | `testVlanAssignment` | Test what VLAN a MAC gets | +| `IReq_GetRadiusSessions` | `getRadiusSessions` | List active sessions | +| `IReq_DisconnectRadiusSession` | `disconnectRadiusSession` | Force disconnect | +| `IReq_GetRadiusStatistics` | `getRadiusStatistics` | RADIUS stats | +| `IReq_GetRadiusAccountingSummary` | `getRadiusAccountingSummary` | Accounting summary | + +## Example: Full API Integration + +```typescript +import * as typedrequest from '@api.global/typedrequest'; +import { data, requests } from '@serve.zone/dcrouter-interfaces'; + +// 1. Login +const loginClient = new typedrequest.TypedRequest( + 'https://your-dcrouter:3000/typedrequest', + 'adminLogin' +); + +const loginResponse = await loginClient.fire({ + username: 'admin', + password: 'your-password' +}); +const identity = loginResponse.identity; + +// 2. Fetch combined metrics +const metricsClient = new typedrequest.TypedRequest( + 'https://your-dcrouter:3000/typedrequest', + 'getCombinedMetrics' +); + +const metrics = await metricsClient.fire({ identity }); +console.log('Server:', metrics.serverStats); +console.log('Email:', metrics.emailStats); +console.log('DNS:', metrics.dnsStats); +console.log('Security:', metrics.securityMetrics); + +// 3. Check email queues +const queueClient = new typedrequest.TypedRequest( + 'https://your-dcrouter:3000/typedrequest', + 'getQueuedEmails' +); + +const queued = await queueClient.fire({ identity }); +console.log('Queued emails:', queued.emails.length); ``` ## License and Legal Information diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index d66d9d7..11f7dd9 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '4.1.1', + version: '5.0.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/readme.md b/ts_web/readme.md index 39cbe34..1878551 100644 --- a/ts_web/readme.md +++ b/ts_web/readme.md @@ -1,8 +1,8 @@ # @serve.zone/dcrouter-web -Web-based Operations Dashboard for DCRouter. 🖥️ +Web-based Operations Dashboard for DcRouter. 🖥️ -This module provides the frontend web application for the DCRouter OpsServer, built with modern web components using the `@design.estate/dees-element` library. It offers a comprehensive dashboard for monitoring and managing your DCRouter instance in real-time. +A modern, reactive web application for monitoring and managing your DcRouter instance in real-time. Built with web components using [@design.estate/dees-element](https://code.foss.global/design.estate/dees-element) and [@design.estate/dees-catalog](https://code.foss.global/design.estate/dees-catalog). ## Issue Reporting and Security @@ -10,203 +10,184 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community ## Features -### 🔐 **Secure Authentication** -- JWT-based authentication flow -- Automatic session management -- Secure login with username/password +### 🔐 Secure Authentication +- JWT-based login with persistent sessions (IndexedDB) +- Automatic session expiry detection and cleanup +- Secure username/password authentication -### 📊 **Overview Dashboard** -- Real-time server statistics -- Connection monitoring -- Email throughput visualization -- DNS query metrics -- RADIUS session tracking +### 📊 Overview Dashboard +- Real-time server statistics (CPU, memory, uptime) +- Active connection counts and email throughput +- DNS query metrics and RADIUS session tracking +- Auto-refreshing with configurable intervals -### 🌐 **Network View** -- Active connection monitoring -- SmartProxy route visualization -- TCP/HTTP connection details -- TLS certificate status +### 🌐 Network View +- Active connection monitoring with real-time data from SmartProxy +- Top connected IPs with connection counts and percentages +- Throughput rates (inbound/outbound in kbit/s, Mbit/s, Gbit/s) +- Traffic chart with selectable time ranges -### 📧 **Email Management** -- Email queue monitoring -- Delivery status tracking -- Bounce management -- DKIM key status -- Domain configuration overview +### 📧 Email Management +- **Queued** — Emails pending delivery with queue position +- **Sent** — Successfully delivered emails with timestamps +- **Failed** — Failed emails with resend capability +- **Security** — Security incidents from email processing +- Bounce record management and suppression list controls -### 📜 **Log Viewer** +### 📜 Log Viewer - Real-time log streaming -- Log level filtering (error, warning, info, debug) -- Search and filter capabilities -- Time-range selection +- Filter by log level (error, warning, info, debug) +- Search and time-range selection -### ⚙️ **Configuration** -- View current system configuration -- Update settings via TypedRequest API -- Route management -- Domain management +### ⚙️ Configuration +- Read-only display of current system configuration +- Status badges for boolean values (enabled/disabled) +- Array values displayed as pills with counts +- Section icons and formatted byte/time values -### 🛡️ **Security Dashboard** +### 🛡️ Security Dashboard - IP reputation monitoring -- Rate limit status -- Blocked connections -- Security event tracking +- Rate limit status across domains +- Blocked connection tracking +- Security event timeline ## Architecture -The web application is built using: +### Technology Stack -- **@design.estate/dees-element** - Modern web component framework with lit-element -- **@design.estate/dees-catalog** - Pre-built UI components (appdash, login, forms) -- **@push.rocks/smartstate** - Reactive state management -- **@api.global/typedrequest** - Type-safe API communication +| Layer | Package | Purpose | +|-------|---------|---------| +| **Components** | `@design.estate/dees-element` | Web component framework (lit-element based) | +| **UI Kit** | `@design.estate/dees-catalog` | Pre-built components (tables, charts, forms, app shell) | +| **State** | `@push.rocks/smartstate` | Reactive state management with persistent/soft modes | +| **Routing** | Client-side router | URL-synchronized view navigation | +| **API** | `@api.global/typedrequest` | Type-safe communication with OpsServer | +| **Types** | `@serve.zone/dcrouter-interfaces` | Shared TypedRequest interface definitions | ### Component Structure ``` ts_web/ -├── index.ts # Application entry point -├── plugins.ts # External dependency imports -├── router.ts # Client-side routing -├── appstate.ts # State management +├── index.ts # Entry point — renders +├── appstate.ts # State management (all state parts + actions) +├── router.ts # Client-side routing (AppRouter) +├── plugins.ts # Dependency imports └── elements/ - ├── index.ts # Component barrel export - ├── ops-dashboard.ts # Main dashboard container - ├── ops-view-overview.ts # Overview statistics - ├── ops-view-network.ts # Network monitoring - ├── ops-view-emails.ts # Email management - ├── ops-view-logs.ts # Log viewer - ├── ops-view-config.ts # Configuration - ├── ops-view-security.ts # Security dashboard + ├── ops-dashboard.ts # Main app shell + ├── ops-view-overview.ts # Overview statistics + ├── ops-view-network.ts # Network monitoring + ├── ops-view-emails.ts # Email queue management + ├── ops-view-logs.ts # Log viewer + ├── ops-view-config.ts # Configuration display + ├── ops-view-security.ts # Security dashboard └── shared/ - ├── index.ts # Shared component exports - ├── css.ts # Shared styles + ├── css.ts # Shared styles └── ops-sectionheading.ts # Section heading component ``` -## State Management +### State Management -The application uses `@push.rocks/smartstate` for reactive state management: +The app uses `@push.rocks/smartstate` with multiple state parts: -### State Parts - -```typescript -// Login state -interface ILoginState { - identity: IIdentity | null; - isLoggedIn: boolean; -} - -// UI state -interface IUiState { - activeView: string; - sidebarCollapsed: boolean; - autoRefresh: boolean; - refreshInterval: number; - theme: 'light' | 'dark'; -} - -// Statistics state -interface IStatsState { - server: IServerStats; - email: IEmailStats; - dns: IDnsStats; - connections: IConnectionInfo[]; - health: IHealthStatus; -} - -// Configuration state -interface IConfigState { - configuration: IDcRouterConfig; - loading: boolean; -} -``` +| State Part | Mode | Description | +|-----------|------|-------------| +| `loginStatePart` | Persistent (IndexedDB) | JWT identity and login status | +| `statsStatePart` | Soft (memory) | Server, email, DNS, security metrics | +| `configStatePart` | Soft | Current system configuration | +| `uiStatePart` | Soft | Active view, sidebar, auto-refresh, theme | +| `logStatePart` | Soft | Recent logs, streaming status, filters | +| `networkStatePart` | Soft | Connections, IPs, throughput rates | +| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list | ### Actions -- `loginAction` - Authenticate user -- `logoutAction` - End session -- `fetchAllStatsAction` - Refresh all statistics -- `fetchConfigurationAction` - Load configuration (read-only) - -## Client-Side Routing - -The application includes client-side routing for deep linking: - ```typescript -// Routes -/overview → Overview dashboard -/network → Network monitoring -/emails → Email management -/logs → Log viewer -/configuration → System configuration -/security → Security dashboard +// Authentication +loginAction(username, password) // JWT login +logoutAction() // Clear session + +// Data fetching (auto-refresh compatible) +fetchAllStatsAction() // Server + email + DNS + security stats +fetchConfigurationAction() // System configuration +fetchRecentLogsAction() // Log entries +fetchNetworkStatsAction() // Connection + throughput data + +// Email operations +fetchQueuedEmailsAction() // Pending emails +fetchSentEmailsAction() // Delivered emails +fetchFailedEmailsAction() // Failed emails +fetchSecurityIncidentsAction() // Security events +fetchBounceRecordsAction() // Bounce records +resendEmailAction(emailId) // Re-queue failed email +removeFromSuppressionAction(email) // Remove from suppression list ``` -URL state is synchronized with the UI, allowing bookmarking and sharing of specific views. +### Client-Side Routing -## Building - -The web application is built using `@git.zone/tsbundle`: - -```bash -# Build the bundle -pnpm run bundle - -# Watch for development -pnpm run watch +``` +/overview → Overview dashboard +/network → Network monitoring +/emails → Email management + /emails/queued → Queued emails + /emails/sent → Sent emails + /emails/failed → Failed emails + /emails/security → Security incidents +/logs → Log viewer +/configuration → System configuration +/security → Security dashboard ``` -The bundle is output to `./dist_serve/bundle.js` and served by the OpsServer. +URL state is synchronized with the UI — bookmarking and deep linking fully supported. ## Development -### Local Development +### Running Locally + +Start DcRouter with OpsServer enabled: ```typescript -// Start DCRouter with OpsServer enabled import { DcRouter } from '@serve.zone/dcrouter'; const router = new DcRouter({ - opsServerConfig: { - port: 3000, - admin: { - username: 'admin', - password: 'dev-password' - } - } + // OpsServer starts automatically on port 3000 + smartProxyConfig: { routes: [/* your routes */] } }); await router.start(); // Dashboard at http://localhost:3000 ``` -### Adding New Views +### Building -1. Create a new view component in `elements/`: +```bash +# Build the bundle +pnpm run bundle + +# Watch for development (auto-rebuild + restart) +pnpm run watch +``` + +The bundle is output to `./dist_serve/bundle.js` and served by the OpsServer. + +### Adding a New View + +1. Create a view component in `elements/`: ```typescript -import { DeesElement, customElement, html } from '@design.estate/dees-element'; +import { DeesElement, customElement, html, css } from '@design.estate/dees-element'; @customElement('ops-view-myview') export class OpsViewMyView extends DeesElement { + public static styles = [css`:host { display: block; padding: 24px; }`]; + public render() { - return html`
My custom view
`; + return html`My View`; } } ``` -2. Import and add to `ops-dashboard.ts`: -```typescript -import { OpsViewMyView } from './ops-view-myview.js'; - -private viewTabs = [ - // ... existing tabs - { name: 'MyView', element: OpsViewMyView } -]; -``` - -3. Add route in `router.ts` if needed. +2. Add it to the dashboard tabs in `ops-dashboard.ts` +3. Add the route in `router.ts` +4. Add any state management in `appstate.ts` ## License and Legal Information