Compare commits
355 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7148306381 | |||
| d3aefef78d | |||
| ecd0cc0066 | |||
| eac490297a | |||
| de65641f6f | |||
| ffddc1a5f5 | |||
| 26152e0520 | |||
| f79ad07a57 | |||
| 76d5b9bf7c | |||
| 670b67eecf | |||
| 174af5cf86 | |||
| a1f5e45e94 | |||
| d06165bd0c | |||
| 8f3c6fdf23 | |||
| 106ef2919e | |||
| 3d7fd233cf | |||
| 34d40f7370 | |||
| 89b9d01628 | |||
| ed3964e892 | |||
| baab152fd3 | |||
| 9baf09ff61 | |||
| 71f23302d3 | |||
| ecbaab3000 | |||
| 8cb1f3c12d | |||
| c7d7f92759 | |||
| 02e1b9231f | |||
| 4ec4dd2bdb | |||
| aa543160e2 | |||
| 94fa0f04d8 | |||
| 17deb481e0 | |||
| e452ffd38e | |||
| 865b4a53e6 | |||
| c07f3975e9 | |||
| 476505537a | |||
| 74ad5cec90 | |||
| 59a3f7978e | |||
| 7dc976b59e | |||
| 345effee13 | |||
| dee6897931 | |||
| 56f41d70b3 | |||
| 8f570ae8a0 | |||
| e58e24a92d | |||
| 12070bc7b5 | |||
| 37d62c51f3 | |||
| ea9427d46b | |||
| bc77321752 | |||
| 65aa546c1c | |||
| 54484518dc | |||
| 6fe1247d4d | |||
| e59d80a3b3 | |||
| 6c4feba711 | |||
| 006a9af20c | |||
| dfb3b0ac37 | |||
| 44c1a3a928 | |||
| 0c4e28455e | |||
| cfc4cf378f | |||
| a09e69a28b | |||
| 82dd19e274 | |||
| c1d8afdbf7 | |||
| 9b7426f1e6 | |||
| 3c9c865841 | |||
| 8421c9fe46 | |||
| 907e3df156 | |||
| aaa0956148 | |||
| 118019fcf5 | |||
| deb80f4fd0 | |||
| 7d28cea937 | |||
| 2bd5e5c7c5 | |||
| 4d6ac81c59 | |||
| 2ebe0de92d | |||
| f5028ffb60 | |||
| 90016d1217 | |||
| 48d3d1218f | |||
| 4759c4f011 | |||
| 0fbd8d1cdd | |||
| 447cf44d68 | |||
| 82ce17a941 | |||
| 15da996e70 | |||
| 582e19e6a6 | |||
| 79765d6729 | |||
| ffc93eb9d3 | |||
| 1337a4905a | |||
| c7418d9e1a | |||
| 2a94ffd4c9 | |||
| b2fe6caf33 | |||
| 822bbc1957 | |||
| eacddc7ce1 | |||
| dc6ce341bd | |||
| 1aadc93f92 | |||
| 8fdcd479d6 | |||
| d24dde8eff | |||
| 40a34073e9 | |||
| 9ac297c197 | |||
| ddd0662fb8 | |||
| 11bc0dde6c | |||
| 610d691244 | |||
| c88410ea53 | |||
| 9cbdd24281 | |||
| dce1de8c4b | |||
| 86e6c4f600 | |||
| 0618755236 | |||
| b21f3385e1 | |||
| dd61e0c962 | |||
| ac3a42fc41 | |||
| c23f16149c | |||
| 529a4bae00 | |||
| 49606ae007 | |||
| 31a6510d8b | |||
| b5e760ae07 | |||
| ea32babaac | |||
| a4ddedaf46 | |||
| 7ce09c53ca | |||
| 69be2295f1 | |||
| 018efa32f6 | |||
| 2530918dc6 | |||
| 0b09ea1573 | |||
| 21157477b4 | |||
| fcf36e5cd5 | |||
| f5740fa565 | |||
| 4a9fba53a9 | |||
| da61adc9a2 | |||
| 616066ffd0 | |||
| bd5cccb405 | |||
| fbade85cda | |||
| 9060d26f3a | |||
| c889141ec3 | |||
| fb472f353c | |||
| 090bd747e1 | |||
| 4d77a94bbb | |||
| 7f5284b10f | |||
| 9cd5db2d81 | |||
| de0b7d1fe0 | |||
| 4e32745a8f | |||
| 121573de2f | |||
| cd957526e2 | |||
| 7aa5f07731 | |||
| 5b6f7b30c3 | |||
| 18cc21a49e | |||
| 46fa2f6ade | |||
| 0a6315f177 | |||
| 841f99e19d | |||
| 8e9de46cd2 | |||
| 2d44528345 | |||
| 28a38252da | |||
| dfb268bbfc | |||
| 6532c7ff22 | |||
| d2c63cf170 | |||
| 09d66e4528 | |||
| 3078fa9d7b | |||
| 57fbb128e6 | |||
| d73266eeb8 | |||
| 2dbdf2d2b1 | |||
| 383e0adc23 | |||
| d7789f5a44 | |||
| 2638990667 | |||
| c33ecdc26f | |||
| b033d80927 | |||
| cf5d616769 | |||
| 8e722f5ab6 | |||
| 2b75709161 | |||
| c5e2c262b7 | |||
| d10896196d | |||
| 8be1e87bdc | |||
| 96cefe984a | |||
| ca112c3e42 | |||
| 85b6c4fa51 | |||
| ee550e6f25 | |||
| 108a8bb51d | |||
| 3c5b26d1c1 | |||
| 01fbc3db95 | |||
| 8dd9770339 | |||
| 77842647fd | |||
| a309145829 | |||
| 5de8d38b78 | |||
| 2d6dbc552e | |||
| f0fae866dc | |||
| 87c039a63f | |||
| 2c875cbb18 | |||
| 735464e8e6 | |||
| e6a1f50554 | |||
| 530ebbf3e4 | |||
| 048f038e36 | |||
| e375adb80a | |||
| 9d7da5bc25 | |||
| 41fe7a8a47 | |||
| f3f1f58b67 | |||
| 9e0e77737b | |||
| 5de3344905 | |||
| ae34314f54 | |||
| 5b473de354 | |||
| 1a108fa8b7 | |||
| badabe753a | |||
| c2d3ace0dd | |||
| fcea194cf6 | |||
| b90650c660 | |||
| 2206abd04b | |||
| d54831765b | |||
| dd4ac9fa3d | |||
| aed9151998 | |||
| 5d4bf4eff8 | |||
| 9027125520 | |||
| ee561c0823 | |||
| 95cb5d7840 | |||
| 2f46b3c9f3 | |||
| 7bd94884f4 | |||
| 405990563b | |||
| bf9f805c71 | |||
| 28cbf84f97 | |||
| d24e51117d | |||
| 92fde9d0d7 | |||
| b81bda6ce8 | |||
| 9b3f5c458d | |||
| 3ba47f9a71 | |||
| 2ab2e30336 | |||
| 8ce6c88d58 | |||
| facae93e4b | |||
| 0eb4963247 | |||
| 02dd3c77b5 | |||
| 93995d5031 | |||
| 554d245c0c | |||
| e3cb35a036 | |||
| 3a95ea9f4e | |||
| 99f57dba76 | |||
| 415e28038d | |||
| 7bda406624 | |||
| 8282610307 | |||
| 5269c20770 | |||
| f1fb4c8495 | |||
| 5faca8c1b6 | |||
| 61778bdba8 | |||
| ab19130904 | |||
| 646aa7106b | |||
| b0f167f6da | |||
| 4d8d802006 | |||
| 6ee1d6e917 | |||
| f877ad9676 | |||
| fe817dde00 | |||
| 272973702e | |||
| c776dab2c0 | |||
| 74692c4aa5 | |||
| 71183b35c0 | |||
| ae73de19b2 | |||
| a2b413a78f | |||
| 739eeb63aa | |||
| eb26a62a87 | |||
| ad0ab6c103 | |||
| 37e1ecefd2 | |||
| e6251ab655 | |||
| 53b64025f3 | |||
| 40db395591 | |||
| 2c244c4a9a | |||
| 0baf2562b7 | |||
| 64da8d9100 | |||
| b11fea7334 | |||
| 6c8458f63c | |||
| 455b0085ec | |||
| 2b2fe940c4 | |||
| e1a7b3e8f7 | |||
| 191c4160c1 | |||
| 2e75961d1c | |||
| 88099e120a | |||
| 77ff948404 | |||
| 0e610cba16 | |||
| 8d59d617f1 | |||
| 6aa54d974e | |||
| 2aeb52bf13 | |||
| 243a45d24c | |||
| cfea44742a | |||
| 073c8378c7 | |||
| af408d38c9 | |||
| c3b14c0f58 | |||
| 69304dc839 | |||
| a3721f7a74 | |||
| 20583beb35 | |||
| b8ea8f660e | |||
| 5a45d6cd45 | |||
| 84196f9b13 | |||
| 4c9fd22a86 | |||
| 5b33623c2d | |||
| 58f4a123d2 | |||
| 11a2ae6b27 | |||
| 4e4c7df558 | |||
| 3d669ed9dd | |||
| 6e19e30f87 | |||
| dc5c0b2584 | |||
| 35712b18bc | |||
| 9958c036a0 | |||
| 14c9fbdc3c | |||
| 4fd3ec2958 | |||
| f2e9ff0a51 | |||
| cb52446f65 | |||
| 0907949f8a | |||
| 9629329bc2 | |||
| f651cd1c2f | |||
| a7438a7cd6 | |||
| e0f6e3237b | |||
| 1b141ec8f3 | |||
| 7d28d23bbd | |||
| 53f5e30b23 | |||
| 7344bf0f70 | |||
| 4905595cbb | |||
| f058b2d1e7 | |||
| 6fcc3feb73 | |||
| 50350bd78d | |||
| f065a9c952 | |||
| 72898c67b7 | |||
| ca53816b41 | |||
| ac419e7b79 | |||
| 7c0f9b4e44 | |||
| d584f3584c | |||
| a4353b10bb | |||
| b2f25c49b6 | |||
| d3255a7e14 | |||
| 2564d0874b | |||
| ca111f4783 | |||
| b6dd281a54 | |||
| 645790d0c2 | |||
| 535b055664 | |||
| 2eeb731669 | |||
| c3ae995372 | |||
| 15e7a3032c | |||
| 10ab09894b | |||
| 38811dbf23 | |||
| 3f220996ee | |||
| b0a0078ad0 | |||
| ecb913843c | |||
| 162795802f | |||
| b1890f59ee | |||
| 5c85188183 | |||
| f37cddf26d | |||
| f3f06ed06d | |||
| 07f03eb834 | |||
| e7174e8630 | |||
| 186e94c1a2 | |||
| fb424d814c | |||
| 0ad5dfd6ee | |||
| fbaafa909b | |||
| f1cc7fd340 | |||
| deec61da42 | |||
| 190ae11667 | |||
| f4ace3999d | |||
| 8b857e3d1d | |||
| 7aaf8f2595 | |||
| 39b634b6bb | |||
| 4624fdbe10 | |||
| 858794799b | |||
| cb33dd26d0 | |||
| d3d197d9d3 | |||
| 0e914a3366 | |||
| 747478f0f9 | |||
| b61de33ee0 | |||
| 970c0d5c60 | |||
| fe2069c48e | |||
| 63781ab1bd | |||
| 0b155d6925 |
3
.gitignore
vendored
@@ -19,4 +19,5 @@ dist_*/
|
|||||||
|
|
||||||
# custom
|
# custom
|
||||||
**/.claude/settings.local.json
|
**/.claude/settings.local.json
|
||||||
data/
|
.nogit/data/
|
||||||
|
readme.plan.md
|
||||||
|
|||||||
7
.playwright-mcp/console-2026-02-23T10-44-24-024Z.log
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[ 74ms] TypeError: Cannot read properties of null (reading 'appendChild')
|
||||||
|
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
|
||||||
|
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
|
||||||
|
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
|
||||||
|
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
|
||||||
|
[ 587ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 697ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||||
12
.playwright-mcp/console-2026-02-23T11-19-21-255Z.log
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[ 669ms] [WARNING] Lit is in dev mode. Not recommended for production! See https://lit.dev/msg/dev-mode for more information. @ http://localhost:3000/chunk-3L5NJTXF.js:13541
|
||||||
|
[ 729ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0
|
||||||
|
[ 27973ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||||
|
[ 27973ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||||
|
[ 29975ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||||
|
[ 29975ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||||
|
[ 33977ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||||
|
[ 33978ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||||
|
[ 41980ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||||
|
[ 41980ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||||
|
[ 51983ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||||
|
[ 51983ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||||
6
.playwright-mcp/console-2026-02-23T11-20-31-682Z.log
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[ 55ms] TypeError: Cannot read properties of null (reading 'appendChild')
|
||||||
|
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
|
||||||
|
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
|
||||||
|
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
|
||||||
|
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
|
||||||
|
[ 791ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
50
.playwright-mcp/console-2026-02-23T11-21-09-382Z.log
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
[ 272ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078)
|
||||||
|
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 272ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 274ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Pause-circle
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078)
|
||||||
|
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 274ms] [WARNING] Lucide icon 'Pause-circle' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 275ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 275ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 276ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078)
|
||||||
|
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 276ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 276ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 276ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 297ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 377ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||||
|
[ 78064ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 78237ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||||
|
[ 127969ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 127969ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 129695ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 129695ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 133309ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 133309ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 141762ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 141910ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||||
23
.playwright-mcp/console-2026-02-23T11-23-44-606Z.log
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[ 437ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
|
[ 38948ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 52895ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 52896ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 52896ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 52897ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 99401ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 99401ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
31
.playwright-mcp/console-2026-02-23T12-47-06-007Z.log
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[ 75ms] TypeError: Cannot read properties of null (reading 'appendChild')
|
||||||
|
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
|
||||||
|
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
|
||||||
|
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
|
||||||
|
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
|
||||||
|
[ 763ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
|
[ 22315ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 22315ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 22316ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 22316ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 22321ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 22322ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 22322ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 22322ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 65371ms] [ERROR] method: >>createApiToken<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 65371ms] [ERROR] Failed to create token: zs @ http://localhost:3000/bundle.js:38142
|
||||||
25
.playwright-mcp/console-2026-02-23T12-48-31-563Z.log
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[ 642ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
|
[ 114916ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
|
[ 179731ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 179731ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 179731ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 179732ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 179737ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 179738ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 179738ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 179738ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
1
.playwright-mcp/console-2026-02-23T12-53-33-702Z.log
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[ 603ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
24
.playwright-mcp/console-2026-02-23T12-55-40-311Z.log
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[ 308ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 309ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 309ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 310ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 349ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 350ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 350ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 351ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 500ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/apitokens:0
|
||||||
30
.playwright-mcp/console-2026-02-23T12-57-47-953Z.log
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[ 427ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
|
[ 44124ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 59106ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 59106ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 59107ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 59107ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 59116ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 59116ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 89192ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 89192ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
6
.playwright-mcp/console-2026-03-02T19-29-32-708Z.log
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[ 95ms] TypeError: Cannot read properties of null (reading 'appendChild')
|
||||||
|
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
|
||||||
|
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
|
||||||
|
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
|
||||||
|
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
|
||||||
|
[ 992ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
5
.playwright-mcp/console-2026-03-02T19-30-09-759Z.log
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[ 329ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 727ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||||
|
[ 260513ms] [ERROR] method: >>adminLoginWithUsernameAndPassword<< got an ERROR: "login failed" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 260514ms] [ERROR] Login failed: Ns @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 260518ms] [WARNING] FontAwesome icon not found: circle-xmark @ http://localhost:3000/bundle.js:1203
|
||||||
3
.playwright-mcp/console-2026-03-02T19-34-55-496Z.log
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[ 397ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 657ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||||
|
[ 24180ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
|
||||||
15
.playwright-mcp/console-2026-03-03T21-47-08-122Z.log
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[ 916ms] [ERROR] method: >>getCombinedMetrics<< got an ERROR: "Valid identity required" with data {} @ http://localhost:3000/bundle.js:15
|
||||||
|
[ 972ms] [ERROR] method: >>getConfiguration<< got an ERROR: "Valid identity required" with data {} @ http://localhost:3000/bundle.js:15
|
||||||
|
[ 973ms] [ERROR] method: >>getRecentLogs<< got an ERROR: "Valid identity required" with data {} @ http://localhost:3000/bundle.js:15
|
||||||
|
[ 990ms] K2
|
||||||
|
[ 1024ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
|
[ 37030ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 37031ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 37923ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 37923ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 39699ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 39699ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 44287ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 44288ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 53685ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 53685ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
90
.playwright-mcp/console-2026-03-03T22-02-52-436Z.log
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
[ 1146ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
|
[ 26151ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 257684ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 257684ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 257684ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 257685ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 258151ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 258500ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 258500ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 258568ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 258568ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 259149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 260149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 260245ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 260245ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 260324ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 260324ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 261149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 262149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 263149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 263917ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 263917ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 264149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 264781ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 264781ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 265169ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 266149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 267149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 268149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 269149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 270149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 271149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 272149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 272565ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 272565ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 273149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 273647ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 273647ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 274149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 275149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 276149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 277149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 278149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 279149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 280149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 281149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 282149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 283149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 284149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 285149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 286149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 287149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 288150ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 289149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 290149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 290179ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 290179ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 291147ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 291147ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 291149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 292149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 293149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 294149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 295149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 296149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 297149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 298149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 299149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 300149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 301149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 302149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 303149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 304149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 305149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 306149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 307149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 308149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 309149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 310149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 311149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 312150ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 313149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 314149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 315149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 316149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 317149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 318150ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 319149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 320149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 321149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
BIN
.playwright-mcp/page-2026-02-23T11-25-39-255Z.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
.playwright-mcp/page-2026-02-23T11-26-10-952Z.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
.playwright-mcp/page-2026-02-23T11-26-15-885Z.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
.playwright-mcp/page-2026-03-02T19-32-32-890Z.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
.playwright-mcp/page-2026-03-02T19-33-32-637Z.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
.playwright-mcp/page-2026-03-03T21-48-01-361Z.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
.playwright-mcp/page-2026-03-03T22-07-21-001Z.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
1020
changelog.md
4
cli.child.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
process.env.CLI_CALL = 'true';
|
||||||
|
import * as cliTool from './ts/index.js';
|
||||||
|
cliTool.runCli();
|
||||||
121
html/index.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<!--gitzone default-->
|
||||||
|
<!-- made by Lossless GmbH -->
|
||||||
|
<!-- checkout https://maintainedby.lossless.com for awesome OpenSource projects -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<!--Lets set some basic meta tags-->
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
|
||||||
|
/>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
|
||||||
|
<!--Lets make sure we recognize this as an PWA-->
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="icon" type="image/png" href="/assetbroker/manifest/favicon.png" />
|
||||||
|
|
||||||
|
<!--Lets load standard fonts-->
|
||||||
|
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
||||||
|
|
||||||
|
|
||||||
|
<!--Lets avoid a rescaling flicker due to default body margins-->
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
position: relative;
|
||||||
|
background: #000;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
projectVersion = '';
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #303f9f;
|
||||||
|
font-family: Inter, Roboto, sans-serif;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
margin-top: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 600px;
|
||||||
|
margin: auto;
|
||||||
|
margin-top: 20px;
|
||||||
|
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #4357d9;
|
||||||
|
}
|
||||||
|
.contentHeader {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 25px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="logo">
|
||||||
|
<img src="https://assetbroker.lossless.one/brandfiles/lossless/svg-minimal-bright.svg" />
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="contentHeader">We need JavaScript to run properly!</div>
|
||||||
|
<div class="content">
|
||||||
|
This site is being built using lit-element (made by Google). This technology works with
|
||||||
|
JavaScript. Subsequently this website does not work as intended by Lossless GmbH without
|
||||||
|
JavaScript.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<a href="https://lossless.gmbh">Legal Info</a> |
|
||||||
|
<a href="https://lossless.gmbh/privacy">Privacy Policy</a>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
<script type="text/javascript" async defer>
|
||||||
|
window.revenueEnabled = true;
|
||||||
|
const runRevenueCheck = async () => {
|
||||||
|
var e = document.createElement('div');
|
||||||
|
e.id = '476kjuhzgtr764';
|
||||||
|
e.style.display = 'none';
|
||||||
|
document.body.appendChild(e);
|
||||||
|
if (document.getElementById('476kjuhzgtr764')) {
|
||||||
|
window.revenueEnabled = true;
|
||||||
|
} else {
|
||||||
|
window.revenueEnabled = false;
|
||||||
|
}
|
||||||
|
console.log(`revenue enabled: ${window.revenueEnabled}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
runRevenueCheck();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
<script defer type="module" src="/bundle.js"></script>
|
||||||
|
</html>
|
||||||
@@ -1,12 +1,39 @@
|
|||||||
{
|
{
|
||||||
"gitzone": {
|
"@git.zone/tswatch": {
|
||||||
|
"watchers": [
|
||||||
|
{
|
||||||
|
"name": "dcrouter-dev",
|
||||||
|
"watch": [
|
||||||
|
"ts/**/*.ts",
|
||||||
|
"ts_*/**/*.ts",
|
||||||
|
"test_watch/devserver.ts"
|
||||||
|
],
|
||||||
|
"command": "pnpm run build && tsrun test_watch/devserver.ts",
|
||||||
|
"restart": true,
|
||||||
|
"debounce": 500,
|
||||||
|
"runOnStart": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@git.zone/tsbundle": {
|
||||||
|
"bundles": [
|
||||||
|
{
|
||||||
|
"from": "./ts_web/index.ts",
|
||||||
|
"to": "./dist_serve/bundle.js",
|
||||||
|
"outputMode": "bundle",
|
||||||
|
"bundler": "esbuild",
|
||||||
|
"production": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@git.zone/cli": {
|
||||||
"projectType": "service",
|
"projectType": "service",
|
||||||
"module": {
|
"module": {
|
||||||
"githost": "gitlab.com",
|
"githost": "gitlab.com",
|
||||||
"gitscope": "serve.zone",
|
"gitscope": "serve.zone",
|
||||||
"gitrepo": "platformservice",
|
"gitrepo": "dcrouter",
|
||||||
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
|
"description": "A traffic router intended to be gating your datacenter.",
|
||||||
"npmPackagename": "@serve.zone/platformservice",
|
"npmPackagename": "@serve.zone/dcrouter",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"projectDomain": "serve.zone",
|
"projectDomain": "serve.zone",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -17,8 +44,7 @@
|
|||||||
"SMTP server",
|
"SMTP server",
|
||||||
"mail parsing",
|
"mail parsing",
|
||||||
"DKIM",
|
"DKIM",
|
||||||
"platform service",
|
"traffic router",
|
||||||
"mailgun integration",
|
|
||||||
"letterXpress",
|
"letterXpress",
|
||||||
"OpenAI",
|
"OpenAI",
|
||||||
"Anthropic AI",
|
"Anthropic AI",
|
||||||
@@ -31,12 +57,19 @@
|
|||||||
"SMTP STARTTLS",
|
"SMTP STARTTLS",
|
||||||
"DNS management"
|
"DNS management"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"registries": [
|
||||||
|
"https://verdaccio.lossless.digital",
|
||||||
|
"https://registry.npmjs.org"
|
||||||
|
],
|
||||||
|
"accessLevel": "public"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"npmci": {
|
"@ship.zone/szci": {
|
||||||
"npmGlobalTools": [],
|
"npmGlobalTools": [],
|
||||||
"dockerRegistryRepoMap": {
|
"dockerRegistryRepoMap": {
|
||||||
"registry.gitlab.com": "code.foss.global/serve.zone/platformservice"
|
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
||||||
},
|
},
|
||||||
"dockerBuildargEnvMap": {
|
"dockerBuildargEnvMap": {
|
||||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||||
|
|||||||
117
package.json
@@ -1,56 +1,67 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/platformservice",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": true,
|
"private": false,
|
||||||
"version": "2.7.0",
|
"version": "11.0.9",
|
||||||
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"main": "dist_ts/index.js",
|
|
||||||
"typings": "dist_ts/index.d.ts",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./dist_ts/index.js",
|
||||||
|
"./interfaces": "./dist_ts_interfaces/index.js"
|
||||||
|
},
|
||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/)",
|
"test": "(tstest test/ --logfile --timeout 60)",
|
||||||
"start": "(node --max_old_space_size=250 ./cli.js)",
|
"start": "(node --max_old_space_size=250 ./cli.js)",
|
||||||
"startTs": "(node cli.ts.js)",
|
"startTs": "(node cli.ts.js)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany)",
|
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||||
"localPublish": ""
|
"bundle": "(tsbundle)",
|
||||||
|
"watch": "tswatch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.3.2",
|
"@git.zone/tsbuild": "^4.1.4",
|
||||||
"@git.zone/tsrun": "^1.2.8",
|
"@git.zone/tsbundle": "^2.9.0",
|
||||||
"@git.zone/tstest": "^1.0.88",
|
"@git.zone/tsrun": "^2.0.1",
|
||||||
"@git.zone/tswatch": "^2.0.1",
|
"@git.zone/tstest": "^3.2.0",
|
||||||
"@push.rocks/tapbundle": "^6.0.3",
|
"@git.zone/tswatch": "^3.2.5",
|
||||||
"@types/node": "^22.15.14"
|
"@types/node": "^25.3.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.0.19",
|
"@api.global/typedrequest": "^3.3.0",
|
||||||
"@api.global/typedserver": "^3.0.74",
|
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||||
"@api.global/typedsocket": "^3.0.0",
|
"@api.global/typedserver": "^8.4.2",
|
||||||
"@apiclient.xyz/cloudflare": "^6.4.1",
|
"@api.global/typedsocket": "^4.1.2",
|
||||||
"@push.rocks/projectinfo": "^5.0.1",
|
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||||
"@push.rocks/qenv": "^6.1.0",
|
"@design.estate/dees-catalog": "^3.43.3",
|
||||||
"@push.rocks/smartacme": "^7.3.3",
|
"@design.estate/dees-element": "^2.1.6",
|
||||||
"@push.rocks/smartdata": "^5.15.1",
|
"@push.rocks/lik": "^6.3.1",
|
||||||
"@push.rocks/smartdns": "^6.2.2",
|
"@push.rocks/projectinfo": "^5.0.2",
|
||||||
"@push.rocks/smartfile": "^11.0.4",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartlog": "^3.0.3",
|
"@push.rocks/smartacme": "^9.1.3",
|
||||||
"@push.rocks/smartmail": "^2.1.0",
|
"@push.rocks/smartdata": "^7.1.0",
|
||||||
"@push.rocks/smartpath": "^5.0.5",
|
"@push.rocks/smartdns": "^7.9.0",
|
||||||
"@push.rocks/smartpromise": "^4.0.3",
|
"@push.rocks/smartfile": "^13.1.2",
|
||||||
"@push.rocks/smartproxy": "^10.2.0",
|
"@push.rocks/smartguard": "^3.1.0",
|
||||||
"@push.rocks/smartrequest": "^2.1.0",
|
"@push.rocks/smartjwt": "^2.2.1",
|
||||||
"@push.rocks/smartrule": "^2.0.1",
|
"@push.rocks/smartlog": "^3.2.1",
|
||||||
|
"@push.rocks/smartmetrics": "^3.0.2",
|
||||||
|
"@push.rocks/smartmongo": "^5.1.0",
|
||||||
|
"@push.rocks/smartmta": "^5.3.1",
|
||||||
|
"@push.rocks/smartnetwork": "^4.4.0",
|
||||||
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
|
"@push.rocks/smartproxy": "^25.9.1",
|
||||||
|
"@push.rocks/smartradius": "^1.1.1",
|
||||||
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstate": "^2.0.0",
|
"@push.rocks/smartstate": "^2.2.0",
|
||||||
"@serve.zone/interfaces": "^5.0.4",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@tsclass/tsclass": "^9.2.0",
|
"@serve.zone/catalog": "^2.5.0",
|
||||||
"@types/mailparser": "^3.4.6",
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
"ip": "^2.0.1",
|
"@serve.zone/remoteingress": "^4.4.0",
|
||||||
"lru-cache": "^11.1.0",
|
"@tsclass/tsclass": "^9.3.0",
|
||||||
"mailauth": "^4.8.4",
|
"lru-cache": "^11.2.6",
|
||||||
"mailparser": "^3.6.9",
|
"uuid": "^13.0.0"
|
||||||
"uuid": "^11.1.0"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mail service",
|
"mail service",
|
||||||
@@ -60,8 +71,7 @@
|
|||||||
"SMTP server",
|
"SMTP server",
|
||||||
"mail parsing",
|
"mail parsing",
|
||||||
"DKIM",
|
"DKIM",
|
||||||
"platform service",
|
"mail router",
|
||||||
"mailgun integration",
|
|
||||||
"letterXpress",
|
"letterXpress",
|
||||||
"OpenAI",
|
"OpenAI",
|
||||||
"Anthropic AI",
|
"Anthropic AI",
|
||||||
@@ -72,7 +82,12 @@
|
|||||||
"email templating",
|
"email templating",
|
||||||
"rule management",
|
"rule management",
|
||||||
"SMTP STARTTLS",
|
"SMTP STARTTLS",
|
||||||
"DNS management"
|
"DNS management",
|
||||||
|
"RADIUS",
|
||||||
|
"AAA",
|
||||||
|
"network authentication",
|
||||||
|
"VLAN assignment",
|
||||||
|
"MAC authentication"
|
||||||
],
|
],
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
@@ -81,5 +96,17 @@
|
|||||||
"puppeteer"
|
"puppeteer"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
|
"packageManager": "pnpm@10.11.0",
|
||||||
|
"files": [
|
||||||
|
"ts/**/*",
|
||||||
|
"ts_web/**/*",
|
||||||
|
"dist/**/*",
|
||||||
|
"dist_*/**/*",
|
||||||
|
"dist_ts/**/*",
|
||||||
|
"dist_ts_web/**/*",
|
||||||
|
"assets/**/*",
|
||||||
|
"cli.js",
|
||||||
|
"npmextra.json",
|
||||||
|
"readme.md"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
9639
pnpm-lock.yaml
generated
729
readme.hints.md
@@ -1,32 +1,347 @@
|
|||||||
# Implementation Hints and Learnings
|
# 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 starting a real MongoDB process in tests
|
||||||
|
|
||||||
|
```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
|
||||||
|
- `@push.rocks/smartproxy`: 22.4.2 → 23.1.0
|
||||||
|
|
||||||
|
**Key Changes:**
|
||||||
|
- Rust-based proxy components for improved performance
|
||||||
|
- Rust binary runs as separate process via IPC
|
||||||
|
- `getStatistics()` now returns `Promise<any>` (was synchronous)
|
||||||
|
- nftables-proxy removed (not used by dcrouter)
|
||||||
|
|
||||||
|
**Code Changes Required:**
|
||||||
|
```typescript
|
||||||
|
// Old (synchronous)
|
||||||
|
const proxyStats = this.dcRouter.smartProxy.getStatistics();
|
||||||
|
|
||||||
|
// New (async)
|
||||||
|
const proxyStats = await this.dcRouter.smartProxy.getStatistics();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `ts/monitoring/classes.metricsmanager.ts` - Added `await` to `getStatistics()` call
|
||||||
|
|
||||||
|
## Dependency Upgrade (2026-02-01)
|
||||||
|
|
||||||
|
### Major Upgrades Completed
|
||||||
|
- `@api.global/typedserver`: 3.0.80 → 8.3.0
|
||||||
|
- `@api.global/typedsocket`: 3.1.1 → 4.1.0
|
||||||
|
- `@apiclient.xyz/cloudflare`: 6.4.3 → 7.1.0
|
||||||
|
- `@design.estate/dees-catalog`: 1.12.4 → 3.41.4
|
||||||
|
- `@push.rocks/smartpath`: 5.1.0 → 6.0.0
|
||||||
|
- `@push.rocks/smartproxy`: 19.6.17 → 22.4.2
|
||||||
|
- `@push.rocks/smartrequest`: 2.1.0 → 5.0.1
|
||||||
|
- `uuid`: 11.1.0 → 13.0.0
|
||||||
|
|
||||||
|
### Breaking Changes Fixed
|
||||||
|
|
||||||
|
1. **SmartProxy v22**: `target` → `targets` (array)
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
action: { type: 'forward', target: { host: 'x', port: 25 } }
|
||||||
|
// New
|
||||||
|
action: { type: 'forward', targets: [{ host: 'x', port: 25 }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **SmartRequest v5**: `SmartRequestClient` → `SmartRequest`, `.body` → `.json()`
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
const resp = await plugins.smartrequest.SmartRequestClient.create()...post();
|
||||||
|
const json = resp.body;
|
||||||
|
// New
|
||||||
|
const resp = await plugins.smartrequest.SmartRequest.create()...post();
|
||||||
|
const json = await resp.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **dees-catalog v3**: Icon naming changed to library-prefixed format
|
||||||
|
```typescript
|
||||||
|
// Old (deprecated but supported)
|
||||||
|
<dees-icon iconFA="check"></dees-icon>
|
||||||
|
// New
|
||||||
|
<dees-icon icon="fa:check"></dees-icon>
|
||||||
|
<dees-icon icon="lucide:menu"></dees-icon>
|
||||||
|
```
|
||||||
|
|
||||||
|
### TC39 Decorators
|
||||||
|
- ts_web components updated to use `accessor` keyword for `@state()` decorators
|
||||||
|
- Required for TC39 standard decorator support
|
||||||
|
|
||||||
|
### tswatch Configuration
|
||||||
|
The project now uses tswatch for development:
|
||||||
|
```bash
|
||||||
|
pnpm run watch
|
||||||
|
```
|
||||||
|
Configuration in `npmextra.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@git.zone/tswatch": {
|
||||||
|
"watchers": [{
|
||||||
|
"name": "dcrouter-dev",
|
||||||
|
"watch": ["ts/**/*.ts", "ts_*/**/*.ts", "test_watch/devserver.ts"],
|
||||||
|
"command": "pnpm run build && tsrun test_watch/devserver.ts",
|
||||||
|
"restart": true,
|
||||||
|
"debounce": 500,
|
||||||
|
"runOnStart": true
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## RADIUS Server Integration (2026-02-01)
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
DcRouter now supports RADIUS server functionality for network authentication via `@push.rocks/smartradius`.
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
- **MAC Authentication Bypass (MAB)** - Authenticate network devices based on MAC address
|
||||||
|
- **VLAN Assignment** - Assign VLANs based on MAC address or OUI patterns
|
||||||
|
- **RADIUS Accounting** - Track sessions, data usage, and billing
|
||||||
|
|
||||||
|
### Configuration Example
|
||||||
|
```typescript
|
||||||
|
const dcRouter = new DcRouter({
|
||||||
|
radiusConfig: {
|
||||||
|
authPort: 1812, // Authentication port (default)
|
||||||
|
acctPort: 1813, // Accounting port (default)
|
||||||
|
clients: [
|
||||||
|
{
|
||||||
|
name: 'switch-1',
|
||||||
|
ipRange: '192.168.1.0/24',
|
||||||
|
secret: 'shared-secret',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
vlanAssignment: {
|
||||||
|
defaultVlan: 100, // VLAN for unknown MACs
|
||||||
|
allowUnknownMacs: true,
|
||||||
|
mappings: [
|
||||||
|
{ mac: '00:11:22:33:44:55', vlan: 10, enabled: true },
|
||||||
|
{ mac: '00:11:22', vlan: 20, enabled: true } // OUI pattern
|
||||||
|
]
|
||||||
|
},
|
||||||
|
accounting: {
|
||||||
|
enabled: true,
|
||||||
|
retentionDays: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components
|
||||||
|
- `RadiusServer` - Main server wrapping smartradius
|
||||||
|
- `VlanManager` - MAC-to-VLAN mapping with OUI pattern support
|
||||||
|
- `AccountingManager` - Session tracking and billing data
|
||||||
|
|
||||||
|
### OpsServer API Endpoints
|
||||||
|
- `getRadiusClients` / `setRadiusClient` / `removeRadiusClient` - Client management
|
||||||
|
- `getVlanMappings` / `setVlanMapping` / `removeVlanMapping` - VLAN mappings
|
||||||
|
- `testVlanAssignment` - Test what VLAN a MAC would get
|
||||||
|
- `getRadiusSessions` / `disconnectRadiusSession` - Session management
|
||||||
|
- `getRadiusStatistics` / `getRadiusAccountingSummary` - Statistics
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `ts/radius/` - RADIUS module
|
||||||
|
- `ts/opsserver/handlers/radius.handler.ts` - OpsServer handler
|
||||||
|
- `ts_interfaces/requests/radius.ts` - TypedRequest interfaces
|
||||||
|
|
||||||
|
## Test Fix: test.dcrouter.email.ts (2026-02-01)
|
||||||
|
|
||||||
|
### Issue
|
||||||
|
The test `DcRouter class - Custom email storage path` was failing with "domainConfigs is not iterable".
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
The test was using outdated email config properties:
|
||||||
|
- Used `domainRules: []` (non-existent property)
|
||||||
|
- Used `defaultMode` (non-existent property)
|
||||||
|
- Missing required `domains: []` property
|
||||||
|
- Missing required `routes: []` property
|
||||||
|
- Referenced `router.unifiedEmailServer` instead of `router.emailServer`
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
Updated the test to use the correct `IUnifiedEmailServerOptions` interface properties:
|
||||||
|
```typescript
|
||||||
|
const emailConfig: IEmailConfig = {
|
||||||
|
ports: [2525],
|
||||||
|
hostname: 'mail.example.com',
|
||||||
|
domains: [], // Required: domain configurations
|
||||||
|
routes: [] // Required: email routing rules
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
And fixed the property name:
|
||||||
|
```typescript
|
||||||
|
expect(router.emailServer).toBeTruthy(); // Not unifiedEmailServer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Learning
|
||||||
|
When using `IUnifiedEmailServerOptions` (aliased as `IEmailConfig` in some tests):
|
||||||
|
- `domains: IEmailDomainConfig[]` is required (array of domain configs)
|
||||||
|
- `routes: IEmailRoute[]` is required (email routing rules)
|
||||||
|
- Access the email server via `dcRouter.emailServer` not `dcRouter.unifiedEmailServer`
|
||||||
|
|
||||||
|
## Network Metrics Implementation (2025-06-23)
|
||||||
|
|
||||||
|
### SmartProxy Metrics API Integration
|
||||||
|
- Updated to use new SmartProxy metrics API (v19.6.7)
|
||||||
|
- Use `getMetrics()` for detailed metrics with grouped methods:
|
||||||
|
```typescript
|
||||||
|
const metrics = smartProxy.getMetrics();
|
||||||
|
metrics.connections.active() // Current active connections
|
||||||
|
metrics.throughput.instant() // Real-time throughput {in, out}
|
||||||
|
metrics.connections.topIPs(10) // Top 10 IPs by connection count
|
||||||
|
```
|
||||||
|
- Use `getStatistics()` for basic stats
|
||||||
|
|
||||||
|
### Network Traffic Display
|
||||||
|
- All throughput values shown in bits per second (kbit/s, Mbit/s, Gbit/s)
|
||||||
|
- Conversion: `bytesPerSecond * 8 / 1000000` for Mbps
|
||||||
|
- Network graph shows separate lines for inbound (green) and outbound (purple)
|
||||||
|
- Throughput tiles and graph use same data source for consistency
|
||||||
|
|
||||||
|
### Requests/sec vs Connections
|
||||||
|
- Requests/sec shows HTTP request counts (derived from connections)
|
||||||
|
- Single connection can handle multiple requests
|
||||||
|
- Current implementation tracks connections, not individual requests
|
||||||
|
- Trend line shows historical request counts, not throughput
|
||||||
|
|
||||||
|
## DKIM Implementation Status (2025-05-30)
|
||||||
|
|
||||||
|
**Note:** DKIM is now handled by `@push.rocks/smartmta`. The `dkimCreator` is a public property on `UnifiedEmailServer`.
|
||||||
|
|
||||||
## SmartProxy Usage
|
## SmartProxy Usage
|
||||||
|
|
||||||
|
### New Route-Based Architecture (v18+)
|
||||||
|
- SmartProxy now uses a route-based configuration system
|
||||||
|
- Routes define match criteria and actions instead of simple port-to-port forwarding
|
||||||
|
- All traffic types (HTTP, HTTPS, TCP, WebSocket) are configured through routes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// NEW: Route-based SmartProxy configuration
|
||||||
|
const smartProxy = new plugins.smartproxy.SmartProxy({
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'https-traffic',
|
||||||
|
match: {
|
||||||
|
ports: 443,
|
||||||
|
domains: ['example.com', '*.example.com']
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
target: {
|
||||||
|
host: 'backend.server.com',
|
||||||
|
port: 8080
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
mode: 'terminate',
|
||||||
|
certificate: 'auto'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
defaults: {
|
||||||
|
target: {
|
||||||
|
host: 'fallback.server.com',
|
||||||
|
port: 8080
|
||||||
|
}
|
||||||
|
},
|
||||||
|
acme: {
|
||||||
|
accountEmail: 'admin@example.com',
|
||||||
|
enabled: true,
|
||||||
|
useProduction: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration from Old to New
|
||||||
|
```typescript
|
||||||
|
// OLD configuration style (deprecated)
|
||||||
|
{
|
||||||
|
fromPort: 443,
|
||||||
|
toPort: 8080,
|
||||||
|
targetIP: 'backend.server.com',
|
||||||
|
domainConfigs: [...]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW route-based style
|
||||||
|
{
|
||||||
|
routes: [{
|
||||||
|
name: 'main-route',
|
||||||
|
match: { ports: 443 },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
target: { host: 'backend.server.com', port: 8080 }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Direct Component Usage
|
### Direct Component Usage
|
||||||
- Use SmartProxy components directly instead of creating your own wrappers
|
- Use SmartProxy components directly instead of creating your own wrappers
|
||||||
- SmartProxy already includes Port80Handler and NetworkProxy functionality
|
- SmartProxy already includes Port80Handler and NetworkProxy functionality
|
||||||
- When using SmartProxy, configure it directly rather than instantiating Port80Handler or NetworkProxy separately
|
- When using SmartProxy, configure it directly rather than instantiating Port80Handler or NetworkProxy separately
|
||||||
|
|
||||||
```typescript
|
|
||||||
// PREFERRED: Use SmartProxy with built-in ACME support
|
|
||||||
const smartProxy = new plugins.smartproxy.SmartProxy({
|
|
||||||
fromPort: 443,
|
|
||||||
toPort: targetPort,
|
|
||||||
targetIP: targetServer,
|
|
||||||
sniEnabled: true,
|
|
||||||
acme: {
|
|
||||||
port: 80,
|
|
||||||
enabled: true,
|
|
||||||
autoRenew: true,
|
|
||||||
useProduction: true,
|
|
||||||
renewThresholdDays: 30,
|
|
||||||
accountEmail: contactEmail
|
|
||||||
},
|
|
||||||
globalPortRanges: [{ from: 443, to: 443 }],
|
|
||||||
domainConfigs: [/* domain configurations */]
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Certificate Management
|
### Certificate Management
|
||||||
- SmartProxy has built-in ACME certificate management
|
- SmartProxy has built-in ACME certificate management
|
||||||
- Configure it in the `acme` property of SmartProxy options
|
- Configure it in the `acme` property of SmartProxy options
|
||||||
@@ -48,15 +363,48 @@ const value = await qenv.getEnvVarOnDemand('ENV_VAR_NAME');
|
|||||||
|
|
||||||
### SmartProxy Interfaces
|
### SmartProxy Interfaces
|
||||||
- Always check the interfaces from the node_modules to ensure correct property names
|
- Always check the interfaces from the node_modules to ensure correct property names
|
||||||
- Important interfaces:
|
- Important interfaces for the new architecture:
|
||||||
- `ISmartProxyOptions`: Main configuration for SmartProxy
|
- `ISmartProxyOptions`: Main configuration with `routes` array
|
||||||
|
- `IRouteConfig`: Individual route configuration
|
||||||
|
- `IRouteMatch`: Match criteria for routes
|
||||||
|
- `IRouteTarget`: Target configuration for forwarding
|
||||||
- `IAcmeOptions`: ACME certificate configuration
|
- `IAcmeOptions`: ACME certificate configuration
|
||||||
- `IDomainConfig`: Domain-specific configuration
|
- `TTlsMode`: TLS handling modes ('passthrough' | 'terminate' | 'terminate-and-reencrypt')
|
||||||
|
|
||||||
|
### New Route Configuration
|
||||||
|
```typescript
|
||||||
|
interface IRouteConfig {
|
||||||
|
name: string;
|
||||||
|
match: {
|
||||||
|
ports: number | number[];
|
||||||
|
domains?: string | string[];
|
||||||
|
path?: string;
|
||||||
|
headers?: Record<string, string | RegExp>;
|
||||||
|
};
|
||||||
|
action: {
|
||||||
|
type: 'forward' | 'redirect' | 'block' | 'static';
|
||||||
|
target?: {
|
||||||
|
host: string | string[] | ((context) => string);
|
||||||
|
port: number | 'preserve' | ((context) => number);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
tls?: {
|
||||||
|
mode: TTlsMode;
|
||||||
|
certificate?: 'auto' | { key: string; cert: string; };
|
||||||
|
};
|
||||||
|
security?: {
|
||||||
|
authentication?: IRouteAuthentication;
|
||||||
|
rateLimit?: IRouteRateLimit;
|
||||||
|
ipAllowList?: string[];
|
||||||
|
ipBlockList?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Required Properties
|
### Required Properties
|
||||||
- Remember to include all required properties in your interface implementations
|
- For `ISmartProxyOptions`, `routes` array is the main configuration
|
||||||
- For `ISmartProxyOptions`, `globalPortRanges` is required
|
|
||||||
- For `IAcmeOptions`, use `accountEmail` for the contact email
|
- For `IAcmeOptions`, use `accountEmail` for the contact email
|
||||||
|
- Routes must have `name`, `match`, and `action` properties
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -93,4 +441,333 @@ tap.test('stop', async () => {
|
|||||||
### Component Integration
|
### Component Integration
|
||||||
- Leverage built-in integrations between components (like SmartProxy's ACME handling)
|
- Leverage built-in integrations between components (like SmartProxy's ACME handling)
|
||||||
- Use parallel operations for performance (like in the `stop()` method)
|
- Use parallel operations for performance (like in the `stop()` method)
|
||||||
- Separate concerns clearly (HTTP handling vs. SMTP handling)
|
- Separate concerns clearly (HTTP handling vs. SMTP handling)
|
||||||
|
|
||||||
|
## Email Integration with SmartProxy
|
||||||
|
|
||||||
|
### Architecture (Post-Migration)
|
||||||
|
- Email traffic is routed through SmartProxy using automatic route generation
|
||||||
|
- 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
|
||||||
|
- smartmta's Rust SMTP bridge handles SMTP protocol processing
|
||||||
|
|
||||||
|
### Port Mapping
|
||||||
|
- External port 25 → Internal port 10025 (SMTP)
|
||||||
|
- External port 587 → Internal port 10587 (Submission)
|
||||||
|
- External port 465 → Internal port 10465 (SMTPS)
|
||||||
|
|
||||||
|
### TLS Handling
|
||||||
|
- Ports 25 and 587: Use 'passthrough' mode (STARTTLS handled by smartmta)
|
||||||
|
- Port 465: Use 'terminate' mode (SmartProxy handles TLS termination)
|
||||||
|
|
||||||
|
## SmartMetrics Integration (2025-06-12) - COMPLETED
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
Fixed the UI metrics display to show accurate CPU and memory data from SmartMetrics.
|
||||||
|
|
||||||
|
### Key Findings
|
||||||
|
1. **CPU Metrics:**
|
||||||
|
- SmartMetrics provides `cpuUsageText` as a string percentage
|
||||||
|
- MetricsManager parses it as `cpuUsage.user` (system is always 0)
|
||||||
|
- UI was incorrectly dividing by 2, showing half the actual CPU usage
|
||||||
|
|
||||||
|
2. **Memory Metrics:**
|
||||||
|
- SmartMetrics calculates `maxMemoryMB` as minimum of:
|
||||||
|
- V8 heap size limit
|
||||||
|
- System total memory
|
||||||
|
- Docker memory limit (if available)
|
||||||
|
- Provides `memoryUsageBytes` (total process memory including children)
|
||||||
|
- Provides `memoryPercentage` (pre-calculated percentage)
|
||||||
|
- UI was only showing heap usage, missing actual memory constraints
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
1. **MetricsManager Enhanced:**
|
||||||
|
- Added `maxMemoryMB` from SmartMetrics instance
|
||||||
|
- Added `actualUsageBytes` from SmartMetrics data
|
||||||
|
- Added `actualUsagePercentage` from SmartMetrics data
|
||||||
|
- Kept existing memory fields for compatibility
|
||||||
|
|
||||||
|
2. **Interface Updated:**
|
||||||
|
- Added optional fields to `IServerStats.memoryUsage`
|
||||||
|
- Fields are optional to maintain backward compatibility
|
||||||
|
|
||||||
|
3. **UI Fixed:**
|
||||||
|
- Removed incorrect CPU division by 2
|
||||||
|
- Uses `actualUsagePercentage` when available (falls back to heap percentage)
|
||||||
|
- Shows actual memory usage vs max memory limit (not just heap)
|
||||||
|
|
||||||
|
### Result
|
||||||
|
- CPU now shows accurate usage percentage
|
||||||
|
- Memory shows percentage of actual constraints (Docker/system/V8 limits)
|
||||||
|
- Better monitoring for containerized environments
|
||||||
|
|
||||||
|
## Network UI Implementation (2025-06-20) - COMPLETED
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
Revamped the Network UI to display real network data from SmartProxy instead of mock data.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
1. **MetricsManager Integration:**
|
||||||
|
- Already integrates with SmartProxy via `dcRouter.smartProxy.getStats()`
|
||||||
|
- Extended with `getNetworkStats()` method to expose unused metrics:
|
||||||
|
- `getConnectionsByIP()` - Connection counts by IP address
|
||||||
|
- `getThroughputRate()` - Real-time bandwidth rates (bytes/second)
|
||||||
|
- `getTopIPs()` - Top connecting IPs sorted by connection count
|
||||||
|
- Note: SmartProxy base interface doesn't include all methods, manual implementation required
|
||||||
|
|
||||||
|
2. **Existing Infrastructure Leveraged:**
|
||||||
|
- `getActiveConnections` endpoint already exists in security.handler.ts
|
||||||
|
- Enhanced to include real SmartProxy data via MetricsManager
|
||||||
|
- IConnectionInfo interface already supports network data structures
|
||||||
|
|
||||||
|
3. **State Management:**
|
||||||
|
- Added `INetworkState` interface following existing patterns
|
||||||
|
- Created `networkStatePart` with connections, throughput, and IP data
|
||||||
|
- Integrated with existing auto-refresh mechanism
|
||||||
|
|
||||||
|
4. **UI Changes (Minimal):**
|
||||||
|
- Removed `generateMockData()` method and all mock generation
|
||||||
|
- Connected to real `networkStatePart` state
|
||||||
|
- Added `renderTopIPs()` section to display top connected IPs
|
||||||
|
- Updated traffic chart to show real request data
|
||||||
|
- Kept all existing UI components (DeesTable, DeesChartArea)
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
1. **Data Transformation:**
|
||||||
|
- Converts IConnectionInfo[] to INetworkRequest[] for table display
|
||||||
|
- Calculates traffic buckets based on selected time range
|
||||||
|
- Maps connection data to chart-compatible format
|
||||||
|
|
||||||
|
2. **Real Metrics Displayed:**
|
||||||
|
- Active connections count (from server stats)
|
||||||
|
- Requests per second (calculated from recent connections)
|
||||||
|
- Throughput rates (currently showing 0 until SmartProxy exposes rates)
|
||||||
|
- Top IPs with connection counts and percentages
|
||||||
|
|
||||||
|
3. **TypeScript Fixes:**
|
||||||
|
- SmartProxy methods like `getThroughputRate()` not in base interface
|
||||||
|
- Implemented manual fallbacks for missing methods
|
||||||
|
- Fixed `publicIpv4` → `publicIp` property name
|
||||||
|
|
||||||
|
### Result
|
||||||
|
- Network view now shows real connection activity
|
||||||
|
- Auto-refreshes with other stats every second
|
||||||
|
- Displays actual IPs and connection counts
|
||||||
|
- No more mock/demo data
|
||||||
|
- Minimal code changes (streamlined approach)
|
||||||
|
|
||||||
|
### Throughput Data Fix (2025-06-20)
|
||||||
|
The throughput was showing 0 because:
|
||||||
|
1. MetricsManager was hardcoding throughputRate to 0, assuming the method didn't exist
|
||||||
|
2. SmartProxy's `getStats()` returns `IProxyStats` interface, but the actual object (`MetricsCollector`) implements `IProxyStatsExtended`
|
||||||
|
3. `getThroughputRate()` only exists in the extended interface
|
||||||
|
|
||||||
|
**Solution implemented:**
|
||||||
|
1. Updated MetricsManager to check if methods exist at runtime and call them
|
||||||
|
2. Added property name mapping (`bytesInPerSec` → `bytesInPerSecond`)
|
||||||
|
3. Created new `getNetworkStats` endpoint in security.handler.ts
|
||||||
|
4. Updated frontend to call the new endpoint for complete network metrics
|
||||||
|
|
||||||
|
The throughput data now flows correctly from SmartProxy → MetricsManager → API → UI.
|
||||||
|
|
||||||
|
## Email Operations Dashboard (2026-02-01)
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
Replaced mock data in the email UI with real backend data from the delivery queue and security logger.
|
||||||
|
|
||||||
|
### New Files Created
|
||||||
|
- `ts_interfaces/requests/email-ops.ts` - TypedRequest interfaces for email operations
|
||||||
|
- `ts/opsserver/handlers/email-ops.handler.ts` - Backend handler for email operations
|
||||||
|
|
||||||
|
### Key Interfaces
|
||||||
|
- `IReq_GetQueuedEmails` - Fetch emails from delivery queue by status
|
||||||
|
- `IReq_GetSentEmails` - Fetch delivered emails
|
||||||
|
- `IReq_GetFailedEmails` - Fetch failed emails
|
||||||
|
- `IReq_ResendEmail` - Re-queue a failed email for retry
|
||||||
|
- `IReq_GetSecurityIncidents` - Fetch security events from SecurityLogger
|
||||||
|
- `IReq_GetBounceRecords` - Fetch bounce records and suppression list
|
||||||
|
- `IReq_RemoveFromSuppressionList` - Remove email from suppression list
|
||||||
|
|
||||||
|
### UI Changes (ops-view-emails.ts)
|
||||||
|
- Replaced mock folders (inbox/sent/draft/trash) with operations views:
|
||||||
|
- **Queued**: Emails pending delivery
|
||||||
|
- **Sent**: Successfully delivered emails
|
||||||
|
- **Failed**: Failed emails with resend capability
|
||||||
|
- **Security**: Security incidents from SecurityLogger
|
||||||
|
- Removed `generateMockEmails()` method
|
||||||
|
- Added state management via `emailOpsStatePart` in appstate.ts
|
||||||
|
- Added resend button for failed emails
|
||||||
|
- Added security incident detail view
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
```
|
||||||
|
UnifiedDeliveryQueue → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||||
|
SecurityLogger → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||||
|
BounceManager → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Data Access
|
||||||
|
The handler accesses data from:
|
||||||
|
- `dcRouter.emailServer.deliveryQueue` - Email queue items (IQueueItem)
|
||||||
|
- `SecurityLogger.getInstance()` - Security events (ISecurityEvent)
|
||||||
|
- `emailServer.bounceManager` - Bounce records and suppression list
|
||||||
|
|
||||||
|
## OpsServer UI Fixes (2026-02-02)
|
||||||
|
|
||||||
|
### Configuration Page Fix
|
||||||
|
The configuration page had field name mismatches between frontend and backend:
|
||||||
|
- Frontend expected `server` and `storage` sections
|
||||||
|
- Backend returns `proxy` section (not `server`)
|
||||||
|
- Backend has no `storage` section
|
||||||
|
|
||||||
|
**Fix**: Updated `ops-view-config.ts` to use correct section names:
|
||||||
|
- `proxy` instead of `server`
|
||||||
|
- Removed non-existent `storage` section
|
||||||
|
- Added optional chaining (`?.`) for safety
|
||||||
|
|
||||||
|
### Auth Persistence Fix
|
||||||
|
Login state was using `'soft'` mode in Smartstate which is memory-only:
|
||||||
|
- User login was lost on page refresh
|
||||||
|
- State reset to logged out after browser restart
|
||||||
|
|
||||||
|
**Changes**:
|
||||||
|
1. `ts_web/appstate.ts`: Changed loginStatePart from `'soft'` to `'persistent'`
|
||||||
|
- Now uses IndexedDB to persist across browser sessions
|
||||||
|
2. `ts/opsserver/handlers/admin.handler.ts`: JWT expiry changed from 7 days to 24 hours
|
||||||
|
3. `ts_web/elements/ops-dashboard.ts`: Added JWT expiry check on session restore
|
||||||
|
- Validates stored JWT hasn't expired before auto-logging in
|
||||||
|
- Clears expired sessions and shows login form
|
||||||
|
|
||||||
|
## Config UI Read-Only Conversion (2026-02-03)
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
The configuration UI has been converted from an editable interface to a read-only display. DcRouter is configured through code or remotely, not through the UI.
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
|
||||||
|
1. **Backend (`ts/opsserver/handlers/config.handler.ts`)**:
|
||||||
|
- Removed `updateConfiguration` handler
|
||||||
|
- Removed `updateConfiguration()` private method
|
||||||
|
- Kept `getConfiguration` handler (read-only)
|
||||||
|
|
||||||
|
2. **Interfaces (`ts_interfaces/requests/config.ts`)**:
|
||||||
|
- Removed `IReq_UpdateConfiguration` interface
|
||||||
|
- Kept `IReq_GetConfiguration` interface
|
||||||
|
|
||||||
|
3. **Frontend (`ts_web/elements/ops-view-config.ts`)**:
|
||||||
|
- Removed `editingSection` and `editedConfig` state properties
|
||||||
|
- Removed `startEdit()`, `cancelEdit()`, `saveConfig()` methods
|
||||||
|
- Removed Edit/Save/Cancel buttons
|
||||||
|
- Removed warning banner about immediate changes
|
||||||
|
- Enhanced read-only display with:
|
||||||
|
- Status badges for boolean values (enabled/disabled)
|
||||||
|
- Array display as pills/tags with counts
|
||||||
|
- Section icons (mail, globe, network, shield)
|
||||||
|
- Better formatting for numbers and byte sizes
|
||||||
|
- Empty state handling ("Not configured", "None configured")
|
||||||
|
- Info note explaining configuration is read-only
|
||||||
|
|
||||||
|
4. **State Management (`ts_web/appstate.ts`)**:
|
||||||
|
- Removed `updateConfigurationAction`
|
||||||
|
- Kept `fetchConfigurationAction` (read-only)
|
||||||
|
|
||||||
|
5. **Tests (`test/test.protected-endpoint.ts`)**:
|
||||||
|
- Replaced `updateConfiguration` tests with `verifyIdentity` tests
|
||||||
|
- Added test for read-only config access
|
||||||
|
- Kept auth flow testing with different protected endpoint
|
||||||
|
|
||||||
|
6. **Documentation**:
|
||||||
|
- `readme.md`: Updated API endpoints to show config as read-only
|
||||||
|
- `ts_web/readme.md`: Removed `updateConfigurationAction` from actions list
|
||||||
|
- `ts_interfaces/readme.md`: Removed `IReq_UpdateConfiguration` from table
|
||||||
|
|
||||||
|
### Visual Display Features
|
||||||
|
- Boolean values shown as colored badges (green=enabled, red=disabled)
|
||||||
|
- Arrays displayed as pills with count summaries
|
||||||
|
- Section headers with relevant Lucide icons
|
||||||
|
- Numbers formatted with locale separators
|
||||||
|
- Byte sizes auto-formatted (B, KB, MB, GB)
|
||||||
|
- Time values shown with "seconds" suffix
|
||||||
|
- Nested objects with visual indentation
|
||||||
|
|
||||||
|
## Smartdata Cache System (2026-02-03)
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
DcRouter now uses smartdata + LocalTsmDb for persistent caching. Data is stored at `~/.serve.zone/dcrouter/tsmdb`.
|
||||||
|
|
||||||
|
### Technology Stack
|
||||||
|
| Layer | Package | Purpose |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| ORM | `@push.rocks/smartdata` | Document classes, decorators, queries |
|
||||||
|
| Database | `@push.rocks/smartmongo` (LocalTsmDb) | Embedded TsmDB via Unix socket |
|
||||||
|
|
||||||
|
### TC39 Decorators
|
||||||
|
The project uses TC39 Stage 3 decorators (not experimental decorators). The tsconfig was updated:
|
||||||
|
- Removed `experimentalDecorators: true`
|
||||||
|
- Removed `emitDecoratorMetadata: true`
|
||||||
|
|
||||||
|
This is required for smartdata v7+ compatibility.
|
||||||
|
|
||||||
|
### Cache Document Classes
|
||||||
|
Located in `ts/cache/documents/`:
|
||||||
|
|
||||||
|
| Class | Purpose | Default TTL |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `CachedEmail` | Email queue items | 30 days |
|
||||||
|
| `CachedIPReputation` | IP reputation lookups | 24 hours |
|
||||||
|
|
||||||
|
Note: CachedBounce, CachedSuppression, and CachedDKIMKey were removed in the smartmta migration (smartmta handles its own persistence for those).
|
||||||
|
|
||||||
|
### Usage Pattern
|
||||||
|
```typescript
|
||||||
|
// Document classes use smartdata decorators
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt: Date = new Date();
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id: string;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query examples
|
||||||
|
const email = await CachedEmail.getInstance({ id: 'abc123' });
|
||||||
|
const pending = await CachedEmail.getInstances({ status: 'pending' });
|
||||||
|
await email.save();
|
||||||
|
await email.delete();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
```typescript
|
||||||
|
const dcRouter = new DcRouter({
|
||||||
|
cacheConfig: {
|
||||||
|
enabled: true,
|
||||||
|
storagePath: '~/.serve.zone/dcrouter/tsmdb',
|
||||||
|
dbName: 'dcrouter',
|
||||||
|
cleanupIntervalHours: 1,
|
||||||
|
ttlConfig: {
|
||||||
|
emails: 30, // days
|
||||||
|
ipReputation: 1, // days
|
||||||
|
bounces: 30, // days
|
||||||
|
dkimKeys: 90, // days
|
||||||
|
suppression: 30 // days
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Cleaner
|
||||||
|
- Runs hourly by default (configurable via `cleanupIntervalHours`)
|
||||||
|
- Finds and deletes documents where `expiresAt < now()`
|
||||||
|
- Uses smartdata's `getInstances()` + `delete()` pattern
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
- `ts/cache/classes.cachedb.ts` - CacheDb singleton wrapper
|
||||||
|
- `ts/cache/classes.cached.document.ts` - Base class with TTL support
|
||||||
|
- `ts/cache/classes.cache.cleaner.ts` - Periodic cleanup service
|
||||||
|
- `ts/cache/documents/*.ts` - Document class definitions
|
||||||
1256
readme.plan.md
443
test/readme.md
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
# DCRouter SMTP Test Suite
|
||||||
|
|
||||||
|
```
|
||||||
|
test/
|
||||||
|
├── readme.md # This file
|
||||||
|
├── helpers/
|
||||||
|
│ ├── server.loader.ts # SMTP server lifecycle management
|
||||||
|
│ ├── utils.ts # Common test utilities
|
||||||
|
│ └── smtp.client.ts # Test SMTP client utilities
|
||||||
|
└── suite/
|
||||||
|
├── smtpserver_commands/ # SMTP command tests (CMD)
|
||||||
|
├── smtpserver_connection/ # Connection management tests (CM)
|
||||||
|
├── smtpserver_edge-cases/ # Edge case tests (EDGE)
|
||||||
|
├── smtpserver_email-processing/ # Email processing tests (EP)
|
||||||
|
├── smtpserver_error-handling/ # Error handling tests (ERR)
|
||||||
|
├── smtpserver_performance/ # Performance tests (PERF)
|
||||||
|
├── smtpserver_reliability/ # Reliability tests (REL)
|
||||||
|
├── smtpserver_rfc-compliance/ # RFC compliance tests (RFC)
|
||||||
|
└── smtpserver_security/ # Security tests (SEC)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test ID Convention
|
||||||
|
|
||||||
|
All test files follow a strict naming convention: `test.<category-id>.<description>.ts`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `test.cmd-01.ehlo-command.ts` - EHLO command test
|
||||||
|
- `test.cm-01.tls-connection.ts` - TLS connection test
|
||||||
|
- `test.sec-01.authentication.ts` - Authentication test
|
||||||
|
|
||||||
|
## Test Categories
|
||||||
|
|
||||||
|
### 1. Connection Management (CM)
|
||||||
|
|
||||||
|
Tests for validating SMTP connection handling, TLS support, and connection lifecycle management.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|-------|-------------------------------------------|----------|----------------|
|
||||||
|
| CM-01 | TLS Connection Test | High | `suite/smtpserver_connection/test.cm-01.tls-connection.ts` |
|
||||||
|
| CM-02 | Multiple Simultaneous Connections | High | `suite/smtpserver_connection/test.cm-02.multiple-connections.ts` |
|
||||||
|
| CM-03 | Connection Timeout | High | `suite/smtpserver_connection/test.cm-03.connection-timeout.ts` |
|
||||||
|
| CM-04 | Connection Limits | Medium | `suite/smtpserver_connection/test.cm-04.connection-limits.ts` |
|
||||||
|
| CM-05 | Connection Rejection | Medium | `suite/smtpserver_connection/test.cm-05.connection-rejection.ts` |
|
||||||
|
| CM-06 | STARTTLS Connection Upgrade | High | `suite/smtpserver_connection/test.cm-06.starttls-upgrade.ts` |
|
||||||
|
| CM-07 | Abrupt Client Disconnection | Medium | `suite/smtpserver_connection/test.cm-07.abrupt-disconnection.ts` |
|
||||||
|
| CM-08 | TLS Version Compatibility | Medium | `suite/smtpserver_connection/test.cm-08.tls-versions.ts` |
|
||||||
|
| CM-09 | TLS Cipher Configuration | Medium | `suite/smtpserver_connection/test.cm-09.tls-ciphers.ts` |
|
||||||
|
| CM-10 | Plain Connection Test | Low | `suite/smtpserver_connection/test.cm-10.plain-connection.ts` |
|
||||||
|
| CM-11 | TCP Keep-Alive Test | Low | `suite/smtpserver_connection/test.cm-11.keepalive.ts` |
|
||||||
|
|
||||||
|
### 2. SMTP Commands (CMD)
|
||||||
|
|
||||||
|
Tests for validating proper SMTP protocol command implementation.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|--------|-------------------------------------------|----------|----------------|
|
||||||
|
| CMD-01 | EHLO Command | High | `suite/smtpserver_commands/test.cmd-01.ehlo-command.ts` |
|
||||||
|
| CMD-02 | MAIL FROM Command | High | `suite/smtpserver_commands/test.cmd-02.mail-from.ts` |
|
||||||
|
| CMD-03 | RCPT TO Command | High | `suite/smtpserver_commands/test.cmd-03.rcpt-to.ts` |
|
||||||
|
| CMD-04 | DATA Command | High | `suite/smtpserver_commands/test.cmd-04.data-command.ts` |
|
||||||
|
| CMD-05 | NOOP Command | Medium | `suite/smtpserver_commands/test.cmd-05.noop-command.ts` |
|
||||||
|
| CMD-06 | RSET Command | Medium | `suite/smtpserver_commands/test.cmd-06.rset-command.ts` |
|
||||||
|
| CMD-07 | VRFY Command | Low | `suite/smtpserver_commands/test.cmd-07.vrfy-command.ts` |
|
||||||
|
| CMD-08 | EXPN Command | Low | `suite/smtpserver_commands/test.cmd-08.expn-command.ts` |
|
||||||
|
| CMD-09 | SIZE Extension | Medium | `suite/smtpserver_commands/test.cmd-09.size-extension.ts` |
|
||||||
|
| CMD-10 | HELP Command | Low | `suite/smtpserver_commands/test.cmd-10.help-command.ts` |
|
||||||
|
| CMD-11 | Command Pipelining | Medium | `suite/smtpserver_commands/test.cmd-11.command-pipelining.ts` |
|
||||||
|
| CMD-12 | HELO Command | Low | `suite/smtpserver_commands/test.cmd-12.helo-command.ts` |
|
||||||
|
| CMD-13 | QUIT Command | High | `suite/smtpserver_commands/test.cmd-13.quit-command.ts` |
|
||||||
|
|
||||||
|
### 3. Email Processing (EP)
|
||||||
|
|
||||||
|
Tests for validating email content handling, parsing, and delivery.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|-------|-------------------------------------------|----------|----------------|
|
||||||
|
| EP-01 | Basic Email Sending | High | `suite/smtpserver_email-processing/test.ep-01.basic-email-sending.ts` |
|
||||||
|
| EP-02 | Invalid Email Address Handling | High | `suite/smtpserver_email-processing/test.ep-02.invalid-email-addresses.ts` |
|
||||||
|
| EP-03 | Multiple Recipients | Medium | `suite/smtpserver_email-processing/test.ep-03.multiple-recipients.ts` |
|
||||||
|
| EP-04 | Large Email Handling | High | `suite/smtpserver_email-processing/test.ep-04.large-email.ts` |
|
||||||
|
| EP-05 | MIME Handling | High | `suite/smtpserver_email-processing/test.ep-05.mime-handling.ts` |
|
||||||
|
| EP-06 | Attachment Handling | Medium | `suite/smtpserver_email-processing/test.ep-06.attachment-handling.ts` |
|
||||||
|
| EP-07 | Special Character Handling | Medium | `suite/smtpserver_email-processing/test.ep-07.special-character-handling.ts` |
|
||||||
|
| EP-08 | Email Routing | High | `suite/smtpserver_email-processing/test.ep-08.email-routing.ts` |
|
||||||
|
| EP-09 | Delivery Status Notifications | Medium | `suite/smtpserver_email-processing/test.ep-09.delivery-status-notifications.ts` |
|
||||||
|
|
||||||
|
### 4. Security (SEC)
|
||||||
|
|
||||||
|
Tests for validating security features and protections.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|--------|-------------------------------------------|----------|----------------|
|
||||||
|
| SEC-01 | Authentication | High | `suite/smtpserver_security/test.sec-01.authentication.ts` |
|
||||||
|
| SEC-02 | Authorization | High | `suite/smtpserver_security/test.sec-02.authorization.ts` |
|
||||||
|
| SEC-03 | DKIM Processing | High | `suite/smtpserver_security/test.sec-03.dkim-processing.ts` |
|
||||||
|
| SEC-04 | SPF Checking | High | `suite/smtpserver_security/test.sec-04.spf-checking.ts` |
|
||||||
|
| SEC-05 | DMARC Policy Enforcement | Medium | `suite/smtpserver_security/test.sec-05.dmarc-policy.ts` |
|
||||||
|
| SEC-06 | IP Reputation Checking | High | `suite/smtpserver_security/test.sec-06.ip-reputation.ts` |
|
||||||
|
| SEC-07 | Content Scanning | Medium | `suite/smtpserver_security/test.sec-07.content-scanning.ts` |
|
||||||
|
| SEC-08 | Rate Limiting | High | `suite/smtpserver_security/test.sec-08.rate-limiting.ts` |
|
||||||
|
| SEC-09 | TLS Certificate Validation | High | `suite/smtpserver_security/test.sec-09.tls-certificate-validation.ts` |
|
||||||
|
| SEC-10 | Header Injection Prevention | High | `suite/smtpserver_security/test.sec-10.header-injection-prevention.ts` |
|
||||||
|
| SEC-11 | Bounce Management | Medium | `suite/smtpserver_security/test.sec-11.bounce-management.ts` |
|
||||||
|
|
||||||
|
### 5. Error Handling (ERR)
|
||||||
|
|
||||||
|
Tests for validating proper error handling and recovery.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|--------|-------------------------------------------|----------|----------------|
|
||||||
|
| ERR-01 | Syntax Error Handling | High | `suite/smtpserver_error-handling/test.err-01.syntax-errors.ts` |
|
||||||
|
| ERR-02 | Invalid Sequence Handling | High | `suite/smtpserver_error-handling/test.err-02.invalid-sequence.ts` |
|
||||||
|
| ERR-03 | Temporary Failure Handling | Medium | `suite/smtpserver_error-handling/test.err-03.temporary-failures.ts` |
|
||||||
|
| ERR-04 | Permanent Failure Handling | Medium | `suite/smtpserver_error-handling/test.err-04.permanent-failures.ts` |
|
||||||
|
| ERR-05 | Resource Exhaustion Handling | High | `suite/smtpserver_error-handling/test.err-05.resource-exhaustion.ts` |
|
||||||
|
| ERR-06 | Malformed MIME Handling | Medium | `suite/smtpserver_error-handling/test.err-06.malformed-mime.ts` |
|
||||||
|
| ERR-07 | Exception Handling | High | `suite/smtpserver_error-handling/test.err-07.exception-handling.ts` |
|
||||||
|
| ERR-08 | Error Logging | Medium | `suite/smtpserver_error-handling/test.err-08.error-logging.ts` |
|
||||||
|
|
||||||
|
### 6. Performance (PERF)
|
||||||
|
|
||||||
|
Tests for validating performance characteristics and benchmarks.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|---------|------------------------------------------|----------|----------------|
|
||||||
|
| PERF-01 | Throughput Testing | Medium | `suite/smtpserver_performance/test.perf-01.throughput.ts` |
|
||||||
|
| PERF-02 | Concurrency Testing | High | `suite/smtpserver_performance/test.perf-02.concurrency.ts` |
|
||||||
|
| PERF-03 | CPU Utilization | Medium | `suite/smtpserver_performance/test.perf-03.cpu-utilization.ts` |
|
||||||
|
| PERF-04 | Memory Usage | Medium | `suite/smtpserver_performance/test.perf-04.memory-usage.ts` |
|
||||||
|
| PERF-05 | Connection Processing Time | Medium | `suite/smtpserver_performance/test.perf-05.connection-processing-time.ts` |
|
||||||
|
| PERF-06 | Message Processing Time | Medium | `suite/smtpserver_performance/test.perf-06.message-processing-time.ts` |
|
||||||
|
| PERF-07 | Resource Cleanup | High | `suite/smtpserver_performance/test.perf-07.resource-cleanup.ts` |
|
||||||
|
|
||||||
|
### 7. Reliability (REL)
|
||||||
|
|
||||||
|
Tests for validating system reliability and stability.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|--------|-------------------------------------------|----------|----------------|
|
||||||
|
| REL-01 | Long-Running Operation | High | `suite/smtpserver_reliability/test.rel-01.long-running-operation.ts` |
|
||||||
|
| REL-02 | Restart Recovery | High | `suite/smtpserver_reliability/test.rel-02.restart-recovery.ts` |
|
||||||
|
| REL-03 | Resource Leak Detection | High | `suite/smtpserver_reliability/test.rel-03.resource-leak-detection.ts` |
|
||||||
|
| REL-04 | Error Recovery | High | `suite/smtpserver_reliability/test.rel-04.error-recovery.ts` |
|
||||||
|
| REL-05 | DNS Resolution Failure Handling | Medium | `suite/smtpserver_reliability/test.rel-05.dns-resolution-failure.ts` |
|
||||||
|
| REL-06 | Network Interruption Handling | Medium | `suite/smtpserver_reliability/test.rel-06.network-interruption.ts` |
|
||||||
|
|
||||||
|
### 8. Edge Cases (EDGE)
|
||||||
|
|
||||||
|
Tests for validating handling of unusual or extreme scenarios.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|---------|-------------------------------------------|----------|----------------|
|
||||||
|
| EDGE-01 | Very Large Email | Low | `suite/smtpserver_edge-cases/test.edge-01.very-large-email.ts` |
|
||||||
|
| EDGE-02 | Very Small Email | Low | `suite/smtpserver_edge-cases/test.edge-02.very-small-email.ts` |
|
||||||
|
| EDGE-03 | Invalid Character Handling | Medium | `suite/smtpserver_edge-cases/test.edge-03.invalid-character-handling.ts` |
|
||||||
|
| EDGE-04 | Empty Commands | Low | `suite/smtpserver_edge-cases/test.edge-04.empty-commands.ts` |
|
||||||
|
| EDGE-05 | Extremely Long Lines | Medium | `suite/smtpserver_edge-cases/test.edge-05.extremely-long-lines.ts` |
|
||||||
|
| EDGE-06 | Extremely Long Headers | Medium | `suite/smtpserver_edge-cases/test.edge-06.extremely-long-headers.ts` |
|
||||||
|
| EDGE-07 | Unusual MIME Types | Low | `suite/smtpserver_edge-cases/test.edge-07.unusual-mime-types.ts` |
|
||||||
|
| EDGE-08 | Nested MIME Structures | Low | `suite/smtpserver_edge-cases/test.edge-08.nested-mime-structures.ts` |
|
||||||
|
|
||||||
|
### 9. RFC Compliance (RFC)
|
||||||
|
|
||||||
|
Tests for validating compliance with SMTP-related RFCs.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|--------|-------------------------------------------|----------|----------------|
|
||||||
|
| RFC-01 | RFC 5321 Compliance | High | `suite/smtpserver_rfc-compliance/test.rfc-01.rfc5321-compliance.ts` |
|
||||||
|
| RFC-02 | RFC 5322 Compliance | High | `suite/smtpserver_rfc-compliance/test.rfc-02.rfc5322-compliance.ts` |
|
||||||
|
| RFC-03 | RFC 7208 SPF Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-03.rfc7208-spf-compliance.ts` |
|
||||||
|
| RFC-04 | RFC 6376 DKIM Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-04.rfc6376-dkim-compliance.ts` |
|
||||||
|
| RFC-05 | RFC 7489 DMARC Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-05.rfc7489-dmarc-compliance.ts` |
|
||||||
|
| RFC-06 | RFC 8314 TLS Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-06.rfc8314-tls-compliance.ts` |
|
||||||
|
| RFC-07 | RFC 3461 DSN Compliance | Low | `suite/smtpserver_rfc-compliance/test.rfc-07.rfc3461-dsn-compliance.ts` |
|
||||||
|
|
||||||
|
## SMTP Client Test Suite
|
||||||
|
|
||||||
|
The following test categories ensure our SMTP client is production-ready, RFC-compliant, and handles all real-world scenarios properly.
|
||||||
|
|
||||||
|
### Client Test Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
test/
|
||||||
|
└── suite/
|
||||||
|
├── smtpclient_connection/ # Client connection management tests (CCM)
|
||||||
|
├── smtpclient_commands/ # Client command execution tests (CCMD)
|
||||||
|
├── smtpclient_email-composition/ # Email composition tests (CEP)
|
||||||
|
├── smtpclient_security/ # Client security tests (CSEC)
|
||||||
|
├── smtpclient_error-handling/ # Client error handling tests (CERR)
|
||||||
|
├── smtpclient_performance/ # Client performance tests (CPERF)
|
||||||
|
├── smtpclient_reliability/ # Client reliability tests (CREL)
|
||||||
|
├── smtpclient_edge-cases/ # Client edge case tests (CEDGE)
|
||||||
|
└── smtpclient_rfc-compliance/ # Client RFC compliance tests (CRFC)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Client Connection Management (CCM)
|
||||||
|
|
||||||
|
Tests for validating how the SMTP client establishes and manages connections to servers.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|--------|-------------------------------------------|----------|----------------|
|
||||||
|
| CCM-01 | Basic TCP Connection | High | `suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts` |
|
||||||
|
| CCM-02 | TLS Connection Establishment | High | `suite/smtpclient_connection/test.ccm-02.tls-connection.ts` |
|
||||||
|
| CCM-03 | STARTTLS Upgrade | High | `suite/smtpclient_connection/test.ccm-03.starttls-upgrade.ts` |
|
||||||
|
| CCM-04 | Connection Pooling | High | `suite/smtpclient_connection/test.ccm-04.connection-pooling.ts` |
|
||||||
|
| CCM-05 | Connection Reuse | Medium | `suite/smtpclient_connection/test.ccm-05.connection-reuse.ts` |
|
||||||
|
| CCM-06 | Connection Timeout Handling | High | `suite/smtpclient_connection/test.ccm-06.connection-timeout.ts` |
|
||||||
|
| CCM-07 | Automatic Reconnection | High | `suite/smtpclient_connection/test.ccm-07.automatic-reconnection.ts` |
|
||||||
|
| CCM-08 | DNS Resolution & MX Records | High | `suite/smtpclient_connection/test.ccm-08.dns-mx-resolution.ts` |
|
||||||
|
| CCM-09 | IPv4/IPv6 Dual Stack Support | Medium | `suite/smtpclient_connection/test.ccm-09.dual-stack-support.ts` |
|
||||||
|
| CCM-10 | Proxy Support (SOCKS/HTTP) | Low | `suite/smtpclient_connection/test.ccm-10.proxy-support.ts` |
|
||||||
|
| CCM-11 | Keep-Alive Management | Medium | `suite/smtpclient_connection/test.ccm-11.keepalive-management.ts` |
|
||||||
|
|
||||||
|
### 11. Client Command Execution (CCMD)
|
||||||
|
|
||||||
|
Tests for validating how the client sends SMTP commands and processes responses.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|---------|-------------------------------------------|----------|----------------|
|
||||||
|
| CCMD-01 | EHLO/HELO Command Sending | High | `suite/smtpclient_commands/test.ccmd-01.ehlo-helo-sending.ts` |
|
||||||
|
| CCMD-02 | MAIL FROM Command with Parameters | High | `suite/smtpclient_commands/test.ccmd-02.mail-from-parameters.ts` |
|
||||||
|
| CCMD-03 | RCPT TO Command with Multiple Recipients | High | `suite/smtpclient_commands/test.ccmd-03.rcpt-to-multiple.ts` |
|
||||||
|
| CCMD-04 | DATA Command and Content Transmission | High | `suite/smtpclient_commands/test.ccmd-04.data-transmission.ts` |
|
||||||
|
| CCMD-05 | AUTH Command (LOGIN, PLAIN, CRAM-MD5) | High | `suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts` |
|
||||||
|
| CCMD-06 | Command Pipelining | Medium | `suite/smtpclient_commands/test.ccmd-06.command-pipelining.ts` |
|
||||||
|
| CCMD-07 | Response Code Parsing | High | `suite/smtpclient_commands/test.ccmd-07.response-parsing.ts` |
|
||||||
|
| CCMD-08 | Extended Response Handling | Medium | `suite/smtpclient_commands/test.ccmd-08.extended-responses.ts` |
|
||||||
|
| CCMD-09 | QUIT Command and Graceful Disconnect | High | `suite/smtpclient_commands/test.ccmd-09.quit-disconnect.ts` |
|
||||||
|
| CCMD-10 | RSET Command Usage | Medium | `suite/smtpclient_commands/test.ccmd-10.rset-usage.ts` |
|
||||||
|
| CCMD-11 | NOOP Keep-Alive | Low | `suite/smtpclient_commands/test.ccmd-11.noop-keepalive.ts` |
|
||||||
|
|
||||||
|
### 12. Client Email Composition (CEP)
|
||||||
|
|
||||||
|
Tests for validating email composition, formatting, and encoding.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|--------|-------------------------------------------|----------|----------------|
|
||||||
|
| CEP-01 | Basic Email Headers | High | `suite/smtpclient_email-composition/test.cep-01.basic-headers.ts` |
|
||||||
|
| CEP-02 | MIME Multipart Messages | High | `suite/smtpclient_email-composition/test.cep-02.mime-multipart.ts` |
|
||||||
|
| CEP-03 | Attachment Encoding | High | `suite/smtpclient_email-composition/test.cep-03.attachment-encoding.ts` |
|
||||||
|
| CEP-04 | UTF-8 and International Characters | High | `suite/smtpclient_email-composition/test.cep-04.utf8-international.ts` |
|
||||||
|
| CEP-05 | Base64 and Quoted-Printable Encoding | Medium | `suite/smtpclient_email-composition/test.cep-05.content-encoding.ts` |
|
||||||
|
| CEP-06 | HTML Email with Inline Images | Medium | `suite/smtpclient_email-composition/test.cep-06.html-inline-images.ts` |
|
||||||
|
| CEP-07 | Custom Headers | Low | `suite/smtpclient_email-composition/test.cep-07.custom-headers.ts` |
|
||||||
|
| CEP-08 | Message-ID Generation | Medium | `suite/smtpclient_email-composition/test.cep-08.message-id.ts` |
|
||||||
|
| CEP-09 | Date Header Formatting | Medium | `suite/smtpclient_email-composition/test.cep-09.date-formatting.ts` |
|
||||||
|
| CEP-10 | Line Length Limits (RFC 5322) | High | `suite/smtpclient_email-composition/test.cep-10.line-length-limits.ts` |
|
||||||
|
|
||||||
|
### 13. Client Security (CSEC)
|
||||||
|
|
||||||
|
Tests for client-side security features and protections.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|---------|-------------------------------------------|----------|----------------|
|
||||||
|
| CSEC-01 | TLS Certificate Verification | High | `suite/smtpclient_security/test.csec-01.tls-verification.ts` |
|
||||||
|
| CSEC-02 | Authentication Mechanisms | High | `suite/smtpclient_security/test.csec-02.auth-mechanisms.ts` |
|
||||||
|
| CSEC-03 | OAuth2 Support | Medium | `suite/smtpclient_security/test.csec-03.oauth2-support.ts` |
|
||||||
|
| CSEC-04 | Password Security (No Plaintext) | High | `suite/smtpclient_security/test.csec-04.password-security.ts` |
|
||||||
|
| CSEC-05 | DKIM Signing | High | `suite/smtpclient_security/test.csec-05.dkim-signing.ts` |
|
||||||
|
| CSEC-06 | SPF Record Compliance | Medium | `suite/smtpclient_security/test.csec-06.spf-compliance.ts` |
|
||||||
|
| CSEC-07 | Secure Credential Storage | High | `suite/smtpclient_security/test.csec-07.credential-storage.ts` |
|
||||||
|
| CSEC-08 | TLS Version Enforcement | High | `suite/smtpclient_security/test.csec-08.tls-version-enforcement.ts` |
|
||||||
|
| CSEC-09 | Certificate Pinning | Low | `suite/smtpclient_security/test.csec-09.certificate-pinning.ts` |
|
||||||
|
| CSEC-10 | Injection Attack Prevention | High | `suite/smtpclient_security/test.csec-10.injection-prevention.ts` |
|
||||||
|
|
||||||
|
### 14. Client Error Handling (CERR)
|
||||||
|
|
||||||
|
Tests for how the client handles various error conditions.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|---------|-------------------------------------------|----------|----------------|
|
||||||
|
| CERR-01 | 4xx Error Response Handling | High | `suite/smtpclient_error-handling/test.cerr-01.4xx-errors.ts` |
|
||||||
|
| CERR-02 | 5xx Error Response Handling | High | `suite/smtpclient_error-handling/test.cerr-02.5xx-errors.ts` |
|
||||||
|
| CERR-03 | Network Failure Recovery | High | `suite/smtpclient_error-handling/test.cerr-03.network-failures.ts` |
|
||||||
|
| CERR-04 | Timeout Recovery | High | `suite/smtpclient_error-handling/test.cerr-04.timeout-recovery.ts` |
|
||||||
|
| CERR-05 | Retry Logic with Backoff | High | `suite/smtpclient_error-handling/test.cerr-05.retry-backoff.ts` |
|
||||||
|
| CERR-06 | Greylisting Handling | Medium | `suite/smtpclient_error-handling/test.cerr-06.greylisting.ts` |
|
||||||
|
| CERR-07 | Rate Limit Response Handling | High | `suite/smtpclient_error-handling/test.cerr-07.rate-limits.ts` |
|
||||||
|
| CERR-08 | Malformed Server Response | Medium | `suite/smtpclient_error-handling/test.cerr-08.malformed-responses.ts` |
|
||||||
|
| CERR-09 | Connection Drop During Transfer | High | `suite/smtpclient_error-handling/test.cerr-09.connection-drops.ts` |
|
||||||
|
| CERR-10 | Authentication Failure Handling | High | `suite/smtpclient_error-handling/test.cerr-10.auth-failures.ts` |
|
||||||
|
|
||||||
|
### 15. Client Performance (CPERF)
|
||||||
|
|
||||||
|
Tests for client performance characteristics and optimization.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|----------|-------------------------------------------|----------|----------------|
|
||||||
|
| CPERF-01 | Bulk Email Sending | High | `suite/smtpclient_performance/test.cperf-01.bulk-sending.ts` |
|
||||||
|
| CPERF-02 | Connection Pool Efficiency | High | `suite/smtpclient_performance/test.cperf-02.pool-efficiency.ts` |
|
||||||
|
| CPERF-03 | Memory Usage Under Load | High | `suite/smtpclient_performance/test.cperf-03.memory-usage.ts` |
|
||||||
|
| CPERF-04 | CPU Usage Optimization | Medium | `suite/smtpclient_performance/test.cperf-04.cpu-optimization.ts` |
|
||||||
|
| CPERF-05 | Parallel Sending Performance | High | `suite/smtpclient_performance/test.cperf-05.parallel-sending.ts` |
|
||||||
|
| CPERF-06 | Large Attachment Handling | Medium | `suite/smtpclient_performance/test.cperf-06.large-attachments.ts` |
|
||||||
|
| CPERF-07 | Queue Management | High | `suite/smtpclient_performance/test.cperf-07.queue-management.ts` |
|
||||||
|
| CPERF-08 | DNS Caching Efficiency | Medium | `suite/smtpclient_performance/test.cperf-08.dns-caching.ts` |
|
||||||
|
|
||||||
|
### 16. Client Reliability (CREL)
|
||||||
|
|
||||||
|
Tests for client reliability and resilience.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|---------|-------------------------------------------|----------|----------------|
|
||||||
|
| CREL-01 | Long Running Stability | High | `suite/smtpclient_reliability/test.crel-01.long-running.ts` |
|
||||||
|
| CREL-02 | Failover to Backup MX | High | `suite/smtpclient_reliability/test.crel-02.mx-failover.ts` |
|
||||||
|
| CREL-03 | Queue Persistence | High | `suite/smtpclient_reliability/test.crel-03.queue-persistence.ts` |
|
||||||
|
| CREL-04 | Crash Recovery | High | `suite/smtpclient_reliability/test.crel-04.crash-recovery.ts` |
|
||||||
|
| CREL-05 | Memory Leak Prevention | High | `suite/smtpclient_reliability/test.crel-05.memory-leaks.ts` |
|
||||||
|
| CREL-06 | Concurrent Operation Safety | High | `suite/smtpclient_reliability/test.crel-06.concurrency-safety.ts` |
|
||||||
|
| CREL-07 | Resource Cleanup | Medium | `suite/smtpclient_reliability/test.crel-07.resource-cleanup.ts` |
|
||||||
|
|
||||||
|
### 17. Client Edge Cases (CEDGE)
|
||||||
|
|
||||||
|
Tests for unusual scenarios and edge cases.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|----------|-------------------------------------------|----------|----------------|
|
||||||
|
| CEDGE-01 | Extremely Slow Server Response | Medium | `suite/smtpclient_edge-cases/test.cedge-01.slow-server.ts` |
|
||||||
|
| CEDGE-02 | Server Sending Invalid UTF-8 | Low | `suite/smtpclient_edge-cases/test.cedge-02.invalid-utf8.ts` |
|
||||||
|
| CEDGE-03 | Extremely Large Recipients List | Medium | `suite/smtpclient_edge-cases/test.cedge-03.large-recipient-list.ts` |
|
||||||
|
| CEDGE-04 | Zero-Byte Attachments | Low | `suite/smtpclient_edge-cases/test.cedge-04.zero-byte-attachments.ts` |
|
||||||
|
| CEDGE-05 | Server Disconnect Mid-Command | High | `suite/smtpclient_edge-cases/test.cedge-05.mid-command-disconnect.ts` |
|
||||||
|
| CEDGE-06 | Unusual Server Banners | Low | `suite/smtpclient_edge-cases/test.cedge-06.unusual-banners.ts` |
|
||||||
|
| CEDGE-07 | Non-Standard Port Connections | Medium | `suite/smtpclient_edge-cases/test.cedge-07.non-standard-ports.ts` |
|
||||||
|
|
||||||
|
### 18. Client RFC Compliance (CRFC)
|
||||||
|
|
||||||
|
Tests for RFC compliance from the client perspective.
|
||||||
|
|
||||||
|
| ID | Test Description | Priority | Implementation |
|
||||||
|
|---------|-------------------------------------------|----------|----------------|
|
||||||
|
| CRFC-01 | RFC 5321 Client Requirements | High | `suite/smtpclient_rfc-compliance/test.crfc-01.rfc5321-client.ts` |
|
||||||
|
| CRFC-02 | RFC 5322 Message Format | High | `suite/smtpclient_rfc-compliance/test.crfc-02.rfc5322-format.ts` |
|
||||||
|
| CRFC-03 | RFC 2045-2049 MIME Compliance | High | `suite/smtpclient_rfc-compliance/test.crfc-03.mime-compliance.ts` |
|
||||||
|
| CRFC-04 | RFC 4954 AUTH Extension | High | `suite/smtpclient_rfc-compliance/test.crfc-04.auth-extension.ts` |
|
||||||
|
| CRFC-05 | RFC 3207 STARTTLS | High | `suite/smtpclient_rfc-compliance/test.crfc-05.starttls.ts` |
|
||||||
|
| CRFC-06 | RFC 1870 SIZE Extension | Medium | `suite/smtpclient_rfc-compliance/test.crfc-06.size-extension.ts` |
|
||||||
|
| CRFC-07 | RFC 6152 8BITMIME Extension | Medium | `suite/smtpclient_rfc-compliance/test.crfc-07.8bitmime.ts` |
|
||||||
|
| CRFC-08 | RFC 2920 Command Pipelining | Medium | `suite/smtpclient_rfc-compliance/test.crfc-08.pipelining.ts` |
|
||||||
|
|
||||||
|
## Running SMTP Client Tests
|
||||||
|
|
||||||
|
### Run All Client Tests
|
||||||
|
```bash
|
||||||
|
cd dcrouter
|
||||||
|
pnpm test test/suite/smtpclient_*
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Specific Client Test Category
|
||||||
|
```bash
|
||||||
|
# Run all client connection tests
|
||||||
|
pnpm test test/suite/smtpclient_connection
|
||||||
|
|
||||||
|
# Run all client security tests
|
||||||
|
pnpm test test/suite/smtpclient_security
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Single Client Test File
|
||||||
|
```bash
|
||||||
|
# Run basic TCP connection test
|
||||||
|
tsx test/suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts
|
||||||
|
|
||||||
|
# Run AUTH mechanisms test
|
||||||
|
tsx test/suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Performance Benchmarks
|
||||||
|
|
||||||
|
Expected performance metrics for production-ready SMTP client:
|
||||||
|
- **Sending Rate**: >100 emails per second (with connection pooling)
|
||||||
|
- **Connection Pool Size**: 10-50 concurrent connections efficiently managed
|
||||||
|
- **Memory Usage**: <500MB for 1000 concurrent email operations
|
||||||
|
- **DNS Cache Hit Rate**: >90% for repeated domains
|
||||||
|
- **Retry Success Rate**: >95% for temporary failures
|
||||||
|
- **Large Attachment Support**: Files up to 25MB without performance degradation
|
||||||
|
- **Queue Processing**: >1000 emails/minute with persistent queue
|
||||||
|
|
||||||
|
## Client Security Requirements
|
||||||
|
|
||||||
|
All client security tests must pass for production deployment:
|
||||||
|
- **TLS Support**: TLS 1.2+ required, TLS 1.3 preferred
|
||||||
|
- **Authentication**: Support for LOGIN, PLAIN, CRAM-MD5, OAuth2
|
||||||
|
- **Certificate Validation**: Proper certificate chain validation
|
||||||
|
- **DKIM Signing**: Automatic DKIM signature generation
|
||||||
|
- **Credential Security**: No plaintext password storage
|
||||||
|
- **Injection Prevention**: Protection against header/command injection
|
||||||
|
|
||||||
|
## Client Production Readiness Criteria
|
||||||
|
|
||||||
|
### Production Gate 1: Core Functionality (>95% tests passing)
|
||||||
|
- Basic connection establishment
|
||||||
|
- Command execution and response parsing
|
||||||
|
- Email composition and sending
|
||||||
|
- Error handling and recovery
|
||||||
|
|
||||||
|
### Production Gate 2: Advanced Features (>90% tests passing)
|
||||||
|
- Connection pooling and reuse
|
||||||
|
- Authentication mechanisms
|
||||||
|
- TLS/STARTTLS support
|
||||||
|
- Retry logic and resilience
|
||||||
|
|
||||||
|
### Production Gate 3: Enterprise Ready (>85% tests passing)
|
||||||
|
- High-volume sending capabilities
|
||||||
|
- Advanced security features
|
||||||
|
- Full RFC compliance
|
||||||
|
- Performance under load
|
||||||
|
|
||||||
|
## Key Differences: Server vs Client Tests
|
||||||
|
|
||||||
|
| Aspect | Server Tests | Client Tests |
|
||||||
|
|--------|--------------|--------------|
|
||||||
|
| **Focus** | Accepting connections, processing commands | Making connections, sending commands |
|
||||||
|
| **Security** | Validating incoming data, enforcing policies | Protecting credentials, validating servers |
|
||||||
|
| **Performance** | Handling many clients concurrently | Efficient bulk sending, connection reuse |
|
||||||
|
| **Reliability** | Staying up under attack/load | Retrying failures, handling timeouts |
|
||||||
|
| **RFC Compliance** | Server MUST requirements | Client MUST requirements |
|
||||||
|
|
||||||
|
## Test Implementation Priority
|
||||||
|
|
||||||
|
1. **Critical** (implement first):
|
||||||
|
- Basic connection and command sending
|
||||||
|
- Authentication mechanisms
|
||||||
|
- Error handling and retry logic
|
||||||
|
- TLS/Security features
|
||||||
|
|
||||||
|
2. **High Priority** (implement second):
|
||||||
|
- Connection pooling
|
||||||
|
- Email composition and MIME
|
||||||
|
- Performance optimization
|
||||||
|
- RFC compliance
|
||||||
|
|
||||||
|
3. **Medium Priority** (implement third):
|
||||||
|
- Advanced features (OAuth2, etc.)
|
||||||
|
- Edge case handling
|
||||||
|
- Extended performance tests
|
||||||
|
- Additional RFC extensions
|
||||||
|
|
||||||
|
4. **Low Priority** (implement last):
|
||||||
|
- Proxy support
|
||||||
|
- Certificate pinning
|
||||||
|
- Unusual scenarios
|
||||||
|
- Optional RFC features
|
||||||
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { tap, expect } from '@push.rocks/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();
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
|
||||||
import * as plugins from '../ts/plugins.js';
|
|
||||||
import { SzPlatformService } from '../ts/platformservice.js';
|
|
||||||
import { BounceManager, BounceType, BounceCategory } from '../ts/email/classes.bouncemanager.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 as Smartmail
|
|
||||||
const bounceEmail = new plugins.smartmail.Smartmail({
|
|
||||||
from: 'mailer-daemon@example.com',
|
|
||||||
subject: 'Mail delivery failed: returning message to sender',
|
|
||||||
body: `
|
|
||||||
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
|
|
||||||
`,
|
|
||||||
creationObjectRef: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
175
test/test.config.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# DCRouter Test Configuration
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Run All Tests
|
||||||
|
```bash
|
||||||
|
cd dcrouter
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Specific Category
|
||||||
|
```bash
|
||||||
|
# Run all connection tests
|
||||||
|
tsx test/run-category.ts connection
|
||||||
|
|
||||||
|
# Run all security tests
|
||||||
|
tsx test/run-category.ts security
|
||||||
|
|
||||||
|
# Run all performance tests
|
||||||
|
tsx test/run-category.ts performance
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Individual Test File
|
||||||
|
```bash
|
||||||
|
# Run TLS connection test
|
||||||
|
tsx test/suite/connection/test.tls-connection.ts
|
||||||
|
|
||||||
|
# Run authentication test
|
||||||
|
tsx test/suite/security/test.authentication.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Tests with Verbose Output
|
||||||
|
```bash
|
||||||
|
# All tests with verbose logging
|
||||||
|
pnpm test -- --verbose
|
||||||
|
|
||||||
|
# Individual test with verbose
|
||||||
|
tsx test/suite/connection/test.tls-connection.ts --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Server Configuration
|
||||||
|
|
||||||
|
Each test file starts its own SMTP server with specific configuration. Common configurations:
|
||||||
|
|
||||||
|
### Basic Server
|
||||||
|
```typescript
|
||||||
|
const testServer = await startTestServer({
|
||||||
|
port: 2525,
|
||||||
|
hostname: 'localhost'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### TLS-Enabled Server
|
||||||
|
```typescript
|
||||||
|
const testServer = await startTestServer({
|
||||||
|
port: 2525,
|
||||||
|
hostname: 'localhost',
|
||||||
|
tlsEnabled: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authenticated Server
|
||||||
|
```typescript
|
||||||
|
const testServer = await startTestServer({
|
||||||
|
port: 2525,
|
||||||
|
hostname: 'localhost',
|
||||||
|
authRequired: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### High-Performance Server
|
||||||
|
```typescript
|
||||||
|
const testServer = await startTestServer({
|
||||||
|
port: 2525,
|
||||||
|
hostname: 'localhost',
|
||||||
|
maxConnections: 1000,
|
||||||
|
size: 50 * 1024 * 1024 // 50MB
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Port Allocation
|
||||||
|
|
||||||
|
Tests use different ports to avoid conflicts:
|
||||||
|
- Connection tests: 2525-2530
|
||||||
|
- Command tests: 2531-2540
|
||||||
|
- Email processing: 2541-2550
|
||||||
|
- Security tests: 2551-2560
|
||||||
|
- Performance tests: 2561-2570
|
||||||
|
- Edge cases: 2571-2580
|
||||||
|
- RFC compliance: 2581-2590
|
||||||
|
|
||||||
|
## Test Utilities
|
||||||
|
|
||||||
|
### Server Lifecycle
|
||||||
|
All tests follow this pattern:
|
||||||
|
```typescript
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
||||||
|
|
||||||
|
let testServer;
|
||||||
|
|
||||||
|
tap.test('setup', async () => {
|
||||||
|
testServer = await startTestServer({ port: 2525 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Your tests here...
|
||||||
|
|
||||||
|
tap.test('cleanup', async () => {
|
||||||
|
await stopTestServer(testServer);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
### SMTP Client Testing
|
||||||
|
```typescript
|
||||||
|
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
|
||||||
|
|
||||||
|
const client = createTestSmtpClient({
|
||||||
|
host: 'localhost',
|
||||||
|
port: 2525
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Low-Level SMTP Testing
|
||||||
|
```typescript
|
||||||
|
import { connectToSmtp, sendSmtpCommand } from '../../helpers/test.utils.js';
|
||||||
|
|
||||||
|
const socket = await connectToSmtp('localhost', 2525);
|
||||||
|
const response = await sendSmtpCommand(socket, 'EHLO test.example.com', '250');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Benchmarks
|
||||||
|
|
||||||
|
Expected minimums for production:
|
||||||
|
- Throughput: >10 emails/second
|
||||||
|
- Concurrent connections: >100
|
||||||
|
- Memory increase: <2% under load
|
||||||
|
- Connection time: <5000ms
|
||||||
|
- Error rate: <5%
|
||||||
|
|
||||||
|
## Debugging Failed Tests
|
||||||
|
|
||||||
|
### Enable Verbose Logging
|
||||||
|
```bash
|
||||||
|
DEBUG=* tsx test/suite/connection/test.tls-connection.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Server Logs
|
||||||
|
Tests output server logs to console. Look for:
|
||||||
|
- 🚀 Server start messages
|
||||||
|
- 📧 Email processing logs
|
||||||
|
- ❌ Error messages
|
||||||
|
- ✅ Success confirmations
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Port Already in Use**
|
||||||
|
- Tests use unique ports
|
||||||
|
- Check for orphaned processes: `lsof -i :2525`
|
||||||
|
- Kill process: `kill -9 <PID>`
|
||||||
|
|
||||||
|
2. **TLS Certificate Errors**
|
||||||
|
- Tests use self-signed certificates
|
||||||
|
- Production should use real certificates
|
||||||
|
|
||||||
|
3. **Timeout Errors**
|
||||||
|
- Increase timeout in test configuration
|
||||||
|
- Check network connectivity
|
||||||
|
- Verify server started successfully
|
||||||
|
|
||||||
|
4. **Authentication Failures**
|
||||||
|
- Test servers may not validate credentials
|
||||||
|
- Check authRequired configuration
|
||||||
|
- Verify AUTH mechanisms supported
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import { ContentScanner, ThreatCategory } from '../ts/security/classes.contentscanner.js';
|
import { ContentScanner, ThreatCategory } from '../ts/security/classes.contentscanner.js';
|
||||||
import { Email } from '../ts/mta/classes.email.js';
|
import { Email } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
// Test instantiation
|
// Test instantiation
|
||||||
tap.test('ContentScanner - should be instantiable', async () => {
|
tap.test('ContentScanner - should be instantiable', async () => {
|
||||||
|
|||||||
159
test/test.dcrouter.email.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
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 } 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: Record<number, number> = {
|
||||||
|
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 using smartmta interfaces
|
||||||
|
const emailConfig: IUnifiedEmailServerOptions = {
|
||||||
|
ports: [25, 587, 465, 2525],
|
||||||
|
hostname: 'mail.example.com',
|
||||||
|
maxMessageSize: 50 * 1024 * 1024, // 50MB
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deliver-example-org',
|
||||||
|
match: {
|
||||||
|
recipients: '*@example.org',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'deliver',
|
||||||
|
process: {
|
||||||
|
dkim: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create DcRouter options with custom email port configuration
|
||||||
|
const options: IDcRouterOptions = {
|
||||||
|
emailConfig,
|
||||||
|
emailPortConfig: {
|
||||||
|
portMapping: customPortMapping,
|
||||||
|
portSettings: {
|
||||||
|
2525: {
|
||||||
|
terminateTls: false,
|
||||||
|
routeName: 'custom-smtp-route'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
contactEmail: 'test@example.com'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Test the generateEmailRoutes method
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Check the custom port configuration
|
||||||
|
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.targets[0].port).toEqual(12525);
|
||||||
|
|
||||||
|
// Check standard port mappings
|
||||||
|
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.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.targets[0].port).toEqual(11587);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||||
|
// Create a basic email configuration
|
||||||
|
const emailConfig: IUnifiedEmailServerOptions = {
|
||||||
|
ports: [2525],
|
||||||
|
hostname: 'mail.example.com',
|
||||||
|
domains: [],
|
||||||
|
routes: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create DcRouter options
|
||||||
|
const options: IDcRouterOptions = {
|
||||||
|
emailConfig,
|
||||||
|
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 unified email server was initialized
|
||||||
|
expect(router.emailServer).toBeTruthy();
|
||||||
|
|
||||||
|
// Stop the router
|
||||||
|
await router.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Final clean-up test
|
||||||
|
tap.test('clean up after tests', async () => {
|
||||||
|
// No-op
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
|
||||||
import * as plugins from '../ts/plugins.js';
|
|
||||||
import {
|
|
||||||
DcRouter,
|
|
||||||
type IDcRouterOptions,
|
|
||||||
type IEmailConfig,
|
|
||||||
type EmailProcessingMode,
|
|
||||||
type IDomainRule
|
|
||||||
} from '../ts/dcrouter/index.js';
|
|
||||||
|
|
||||||
tap.test('DcRouter class - basic functionality', async () => {
|
|
||||||
// Create a simple DcRouter instance
|
|
||||||
const options: IDcRouterOptions = {
|
|
||||||
tls: {
|
|
||||||
contactEmail: 'test@example.com'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const router = new DcRouter(options);
|
|
||||||
expect(router).toBeTruthy();
|
|
||||||
expect(router instanceof DcRouter).toEqual(true);
|
|
||||||
expect(router.options.tls.contactEmail).toEqual('test@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DcRouter class - SmartProxy configuration', async () => {
|
|
||||||
// Create SmartProxy configuration
|
|
||||||
const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = {
|
|
||||||
fromPort: 443,
|
|
||||||
toPort: 8080,
|
|
||||||
targetIP: '10.0.0.10',
|
|
||||||
sniEnabled: true,
|
|
||||||
acme: {
|
|
||||||
port: 80,
|
|
||||||
enabled: true,
|
|
||||||
autoRenew: true,
|
|
||||||
useProduction: false,
|
|
||||||
renewThresholdDays: 30,
|
|
||||||
accountEmail: 'admin@example.com'
|
|
||||||
},
|
|
||||||
globalPortRanges: [
|
|
||||||
{ from: 80, to: 80 },
|
|
||||||
{ from: 443, to: 443 }
|
|
||||||
],
|
|
||||||
domainConfigs: [
|
|
||||||
{
|
|
||||||
domains: ['example.com', 'www.example.com'],
|
|
||||||
allowedIPs: ['0.0.0.0/0'],
|
|
||||||
targetIPs: ['10.0.0.10'],
|
|
||||||
portRanges: [
|
|
||||||
{ from: 80, to: 80 },
|
|
||||||
{ from: 443, to: 443 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const options: IDcRouterOptions = {
|
|
||||||
smartProxyConfig,
|
|
||||||
tls: {
|
|
||||||
contactEmail: 'test@example.com'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const router = new DcRouter(options);
|
|
||||||
expect(router.options.smartProxyConfig).toBeTruthy();
|
|
||||||
expect(router.options.smartProxyConfig.domainConfigs.length).toEqual(1);
|
|
||||||
expect(router.options.smartProxyConfig.domainConfigs[0].domains[0]).toEqual('example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DcRouter class - Email configuration', async () => {
|
|
||||||
// Create consolidated email configuration
|
|
||||||
const emailConfig: IEmailConfig = {
|
|
||||||
ports: [25, 587, 465],
|
|
||||||
hostname: 'mail.example.com',
|
|
||||||
maxMessageSize: 50 * 1024 * 1024, // 50MB
|
|
||||||
|
|
||||||
defaultMode: 'forward' as EmailProcessingMode,
|
|
||||||
defaultServer: 'fallback-mail.example.com',
|
|
||||||
defaultPort: 25,
|
|
||||||
defaultTls: true,
|
|
||||||
|
|
||||||
domainRules: [
|
|
||||||
{
|
|
||||||
pattern: '*@example.com',
|
|
||||||
mode: 'forward' as EmailProcessingMode,
|
|
||||||
target: {
|
|
||||||
server: 'mail1.example.com',
|
|
||||||
port: 25,
|
|
||||||
useTls: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pattern: '*@example.org',
|
|
||||||
mode: 'mta' as EmailProcessingMode,
|
|
||||||
mtaOptions: {
|
|
||||||
domain: 'example.org',
|
|
||||||
allowLocalDelivery: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const options: IDcRouterOptions = {
|
|
||||||
emailConfig,
|
|
||||||
tls: {
|
|
||||||
contactEmail: 'test@example.com'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const router = new DcRouter(options);
|
|
||||||
expect(router.options.emailConfig).toBeTruthy();
|
|
||||||
expect(router.options.emailConfig.ports.length).toEqual(3);
|
|
||||||
expect(router.options.emailConfig.domainRules.length).toEqual(2);
|
|
||||||
expect(router.options.emailConfig.domainRules[0].pattern).toEqual('*@example.com');
|
|
||||||
expect(router.options.emailConfig.domainRules[1].pattern).toEqual('*@example.org');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DcRouter class - Domain pattern matching', async () => {
|
|
||||||
const router = new DcRouter({});
|
|
||||||
|
|
||||||
// Use the internal method for testing if accessible
|
|
||||||
// This requires knowledge of the implementation, so it's a bit brittle
|
|
||||||
if (typeof router['isDomainMatch'] === 'function') {
|
|
||||||
// Test exact match
|
|
||||||
expect(router['isDomainMatch']('example.com', 'example.com')).toEqual(true);
|
|
||||||
expect(router['isDomainMatch']('example.com', 'example.org')).toEqual(false);
|
|
||||||
|
|
||||||
// Test wildcard match
|
|
||||||
expect(router['isDomainMatch']('sub.example.com', '*.example.com')).toEqual(true);
|
|
||||||
expect(router['isDomainMatch']('sub.sub.example.com', '*.example.com')).toEqual(true);
|
|
||||||
expect(router['isDomainMatch']('example.com', '*.example.com')).toEqual(false);
|
|
||||||
expect(router['isDomainMatch']('sub.example.org', '*.example.com')).toEqual(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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 a function to run all tests
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { tap, expect } from '@push.rocks/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();
|
|
||||||
140
test/test.dns-server-config.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test DNS server configuration and record registration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
|
// Test DNS configuration
|
||||||
|
const testDnsConfig = {
|
||||||
|
udpPort: 5353, // Use non-privileged port for testing
|
||||||
|
httpsPort: 8443,
|
||||||
|
httpsKey: './test/fixtures/test-key.pem',
|
||||||
|
httpsCert: './test/fixtures/test-cert.pem',
|
||||||
|
dnssecZone: 'test.example.com',
|
||||||
|
records: [
|
||||||
|
{ name: 'test.example.com', type: 'A', value: '192.168.1.1' },
|
||||||
|
{ name: 'mail.test.example.com', type: 'A', value: '192.168.1.2' },
|
||||||
|
{ name: 'test.example.com', type: 'MX', value: '10 mail.test.example.com' },
|
||||||
|
{ name: 'test.example.com', type: 'TXT', value: 'v=spf1 a:mail.test.example.com ~all' },
|
||||||
|
{ name: 'test.example.com', type: 'NS', value: 'ns1.test.example.com' },
|
||||||
|
{ name: 'ns1.test.example.com', type: 'A', value: '192.168.1.1' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('DNS server configuration - should extract records correctly', async () => {
|
||||||
|
const { records, ...dnsServerOptions } = testDnsConfig;
|
||||||
|
|
||||||
|
expect(dnsServerOptions.udpPort).toEqual(5353);
|
||||||
|
expect(dnsServerOptions.httpsPort).toEqual(8443);
|
||||||
|
expect(dnsServerOptions.dnssecZone).toEqual('test.example.com');
|
||||||
|
expect(records).toBeArray();
|
||||||
|
expect(records.length).toEqual(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DNS server configuration - should handle record parsing', async () => {
|
||||||
|
const parseDnsRecordData = (type: string, value: string): any => {
|
||||||
|
switch (type) {
|
||||||
|
case 'A':
|
||||||
|
return value;
|
||||||
|
case 'MX':
|
||||||
|
const [priority, exchange] = value.split(' ');
|
||||||
|
return { priority: parseInt(priority), exchange };
|
||||||
|
case 'TXT':
|
||||||
|
return value;
|
||||||
|
case 'NS':
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test A record parsing
|
||||||
|
const aRecord = parseDnsRecordData('A', '192.168.1.1');
|
||||||
|
expect(aRecord).toEqual('192.168.1.1');
|
||||||
|
|
||||||
|
// Test MX record parsing
|
||||||
|
const mxRecord = parseDnsRecordData('MX', '10 mail.test.example.com');
|
||||||
|
expect(mxRecord).toHaveProperty('priority', 10);
|
||||||
|
expect(mxRecord).toHaveProperty('exchange', 'mail.test.example.com');
|
||||||
|
|
||||||
|
// Test TXT record parsing
|
||||||
|
const txtRecord = parseDnsRecordData('TXT', 'v=spf1 a:mail.test.example.com ~all');
|
||||||
|
expect(txtRecord).toEqual('v=spf1 a:mail.test.example.com ~all');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DNS server configuration - should group records by domain', async () => {
|
||||||
|
const records = testDnsConfig.records;
|
||||||
|
const recordsByDomain = new Map<string, typeof records>();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
|
||||||
|
if (!recordsByDomain.has(pattern)) {
|
||||||
|
recordsByDomain.set(pattern, []);
|
||||||
|
}
|
||||||
|
recordsByDomain.get(pattern)!.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check grouping
|
||||||
|
expect(recordsByDomain.size).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify each group has records
|
||||||
|
for (const [pattern, domainRecords] of recordsByDomain) {
|
||||||
|
expect(domainRecords.length).toBeGreaterThan(0);
|
||||||
|
console.log(`Pattern: ${pattern}, Records: ${domainRecords.length}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DNS server configuration - should extract unique record types', async () => {
|
||||||
|
const records = testDnsConfig.records;
|
||||||
|
const recordTypes = [...new Set(records.map(r => r.type))];
|
||||||
|
|
||||||
|
expect(recordTypes).toContain('A');
|
||||||
|
expect(recordTypes).toContain('MX');
|
||||||
|
expect(recordTypes).toContain('TXT');
|
||||||
|
expect(recordTypes).toContain('NS');
|
||||||
|
|
||||||
|
console.log('Unique record types:', recordTypes.join(', '));
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DNS server - mock handler registration', async () => {
|
||||||
|
// Mock DNS server for testing
|
||||||
|
const mockDnsServer = {
|
||||||
|
handlers: new Map<string, any>(),
|
||||||
|
registerHandler: function(pattern: string, types: string[], handler: Function) {
|
||||||
|
this.handlers.set(pattern, { types, handler });
|
||||||
|
console.log(`Registered handler for pattern: ${pattern}, types: ${types.join(', ')}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate record registration
|
||||||
|
const records = testDnsConfig.records;
|
||||||
|
const recordsByDomain = new Map<string, typeof records>();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
|
||||||
|
if (!recordsByDomain.has(pattern)) {
|
||||||
|
recordsByDomain.set(pattern, []);
|
||||||
|
}
|
||||||
|
recordsByDomain.get(pattern)!.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register handlers
|
||||||
|
for (const [domainPattern, domainRecords] of recordsByDomain) {
|
||||||
|
const recordTypes = [...new Set(domainRecords.map(r => r.type))];
|
||||||
|
mockDnsServer.registerHandler(domainPattern, recordTypes, (question: any) => {
|
||||||
|
const matchingRecord = domainRecords.find(
|
||||||
|
r => r.name === question.name && r.type === question.type
|
||||||
|
);
|
||||||
|
return matchingRecord || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockDnsServer.handlers.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start({
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
148
test/test.dns-socket-handler.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
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 NOT instantiate DNS server when dnsNsDomains is not set', async () => {
|
||||||
|
dcRouter = new DcRouter({
|
||||||
|
smartProxyConfig: {
|
||||||
|
routes: []
|
||||||
|
},
|
||||||
|
cacheConfig: { enabled: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
await dcRouter.start();
|
||||||
|
|
||||||
|
// Check that DNS server is not created
|
||||||
|
expect((dcRouter as any).dnsServer).toBeUndefined();
|
||||||
|
|
||||||
|
await dcRouter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should generate DNS routes when dnsNsDomains is set', async () => {
|
||||||
|
// This test checks the route generation logic WITHOUT starting the full DcRouter
|
||||||
|
// Starting DcRouter would require DNS port 53 and cause conflicts
|
||||||
|
|
||||||
|
dcRouter = new DcRouter({
|
||||||
|
dnsNsDomains: ['ns1.test.local', 'ns2.test.local'],
|
||||||
|
dnsScopes: ['test.local'],
|
||||||
|
smartProxyConfig: {
|
||||||
|
routes: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check routes are generated correctly (without starting)
|
||||||
|
const generatedRoutes = (dcRouter as any).generateDnsRoutes();
|
||||||
|
expect(generatedRoutes.length).toEqual(2); // /dns-query and /resolve
|
||||||
|
|
||||||
|
// Check that routes have socket-handler action
|
||||||
|
generatedRoutes.forEach((route: any) => {
|
||||||
|
expect(route.action.type).toEqual('socket-handler');
|
||||||
|
expect(route.action.socketHandler).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify routes target the primary nameserver
|
||||||
|
const dnsQueryRoute = generatedRoutes.find((r: any) => r.name === 'dns-over-https-dns-query');
|
||||||
|
expect(dnsQueryRoute).toBeDefined();
|
||||||
|
expect(dnsQueryRoute.match.domains).toContain('ns1.test.local');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create DNS routes with correct configuration', async () => {
|
||||||
|
dcRouter = new DcRouter({
|
||||||
|
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||||
|
dnsScopes: ['example.com'],
|
||||||
|
smartProxyConfig: {
|
||||||
|
routes: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Access the private method to generate routes
|
||||||
|
const dnsRoutes = (dcRouter as any).generateDnsRoutes();
|
||||||
|
|
||||||
|
expect(dnsRoutes.length).toEqual(2);
|
||||||
|
|
||||||
|
// Check first route (dns-query) - uses primary nameserver (first in array)
|
||||||
|
const dnsQueryRoute = dnsRoutes.find((r: any) => r.name === 'dns-over-https-dns-query');
|
||||||
|
expect(dnsQueryRoute).toBeDefined();
|
||||||
|
expect(dnsQueryRoute.match.ports).toContain(443);
|
||||||
|
expect(dnsQueryRoute.match.domains).toContain('ns1.example.com');
|
||||||
|
expect(dnsQueryRoute.match.path).toEqual('/dns-query');
|
||||||
|
|
||||||
|
// Check second route (resolve)
|
||||||
|
const resolveRoute = dnsRoutes.find((r: any) => r.name === 'dns-over-https-resolve');
|
||||||
|
expect(resolveRoute).toBeDefined();
|
||||||
|
expect(resolveRoute.match.ports).toContain(443);
|
||||||
|
expect(resolveRoute.match.domains).toContain('ns1.example.com');
|
||||||
|
expect(resolveRoute.match.path).toEqual('/resolve');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DNS socket handler should be created correctly', async () => {
|
||||||
|
// This test verifies the socket handler creation WITHOUT starting the full router
|
||||||
|
dcRouter = new DcRouter({
|
||||||
|
dnsNsDomains: ['ns1.test.local', 'ns2.test.local'],
|
||||||
|
dnsScopes: ['test.local'],
|
||||||
|
smartProxyConfig: {
|
||||||
|
routes: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the socket handler (this doesn't require DNS server to be started)
|
||||||
|
const socketHandler = (dcRouter as any).createDnsSocketHandler();
|
||||||
|
expect(socketHandler).toBeDefined();
|
||||||
|
expect(typeof socketHandler).toEqual('function');
|
||||||
|
|
||||||
|
// Create a mock socket to test the handler behavior without DNS server
|
||||||
|
const mockSocket = new plugins.net.Socket();
|
||||||
|
let socketEnded = false;
|
||||||
|
|
||||||
|
mockSocket.end = () => {
|
||||||
|
socketEnded = true;
|
||||||
|
return mockSocket;
|
||||||
|
};
|
||||||
|
|
||||||
|
// When DNS server is not initialized, the handler should end the socket
|
||||||
|
try {
|
||||||
|
await socketHandler(mockSocket);
|
||||||
|
} catch (error) {
|
||||||
|
// Expected - DNS server not initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Socket should be ended because DNS server wasn't started
|
||||||
|
expect(socketEnded).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DNS routes should only be generated when dnsNsDomains is configured', async () => {
|
||||||
|
// Test without DNS configuration - should return empty routes
|
||||||
|
dcRouter = new DcRouter({
|
||||||
|
smartProxyConfig: {
|
||||||
|
routes: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const routesWithoutDns = (dcRouter as any).generateDnsRoutes();
|
||||||
|
expect(routesWithoutDns.length).toEqual(0);
|
||||||
|
|
||||||
|
// Test with DNS configuration - should return routes
|
||||||
|
const dcRouterWithDns = new DcRouter({
|
||||||
|
dnsNsDomains: ['ns1.example.com'],
|
||||||
|
dnsScopes: ['example.com'],
|
||||||
|
smartProxyConfig: {
|
||||||
|
routes: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const routesWithDns = (dcRouterWithDns as any).generateDnsRoutes();
|
||||||
|
expect(routesWithDns.length).toEqual(2);
|
||||||
|
|
||||||
|
// Verify socket handler can be created
|
||||||
|
const socketHandler = (dcRouterWithDns as any).createDnsSocketHandler();
|
||||||
|
expect(socketHandler).toBeDefined();
|
||||||
|
expect(typeof socketHandler).toEqual('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
|
||||||
import { SzPlatformService } from '../ts/platformservice.js';
|
|
||||||
import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mta/classes.spfverifier.js';
|
|
||||||
import { DmarcVerifier, DmarcPolicy, DmarcAlignment } from '../ts/mta/classes.dmarcverifier.js';
|
|
||||||
import { Email } from '../ts/mta/classes.email.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test email authentication systems: SPF and DMARC
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Setup platform service for testing
|
|
||||||
let platformService: SzPlatformService;
|
|
||||||
|
|
||||||
tap.test('Setup test environment', async () => {
|
|
||||||
platformService = new SzPlatformService();
|
|
||||||
// Use start() instead of init() which doesn't exist
|
|
||||||
await platformService.start();
|
|
||||||
expect(platformService.mtaService).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// SPF Verifier Tests
|
|
||||||
tap.test('SPF Verifier - should parse SPF record', async () => {
|
|
||||||
const spfVerifier = new SpfVerifier(platformService.mtaService);
|
|
||||||
|
|
||||||
// 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(platformService.mtaService);
|
|
||||||
|
|
||||||
// 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(platformService.mtaService);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
);
|
|
||||||
|
|
||||||
// We can now see the actual DMARC result and update our expectations
|
|
||||||
|
|
||||||
expect(dmarcResult2).toBeTruthy();
|
|
||||||
expect(dmarcResult2.spfPassed).toEqual(true);
|
|
||||||
expect(dmarcResult2.dkimPassed).toEqual(true);
|
|
||||||
expect(dmarcResult2.spfDomainAligned).toEqual(false);
|
|
||||||
expect(dmarcResult2.dkimDomainAligned).toEqual(false);
|
|
||||||
|
|
||||||
// The test environment is returning a 'reject' policy - we can verify that
|
|
||||||
expect(dmarcResult2.policyEvaluated).toEqual('reject');
|
|
||||||
expect(dmarcResult2.actualPolicy).toEqual('reject');
|
|
||||||
expect(dmarcResult2.action).toEqual('reject');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('DMARC Verifier - should apply policy correctly', async () => {
|
|
||||||
const dmarcVerifier = new DmarcVerifier(platformService.mtaService);
|
|
||||||
|
|
||||||
// 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('Cleanup test environment', async () => {
|
|
||||||
await platformService.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('stop', async () => {
|
|
||||||
await tap.stopForcefully();
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
274
test/test.errors.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as errors from '../ts/errors/index.js';
|
||||||
|
import {
|
||||||
|
PlatformError,
|
||||||
|
ValidationError,
|
||||||
|
NetworkError,
|
||||||
|
ResourceError,
|
||||||
|
OperationError
|
||||||
|
} from '../ts/errors/base.errors.js';
|
||||||
|
import {
|
||||||
|
ErrorSeverity,
|
||||||
|
ErrorCategory,
|
||||||
|
ErrorRecoverability
|
||||||
|
} from '../ts/errors/error.codes.js';
|
||||||
|
import {
|
||||||
|
ErrorHandler
|
||||||
|
} from '../ts/errors/error-handler.js';
|
||||||
|
|
||||||
|
// Test base error classes
|
||||||
|
tap.test('Base error classes should set properties correctly', async () => {
|
||||||
|
const message = 'Test error message';
|
||||||
|
const code = 'TEST_ERROR_CODE';
|
||||||
|
const context = {
|
||||||
|
component: 'TestComponent',
|
||||||
|
operation: 'testOperation',
|
||||||
|
data: { foo: 'bar' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test PlatformError
|
||||||
|
const platformError = new PlatformError(
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
ErrorSeverity.MEDIUM,
|
||||||
|
ErrorCategory.OPERATION,
|
||||||
|
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(platformError.message).toEqual(message);
|
||||||
|
expect(platformError.code).toEqual(code);
|
||||||
|
expect(platformError.severity).toEqual(ErrorSeverity.MEDIUM);
|
||||||
|
expect(platformError.category).toEqual(ErrorCategory.OPERATION);
|
||||||
|
expect(platformError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
|
||||||
|
expect(platformError.context?.component).toEqual(context.component);
|
||||||
|
expect(platformError.context?.operation).toEqual(context.operation);
|
||||||
|
expect(platformError.context?.data?.foo).toEqual('bar');
|
||||||
|
expect(platformError.name).toEqual('PlatformError');
|
||||||
|
|
||||||
|
// Test ValidationError
|
||||||
|
const validationError = new ValidationError(message, code, context);
|
||||||
|
expect(validationError.category).toEqual(ErrorCategory.VALIDATION);
|
||||||
|
expect(validationError.severity).toEqual(ErrorSeverity.LOW);
|
||||||
|
|
||||||
|
// Test NetworkError
|
||||||
|
const networkError = new NetworkError(message, code, context);
|
||||||
|
expect(networkError.category).toEqual(ErrorCategory.CONNECTIVITY);
|
||||||
|
expect(networkError.severity).toEqual(ErrorSeverity.MEDIUM);
|
||||||
|
expect(networkError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
|
||||||
|
|
||||||
|
// Test ResourceError
|
||||||
|
const resourceError = new ResourceError(message, code, context);
|
||||||
|
expect(resourceError.category).toEqual(ErrorCategory.RESOURCE);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test error handler utility
|
||||||
|
tap.test('ErrorHandler should properly handle and format errors', async () => {
|
||||||
|
// Configure error handler
|
||||||
|
ErrorHandler.configure({
|
||||||
|
logErrors: false, // Disable for testing
|
||||||
|
includeStacksInProd: false,
|
||||||
|
retry: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
baseDelay: 100,
|
||||||
|
maxDelay: 1000,
|
||||||
|
backoffFactor: 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test converting regular Error to PlatformError
|
||||||
|
const regularError = new Error('Something went wrong');
|
||||||
|
const platformError = ErrorHandler.toPlatformError(
|
||||||
|
regularError,
|
||||||
|
'PLATFORM_OPERATION_ERROR',
|
||||||
|
{ component: 'TestHandler' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(platformError).toBeInstanceOf(PlatformError);
|
||||||
|
expect(platformError.code).toEqual('PLATFORM_OPERATION_ERROR');
|
||||||
|
expect(platformError.context?.component).toEqual('TestHandler');
|
||||||
|
|
||||||
|
// Test formatting error for API response
|
||||||
|
const formattedError = ErrorHandler.formatErrorForResponse(platformError, true);
|
||||||
|
expect(formattedError.code).toEqual('PLATFORM_OPERATION_ERROR');
|
||||||
|
expect(formattedError.message).toEqual('An unexpected error occurred.');
|
||||||
|
expect(formattedError.details?.rawMessage).toEqual('Something went wrong');
|
||||||
|
|
||||||
|
// Test executing a function with error handling
|
||||||
|
let executed = false;
|
||||||
|
try {
|
||||||
|
await ErrorHandler.execute(async () => {
|
||||||
|
executed = true;
|
||||||
|
throw new Error('Execution failed');
|
||||||
|
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(PlatformError);
|
||||||
|
expect(error.code).toEqual('TEST_EXECUTION_ERROR');
|
||||||
|
expect(error.context.operation).toEqual('testExecution');
|
||||||
|
}
|
||||||
|
expect(executed).toEqual(true);
|
||||||
|
|
||||||
|
// Test executeWithRetry successful after retries
|
||||||
|
let attempts = 0;
|
||||||
|
const result = await ErrorHandler.executeWithRetry(
|
||||||
|
async () => {
|
||||||
|
attempts++;
|
||||||
|
if (attempts < 3) {
|
||||||
|
throw new Error('Temporary failure');
|
||||||
|
}
|
||||||
|
return 'success';
|
||||||
|
},
|
||||||
|
'TEST_RETRY_ERROR',
|
||||||
|
{
|
||||||
|
maxAttempts: 5,
|
||||||
|
baseDelay: 10, // Use small delay for tests
|
||||||
|
retryableErrorPatterns: [/Temporary failure/], // Add pattern to make error retryable
|
||||||
|
onRetry: (error, attempt, delay) => {
|
||||||
|
expect(error).toBeInstanceOf(PlatformError);
|
||||||
|
expect(attempt).toBeGreaterThan(0);
|
||||||
|
expect(delay).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual('success');
|
||||||
|
expect(attempts).toEqual(3);
|
||||||
|
|
||||||
|
// Test executeWithRetry that fails after max attempts
|
||||||
|
attempts = 0;
|
||||||
|
try {
|
||||||
|
await ErrorHandler.executeWithRetry(
|
||||||
|
async () => {
|
||||||
|
attempts++;
|
||||||
|
throw new Error('Persistent failure');
|
||||||
|
},
|
||||||
|
'TEST_RETRY_ERROR',
|
||||||
|
{
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelay: 10,
|
||||||
|
retryableErrorPatterns: [/Persistent failure/] // Make error retryable so it tries all attempts
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(PlatformError);
|
||||||
|
expect(attempts).toEqual(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test retry utilities
|
||||||
|
tap.test('Error retry utilities should work correctly', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await errors.retry(
|
||||||
|
async () => {
|
||||||
|
attempts++;
|
||||||
|
if (attempts < 3) {
|
||||||
|
throw new Error('Temporary error');
|
||||||
|
}
|
||||||
|
return 'success';
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxRetries: 5,
|
||||||
|
initialDelay: 20,
|
||||||
|
backoffFactor: 1.5,
|
||||||
|
retryableErrors: [/Temporary/]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Should not reach here
|
||||||
|
expect(false).toEqual(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(attempts).toEqual(3);
|
||||||
|
|
||||||
|
// Test retry with non-retryable error
|
||||||
|
attempts = 0;
|
||||||
|
try {
|
||||||
|
await errors.retry(
|
||||||
|
async () => {
|
||||||
|
attempts++;
|
||||||
|
throw new Error('Critical error');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelay: 10,
|
||||||
|
retryableErrors: [/Temporary/] // Won't match "Critical"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.message).toEqual('Critical error');
|
||||||
|
expect(attempts).toEqual(1); // Should only attempt once
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function that will reject first n times, then resolve
|
||||||
|
interface FlakyFunction {
|
||||||
|
(failTimes: number, result?: any): Promise<any>;
|
||||||
|
counter: number;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flaky: FlakyFunction = Object.assign(
|
||||||
|
async function (failTimes: number, result: any = 'success'): Promise<any> {
|
||||||
|
if (flaky.counter < failTimes) {
|
||||||
|
flaky.counter++;
|
||||||
|
throw new Error(`Flaky failure ${flaky.counter}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
counter: 0,
|
||||||
|
reset: () => { flaky.counter = 0; }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test error wrapping and retry combination
|
||||||
|
tap.test('Error handling can be combined with retry for robust operations', async () => {
|
||||||
|
// Reset counter for the test
|
||||||
|
flaky.reset();
|
||||||
|
|
||||||
|
// Create a wrapped version of the flaky function
|
||||||
|
const wrapped = errors.withErrorHandling(
|
||||||
|
() => flaky(2, 'wrapped success'),
|
||||||
|
'TEST_WRAPPED_ERROR',
|
||||||
|
{ component: 'TestComponent' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute with retry
|
||||||
|
const result = await errors.retry(
|
||||||
|
wrapped,
|
||||||
|
{
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelay: 10,
|
||||||
|
retryableErrors: [/Flaky failure/]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(result).toEqual('wrapped success');
|
||||||
|
expect(flaky.counter).toEqual(2);
|
||||||
|
|
||||||
|
// Reset and test failure case
|
||||||
|
flaky.reset();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await errors.retry(
|
||||||
|
() => flaky(5, 'never reached'),
|
||||||
|
{
|
||||||
|
maxRetries: 2, // Only retry twice, but we need 5 attempts to succeed
|
||||||
|
initialDelay: 10,
|
||||||
|
retryableErrors: [/Flaky failure/] // Add pattern to make it retry
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Should not reach here
|
||||||
|
expect(false).toEqual(true);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.message).toContain('Flaky failure');
|
||||||
|
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
|
||||||
import * as plugins from '../ts/plugins.js';
|
|
||||||
import { SzPlatformService } from '../ts/platformservice.js';
|
|
||||||
import { MtaService } from '../ts/mta/classes.mta.js';
|
|
||||||
import { EmailService } from '../ts/email/classes.emailservice.js';
|
|
||||||
import { BounceManager } from '../ts/email/classes.bouncemanager.js';
|
|
||||||
import DcRouter from '../ts/dcrouter/classes.dcrouter.js';
|
|
||||||
|
|
||||||
// Test the new integration architecture
|
|
||||||
tap.test('should be able to create an independent MTA service', async (tools) => {
|
|
||||||
// Create an independent MTA service
|
|
||||||
const mta = new MtaService(undefined, {
|
|
||||||
smtp: {
|
|
||||||
port: 10025, // Use a different port for testing
|
|
||||||
hostname: 'test.example.com'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify it was created properly without a platform service reference
|
|
||||||
expect(mta).toBeTruthy();
|
|
||||||
expect(mta.platformServiceRef).toBeUndefined();
|
|
||||||
|
|
||||||
// Even without a platform service, it should have its own SMTP rule engine
|
|
||||||
expect(mta.smtpRuleEngine).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('should be able to create an EmailService with an existing MTA', async (tools) => {
|
|
||||||
// Create a platform service first
|
|
||||||
const platformService = new SzPlatformService();
|
|
||||||
|
|
||||||
// Create a shared bounce manager
|
|
||||||
const bounceManager = new BounceManager();
|
|
||||||
|
|
||||||
// Create an independent MTA service
|
|
||||||
const mta = new MtaService(undefined, {
|
|
||||||
smtp: {
|
|
||||||
port: 10025, // Use a different port for testing
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Manually set the bounce manager for testing
|
|
||||||
// @ts-ignore - adding property for testing
|
|
||||||
mta.bounceManager = bounceManager;
|
|
||||||
|
|
||||||
// Create an email service that uses the independent MTA
|
|
||||||
// @ts-ignore - passing a third argument to the constructor
|
|
||||||
const emailService = new EmailService(platformService, {}, mta);
|
|
||||||
|
|
||||||
// Manually set the mtaService property
|
|
||||||
emailService.mtaService = mta;
|
|
||||||
|
|
||||||
// Verify relationships
|
|
||||||
expect(emailService.mtaService === mta).toBeTrue();
|
|
||||||
expect(emailService.bounceManager).toBeTruthy();
|
|
||||||
|
|
||||||
// MTA should not have a direct platform service reference
|
|
||||||
expect(mta.platformServiceRef).toBeUndefined();
|
|
||||||
|
|
||||||
// But it should have access to bounce manager
|
|
||||||
// @ts-ignore - accessing property for testing
|
|
||||||
expect(mta.bounceManager === bounceManager).toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('MTA service should have SMTP rule engine', async (tools) => {
|
|
||||||
// Create an independent MTA service
|
|
||||||
const mta = new MtaService(undefined, {
|
|
||||||
smtp: {
|
|
||||||
port: 10025, // Use a different port for testing
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify the MTA has an SMTP rule engine
|
|
||||||
expect(mta.smtpRuleEngine).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('platform service should support having an MTA service', async (tools) => {
|
|
||||||
// Create a platform service with default config
|
|
||||||
const platformService = new SzPlatformService();
|
|
||||||
|
|
||||||
// Create MTA - don't await start() to avoid binding to ports
|
|
||||||
platformService.mtaService = new MtaService(platformService, {
|
|
||||||
smtp: {
|
|
||||||
port: 10025, // Use a different port for testing
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create email service using the platform
|
|
||||||
platformService.emailService = new EmailService(platformService);
|
|
||||||
|
|
||||||
// Verify the MTA has a reference to the platform service
|
|
||||||
expect(platformService.mtaService).toBeTruthy();
|
|
||||||
expect(platformService.mtaService.platformServiceRef).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('stop', async () => {
|
|
||||||
await tap.stopForcefully();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export for tapbundle execution
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import { IPReputationChecker, ReputationThreshold, IPType } from '../ts/security/classes.ipreputationchecker.js';
|
import { IPReputationChecker, ReputationThreshold, IPType } from '../ts/security/classes.ipreputationchecker.js';
|
||||||
import * as plugins from '../ts/plugins.js';
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
@@ -6,8 +6,8 @@ import * as plugins from '../ts/plugins.js';
|
|||||||
const originalDnsResolve = plugins.dns.promises.resolve;
|
const originalDnsResolve = plugins.dns.promises.resolve;
|
||||||
let mockDnsResolveImpl: (hostname: string) => Promise<string[]> = async () => ['127.0.0.1'];
|
let mockDnsResolveImpl: (hostname: string) => Promise<string[]> = async () => ['127.0.0.1'];
|
||||||
|
|
||||||
// Setup mock DNS resolver
|
// Setup mock DNS resolver with proper typing
|
||||||
plugins.dns.promises.resolve = async (hostname: string) => {
|
(plugins.dns.promises as any).resolve = async (hostname: string) => {
|
||||||
return mockDnsResolveImpl(hostname);
|
return mockDnsResolveImpl(hostname);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
import { tap, expect } from '@push.rocks/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).toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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).toBeTrue();
|
|
||||||
|
|
||||||
// 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)).toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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').toBeTrue();
|
|
||||||
|
|
||||||
// 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).toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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).toBeTrue();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// After all tests, clean up
|
|
||||||
tap.test('cleanup', async () => {
|
|
||||||
cleanupTestData();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('stop', async () => {
|
|
||||||
await tap.stopForcefully();
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
131
test/test.jwt-auth.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
import { TypedRequest } from '@api.global/typedrequest';
|
||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
let testDcRouter: DcRouter;
|
||||||
|
let identity: interfaces.data.IIdentity;
|
||||||
|
|
||||||
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
|
testDcRouter = new DcRouter({
|
||||||
|
// Minimal config for testing
|
||||||
|
cacheConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await testDcRouter.start();
|
||||||
|
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should login with admin credentials and receive JWT', async () => {
|
||||||
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'adminLoginWithUsernameAndPassword'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await loginRequest.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('identity');
|
||||||
|
expect(response.identity).toHaveProperty('jwt');
|
||||||
|
expect(response.identity).toHaveProperty('userId');
|
||||||
|
expect(response.identity).toHaveProperty('name');
|
||||||
|
expect(response.identity).toHaveProperty('expiresAt');
|
||||||
|
expect(response.identity).toHaveProperty('role');
|
||||||
|
expect(response.identity.role).toEqual('admin');
|
||||||
|
|
||||||
|
identity = response.identity;
|
||||||
|
console.log('JWT:', identity.jwt);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should verify valid JWT identity', async () => {
|
||||||
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'verifyIdentity'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await verifyRequest.fire({
|
||||||
|
identity
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('valid');
|
||||||
|
expect(response.valid).toBeTrue();
|
||||||
|
expect(response).toHaveProperty('identity');
|
||||||
|
expect(response.identity.userId).toEqual(identity.userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should reject invalid JWT', async () => {
|
||||||
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'verifyIdentity'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await verifyRequest.fire({
|
||||||
|
identity: {
|
||||||
|
...identity,
|
||||||
|
jwt: 'invalid.jwt.token'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('valid');
|
||||||
|
expect(response.valid).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should verify JWT matches identity data', async () => {
|
||||||
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'verifyIdentity'
|
||||||
|
);
|
||||||
|
|
||||||
|
// The response should contain the same identity data as the JWT
|
||||||
|
const response = await verifyRequest.fire({
|
||||||
|
identity
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('valid');
|
||||||
|
expect(response.valid).toBeTrue();
|
||||||
|
expect(response.identity.expiresAt).toEqual(identity.expiresAt);
|
||||||
|
expect(response.identity.userId).toEqual(identity.userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle logout', async () => {
|
||||||
|
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'adminLogout'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await logoutRequest.fire({
|
||||||
|
identity
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('success');
|
||||||
|
expect(response.success).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should reject wrong credentials', async () => {
|
||||||
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'adminLoginWithUsernameAndPassword'
|
||||||
|
);
|
||||||
|
|
||||||
|
let errorOccurred = false;
|
||||||
|
try {
|
||||||
|
await loginRequest.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'wrongpassword'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorOccurred = true;
|
||||||
|
// TypedResponseError is thrown
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errorOccurred).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should stop DCRouter', async () => {
|
||||||
|
await testDcRouter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { tap, expect } from '@push.rocks/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).toBeTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
123
test/test.opsserver-api.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
import { TypedRequest } from '@api.global/typedrequest';
|
||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
let testDcRouter: DcRouter;
|
||||||
|
let adminIdentity: interfaces.data.IIdentity;
|
||||||
|
|
||||||
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
|
testDcRouter = new DcRouter({
|
||||||
|
// Minimal config for testing
|
||||||
|
cacheConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await testDcRouter.start();
|
||||||
|
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should login as admin', async () => {
|
||||||
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'adminLoginWithUsernameAndPassword'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await loginRequest.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('identity');
|
||||||
|
adminIdentity = response.identity;
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should respond to health status request', async () => {
|
||||||
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'getHealthStatus'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await healthRequest.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
detailed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('health');
|
||||||
|
expect(response.health.healthy).toBeTrue();
|
||||||
|
expect(response.health.services).toHaveProperty('OpsServer');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should respond to server statistics request', async () => {
|
||||||
|
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'getServerStatistics'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await statsRequest.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
includeHistory: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('stats');
|
||||||
|
expect(response.stats).toHaveProperty('uptime');
|
||||||
|
expect(response.stats).toHaveProperty('cpuUsage');
|
||||||
|
expect(response.stats).toHaveProperty('memoryUsage');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should respond to configuration request', async () => {
|
||||||
|
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'getConfiguration'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await configRequest.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('config');
|
||||||
|
expect(response.config).toHaveProperty('system');
|
||||||
|
expect(response.config).toHaveProperty('smartProxy');
|
||||||
|
expect(response.config).toHaveProperty('email');
|
||||||
|
expect(response.config).toHaveProperty('dns');
|
||||||
|
expect(response.config).toHaveProperty('tls');
|
||||||
|
expect(response.config).toHaveProperty('cache');
|
||||||
|
expect(response.config).toHaveProperty('radius');
|
||||||
|
expect(response.config).toHaveProperty('remoteIngress');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle log retrieval request', async () => {
|
||||||
|
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'getRecentLogs'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await logsRequest.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('logs');
|
||||||
|
expect(response).toHaveProperty('total');
|
||||||
|
expect(response).toHaveProperty('hasMore');
|
||||||
|
expect(response.logs).toBeArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should reject unauthenticated requests', async () => {
|
||||||
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'getHealthStatus'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await healthRequest.fire({} as any);
|
||||||
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should stop DCRouter', async () => {
|
||||||
|
await testDcRouter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
127
test/test.protected-endpoint.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
import { TypedRequest } from '@api.global/typedrequest';
|
||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
let testDcRouter: DcRouter;
|
||||||
|
let adminIdentity: interfaces.data.IIdentity;
|
||||||
|
|
||||||
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
|
testDcRouter = new DcRouter({
|
||||||
|
// Minimal config for testing
|
||||||
|
cacheConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await testDcRouter.start();
|
||||||
|
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should login as admin', async () => {
|
||||||
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'adminLoginWithUsernameAndPassword'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await loginRequest.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('identity');
|
||||||
|
adminIdentity = response.identity;
|
||||||
|
console.log('Admin logged in with JWT');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should allow admin to verify identity', async () => {
|
||||||
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'verifyIdentity'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await verifyRequest.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('valid');
|
||||||
|
expect(response.valid).toBeTrue();
|
||||||
|
console.log('Admin identity verified successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should reject verify identity without identity', async () => {
|
||||||
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'verifyIdentity'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await verifyRequest.fire({} as any);
|
||||||
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
console.log('Successfully rejected request without identity');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should reject verify identity with invalid JWT', async () => {
|
||||||
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'verifyIdentity'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await verifyRequest.fire({
|
||||||
|
identity: {
|
||||||
|
...adminIdentity,
|
||||||
|
jwt: 'invalid.jwt.token'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
console.log('Successfully rejected request with invalid JWT');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should reject protected endpoints without auth', async () => {
|
||||||
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'getHealthStatus'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// No identity provided — should be rejected
|
||||||
|
await healthRequest.fire({} as any);
|
||||||
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
console.log('Protected endpoint correctly rejects unauthenticated request');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should allow authenticated access to protected endpoints', async () => {
|
||||||
|
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'getConfiguration'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await configRequest.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('config');
|
||||||
|
expect(response.config).toHaveProperty('system');
|
||||||
|
expect(response.config).toHaveProperty('smartProxy');
|
||||||
|
expect(response.config).toHaveProperty('email');
|
||||||
|
expect(response.config).toHaveProperty('dns');
|
||||||
|
expect(response.config).toHaveProperty('tls');
|
||||||
|
expect(response.config).toHaveProperty('cache');
|
||||||
|
expect(response.config).toHaveProperty('radius');
|
||||||
|
expect(response.config).toHaveProperty('remoteIngress');
|
||||||
|
console.log('Authenticated access to config successful');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should stop DCRouter', async () => {
|
||||||
|
await testDcRouter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
|
||||||
import { RateLimiter } from '../ts/mta/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();
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
|
||||||
import * as plugins from '../ts/plugins.js';
|
|
||||||
import * as paths from '../ts/paths.js';
|
|
||||||
import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.js';
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Before running any tests
|
|
||||||
tap.test('setup', async () => {
|
|
||||||
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: true,
|
|
||||||
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: true,
|
|
||||||
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: true,
|
|
||||||
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)).toBeTrue();
|
|
||||||
expect(summary.length >= 3).toBeTrue();
|
|
||||||
|
|
||||||
// Check that domains are included in the summary
|
|
||||||
const domains = summary.map(item => item.domain);
|
|
||||||
expect(domains.includes('high.com')).toBeTrue();
|
|
||||||
expect(domains.includes('medium.com')).toBeTrue();
|
|
||||||
expect(domains.includes('low.com')).toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test adding and removing domains
|
|
||||||
tap.test('should add and remove domains for monitoring', async () => {
|
|
||||||
resetSingleton();
|
|
||||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
|
||||||
enabled: true,
|
|
||||||
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).toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test handling open and click events
|
|
||||||
tap.test('should track engagement metrics correctly', async () => {
|
|
||||||
resetSingleton();
|
|
||||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
|
||||||
enabled: true,
|
|
||||||
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: true,
|
|
||||||
domains: ['example.com']
|
|
||||||
});
|
|
||||||
|
|
||||||
// Record events over multiple days
|
|
||||||
const today = new Date();
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
const todayStr = today.toISOString().split('T')[0];
|
|
||||||
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: true,
|
|
||||||
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 () => {
|
|
||||||
cleanupTestData();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('stop', async () => {
|
|
||||||
await tap.stopForcefully();
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
import { tap, expect } from '@push.rocks/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/email/classes.emailvalidator.js';
|
|
||||||
import { TemplateManager } from '../ts/email/classes.templatemanager.js';
|
|
||||||
import { Email } from '../ts/mta/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')).toBeTrue();
|
|
||||||
expect(validator.isValidFormat('firstname.lastname@example.com')).toBeTrue();
|
|
||||||
expect(validator.isValidFormat('user+tag@example.com')).toBeTrue();
|
|
||||||
|
|
||||||
// Test invalid email formats
|
|
||||||
expect(validator.isValidFormat('user@')).toBeFalse();
|
|
||||||
expect(validator.isValidFormat('@example.com')).toBeFalse();
|
|
||||||
expect(validator.isValidFormat('user@example')).toBeFalse();
|
|
||||||
expect(validator.isValidFormat('user.example.com')).toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
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).toBeTrue();
|
|
||||||
expect(basicResult.details.formatValid).toBeTrue();
|
|
||||||
|
|
||||||
// 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).toBeFalse();
|
|
||||||
expect(invalidResult.details.formatValid).toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
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: '<p>Hello, {{name}}!</p>',
|
|
||||||
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).toBeTrue();
|
|
||||||
expect(templates.some(t => t.id === 'test-template')).toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('TemplateManager - should create smartmail 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: '<p>Hello, {{name}}! Welcome to our service.</p>',
|
|
||||||
bodyText: 'Hello, {{name}}! Welcome to our service.',
|
|
||||||
category: 'test'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create smartmail from template
|
|
||||||
const smartmail = await templateManager.createSmartmail('welcome-test', {
|
|
||||||
name: 'John Doe'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(smartmail).toBeTruthy();
|
|
||||||
expect(smartmail.options.from).toEqual('welcome@example.com');
|
|
||||||
expect(smartmail.getSubject()).toEqual('Welcome, John Doe!');
|
|
||||||
expect(smartmail.getBody(true).indexOf('Hello, John Doe!') > -1).toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
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: '<p>Welcome, <strong>{{name}}</strong>! Your order #{{orderId}} has been processed.</p>',
|
|
||||||
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('<strong>John Doe</strong>') > -1).toBeTrue();
|
|
||||||
|
|
||||||
// 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).toBeTrue();
|
|
||||||
|
|
||||||
// Update multiple variables at once
|
|
||||||
email.setVariables({
|
|
||||||
orderId: '67890',
|
|
||||||
status: 'delivered'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(email.getTextWithVariables().indexOf('67890') > -1).toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
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: '<p>This is a test email.</p>',
|
|
||||||
creationObjectRef: {
|
|
||||||
orderId: '12345'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add recipient and attachment
|
|
||||||
smartmail.addRecipient('recipient@example.com');
|
|
||||||
|
|
||||||
const attachment = await plugins.smartfile.SmartFile.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).toBeTrue();
|
|
||||||
expect(email.subject).toEqual('Test Subject');
|
|
||||||
expect(email.html?.indexOf('This is a test email') > -1).toBeTrue();
|
|
||||||
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)).toBeTrue();
|
|
||||||
expect(convertedSmartmail.options.to.length).toEqual(1);
|
|
||||||
expect(convertedSmartmail.getSubject()).toEqual('Test Subject');
|
|
||||||
expect(convertedSmartmail.getBody(true).indexOf('This is a test email') > -1).toBeTrue();
|
|
||||||
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).toBeTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(errorThrown).toBeTrue();
|
|
||||||
|
|
||||||
// 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).toBeTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(errorThrown).toBeTrue();
|
|
||||||
|
|
||||||
// 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).toBeTrue(); // This should not happen
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('stop', async () => {
|
|
||||||
tap.stopForcefully();
|
|
||||||
})
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
289
test/test.storagemanager.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
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 { promises as fs } from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
const testData = {
|
||||||
|
string: 'Hello, World!',
|
||||||
|
json: { name: 'test', value: 42, nested: { data: true } },
|
||||||
|
largeString: 'x'.repeat(10000)
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('Storage Manager - Memory Backend', async () => {
|
||||||
|
// Create StorageManager without config (defaults to memory)
|
||||||
|
const storage = new StorageManager();
|
||||||
|
|
||||||
|
// Test basic get/set
|
||||||
|
await storage.set('/test/key', testData.string);
|
||||||
|
const value = await storage.get('/test/key');
|
||||||
|
expect(value).toEqual(testData.string);
|
||||||
|
|
||||||
|
// Test JSON helpers
|
||||||
|
await storage.setJSON('/test/json', testData.json);
|
||||||
|
const jsonValue = await storage.getJSON('/test/json');
|
||||||
|
expect(jsonValue).toEqual(testData.json);
|
||||||
|
|
||||||
|
// Test exists
|
||||||
|
expect(await storage.exists('/test/key')).toEqual(true);
|
||||||
|
expect(await storage.exists('/nonexistent')).toEqual(false);
|
||||||
|
|
||||||
|
// Test delete
|
||||||
|
await storage.delete('/test/key');
|
||||||
|
expect(await storage.exists('/test/key')).toEqual(false);
|
||||||
|
|
||||||
|
// Test list
|
||||||
|
await storage.set('/items/1', 'one');
|
||||||
|
await storage.set('/items/2', 'two');
|
||||||
|
await storage.set('/other/3', 'three');
|
||||||
|
|
||||||
|
const items = await storage.list('/items');
|
||||||
|
expect(items.length).toEqual(2);
|
||||||
|
expect(items).toContain('/items/1');
|
||||||
|
expect(items).toContain('/items/2');
|
||||||
|
|
||||||
|
// Verify memory backend
|
||||||
|
expect(storage.getBackend()).toEqual('memory');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Storage Manager - Filesystem Backend', async () => {
|
||||||
|
const testDir = path.join(paths.dataDir, '.test-storage');
|
||||||
|
|
||||||
|
// Clean up test directory if it exists
|
||||||
|
try {
|
||||||
|
await fs.rm(testDir, { recursive: true, force: true });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Create StorageManager with filesystem path
|
||||||
|
const storage = new StorageManager({ fsPath: testDir });
|
||||||
|
|
||||||
|
// Test basic operations
|
||||||
|
await storage.set('/test/file', testData.string);
|
||||||
|
const value = await storage.get('/test/file');
|
||||||
|
expect(value).toEqual(testData.string);
|
||||||
|
|
||||||
|
// Verify file exists on disk
|
||||||
|
const filePath = path.join(testDir, 'test', 'file');
|
||||||
|
const fileExists = await fs.access(filePath).then(() => true).catch(() => false);
|
||||||
|
expect(fileExists).toEqual(true);
|
||||||
|
|
||||||
|
// Test atomic writes (temp file should not exist)
|
||||||
|
const tempPath = filePath + '.tmp';
|
||||||
|
const tempExists = await fs.access(tempPath).then(() => true).catch(() => false);
|
||||||
|
expect(tempExists).toEqual(false);
|
||||||
|
|
||||||
|
// Test nested paths
|
||||||
|
await storage.set('/deeply/nested/path/to/file', testData.largeString);
|
||||||
|
const nestedValue = await storage.get('/deeply/nested/path/to/file');
|
||||||
|
expect(nestedValue).toEqual(testData.largeString);
|
||||||
|
|
||||||
|
// Test list with filesystem
|
||||||
|
await storage.set('/fs/items/a', 'alpha');
|
||||||
|
await storage.set('/fs/items/b', 'beta');
|
||||||
|
await storage.set('/fs/other/c', 'gamma');
|
||||||
|
|
||||||
|
// Filesystem backend now properly supports list
|
||||||
|
const fsItems = await storage.list('/fs/items');
|
||||||
|
expect(fsItems.length).toEqual(2); // Should find both items
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await fs.rm(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Storage Manager - Custom Function Backend', async () => {
|
||||||
|
// Create in-memory storage for custom functions
|
||||||
|
const customStore = new Map<string, string>();
|
||||||
|
|
||||||
|
const storage = new StorageManager({
|
||||||
|
readFunction: async (key: string) => {
|
||||||
|
return customStore.get(key) || null;
|
||||||
|
},
|
||||||
|
writeFunction: async (key: string, value: string) => {
|
||||||
|
customStore.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test basic operations
|
||||||
|
await storage.set('/custom/key', testData.string);
|
||||||
|
expect(customStore.has('/custom/key')).toEqual(true);
|
||||||
|
|
||||||
|
const value = await storage.get('/custom/key');
|
||||||
|
expect(value).toEqual(testData.string);
|
||||||
|
|
||||||
|
// Test that delete sets empty value (as per implementation)
|
||||||
|
await storage.delete('/custom/key');
|
||||||
|
expect(customStore.get('/custom/key')).toEqual('');
|
||||||
|
|
||||||
|
// Verify custom backend (filesystem is implemented as custom backend internally)
|
||||||
|
expect(storage.getBackend()).toEqual('custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Storage Manager - Key Validation', async () => {
|
||||||
|
const storage = new StorageManager();
|
||||||
|
|
||||||
|
// Test key normalization
|
||||||
|
await storage.set('test/key', 'value1'); // Missing leading slash
|
||||||
|
const value1 = await storage.get('/test/key');
|
||||||
|
expect(value1).toEqual('value1');
|
||||||
|
|
||||||
|
// Test dangerous path elements are removed
|
||||||
|
await storage.set('/test/../danger/key', 'value2');
|
||||||
|
const value2 = await storage.get('/test/danger/key'); // .. is removed, not the whole path segment
|
||||||
|
expect(value2).toEqual('value2');
|
||||||
|
|
||||||
|
// Test multiple slashes are normalized
|
||||||
|
await storage.set('/test///multiple////slashes', 'value3');
|
||||||
|
const value3 = await storage.get('/test/multiple/slashes');
|
||||||
|
expect(value3).toEqual('value3');
|
||||||
|
|
||||||
|
// Test invalid keys throw errors
|
||||||
|
let emptyKeyError: Error | null = null;
|
||||||
|
try {
|
||||||
|
await storage.set('', 'value');
|
||||||
|
} catch (error) {
|
||||||
|
emptyKeyError = error as Error;
|
||||||
|
}
|
||||||
|
expect(emptyKeyError).toBeTruthy();
|
||||||
|
expect(emptyKeyError?.message).toEqual('Storage key must be a non-empty string');
|
||||||
|
|
||||||
|
let nullKeyError: Error | null = null;
|
||||||
|
try {
|
||||||
|
await storage.set(null as any, 'value');
|
||||||
|
} catch (error) {
|
||||||
|
nullKeyError = error as Error;
|
||||||
|
}
|
||||||
|
expect(nullKeyError).toBeTruthy();
|
||||||
|
expect(nullKeyError?.message).toEqual('Storage key must be a non-empty string');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Storage Manager - Concurrent Access', async () => {
|
||||||
|
const storage = new StorageManager();
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
// Simulate concurrent writes
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
promises.push(storage.set(`/concurrent/key${i}`, `value${i}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
// Verify all writes succeeded
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const value = await storage.get(`/concurrent/key${i}`);
|
||||||
|
expect(value).toEqual(`value${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test concurrent reads
|
||||||
|
const readPromises: Promise<string | null>[] = [];
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
readPromises.push(storage.get(`/concurrent/key${i}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(readPromises);
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
expect(results[i]).toEqual(`value${i}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Storage Manager - Backend Priority', async () => {
|
||||||
|
const testDir = path.join(paths.dataDir, '.test-storage-priority');
|
||||||
|
|
||||||
|
// Test that custom functions take priority over fsPath
|
||||||
|
let warningLogged = false;
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
console.warn = (message: string) => {
|
||||||
|
if (message.includes('Using custom read/write functions')) {
|
||||||
|
warningLogged = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const storage = new StorageManager({
|
||||||
|
fsPath: testDir,
|
||||||
|
readFunction: async () => 'custom-value',
|
||||||
|
writeFunction: async () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.warn = originalWarn;
|
||||||
|
|
||||||
|
expect(warningLogged).toEqual(true);
|
||||||
|
expect(storage.getBackend()).toEqual('custom'); // Custom functions take priority
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
try {
|
||||||
|
await fs.rm(testDir, { recursive: true, force: true });
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Storage Manager - Error Handling', async () => {
|
||||||
|
// Test filesystem errors
|
||||||
|
const storage = new StorageManager({
|
||||||
|
readFunction: async () => {
|
||||||
|
throw new Error('Read error');
|
||||||
|
},
|
||||||
|
writeFunction: async () => {
|
||||||
|
throw new Error('Write error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read errors should return null
|
||||||
|
const value = await storage.get('/error/key');
|
||||||
|
expect(value).toEqual(null);
|
||||||
|
|
||||||
|
// Write errors should propagate
|
||||||
|
let writeError: Error | null = null;
|
||||||
|
try {
|
||||||
|
await storage.set('/error/key', 'value');
|
||||||
|
} catch (error) {
|
||||||
|
writeError = error as Error;
|
||||||
|
}
|
||||||
|
expect(writeError).toBeTruthy();
|
||||||
|
expect(writeError?.message).toEqual('Write error');
|
||||||
|
|
||||||
|
// Test JSON parse errors
|
||||||
|
const jsonStorage = new StorageManager({
|
||||||
|
readFunction: async () => 'invalid json',
|
||||||
|
writeFunction: async () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test JSON parse errors
|
||||||
|
let jsonError: Error | null = null;
|
||||||
|
try {
|
||||||
|
await jsonStorage.getJSON('/invalid/json');
|
||||||
|
} catch (error) {
|
||||||
|
jsonError = error as Error;
|
||||||
|
}
|
||||||
|
expect(jsonError).toBeTruthy();
|
||||||
|
expect(jsonError?.message).toContain('JSON');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Storage Manager - List Operations', async () => {
|
||||||
|
const storage = new StorageManager();
|
||||||
|
|
||||||
|
// Populate storage with hierarchical data
|
||||||
|
await storage.set('/app/config/database', 'db-config');
|
||||||
|
await storage.set('/app/config/cache', 'cache-config');
|
||||||
|
await storage.set('/app/data/users/1', 'user1');
|
||||||
|
await storage.set('/app/data/users/2', 'user2');
|
||||||
|
await storage.set('/app/logs/error.log', 'errors');
|
||||||
|
|
||||||
|
// List root
|
||||||
|
const rootItems = await storage.list('/');
|
||||||
|
expect(rootItems.length).toBeGreaterThanOrEqual(5);
|
||||||
|
|
||||||
|
// List specific paths
|
||||||
|
const configItems = await storage.list('/app/config');
|
||||||
|
expect(configItems.length).toEqual(2);
|
||||||
|
expect(configItems).toContain('/app/config/database');
|
||||||
|
expect(configItems).toContain('/app/config/cache');
|
||||||
|
|
||||||
|
const userItems = await storage.list('/app/data/users');
|
||||||
|
expect(userItems.length).toEqual(2);
|
||||||
|
|
||||||
|
// List non-existent path
|
||||||
|
const emptyList = await storage.list('/nonexistent/path');
|
||||||
|
expect(emptyList.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
|
||||||
|
|
||||||
tap.test('should create a platform service', async () => {});
|
|
||||||
|
|
||||||
tap.test('stop', async () => {
|
|
||||||
await tap.stopForcefully();
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
46
test_watch/devserver.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
|
||||||
|
const devRouter = new DcRouter({
|
||||||
|
// SmartProxy routes for development/demo
|
||||||
|
smartProxyConfig: {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'web-traffic',
|
||||||
|
match: { ports: [18080], domains: ['example.com', '*.example.com'] },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'api-gateway',
|
||||||
|
match: { ports: [18080], domains: ['api.example.com'], path: '/v1/*' },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 4000 }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tls-passthrough',
|
||||||
|
match: { ports: [18443], domains: ['secure.example.com'] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 4443 }],
|
||||||
|
tls: { mode: 'passthrough' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Disable cache/mongo for dev
|
||||||
|
cacheConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Starting DcRouter in development mode...');
|
||||||
|
|
||||||
|
await devRouter.start();
|
||||||
|
|
||||||
|
// Graceful shutdown handlers
|
||||||
|
const shutdown = async () => {
|
||||||
|
console.log('\nShutting down...');
|
||||||
|
await devRouter.stop();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
|
||||||
|
console.log('DcRouter dev server running. Press Ctrl+C to stop.');
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* autocreated commitinfo by @push.rocks/commitinfo
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/platformservice',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '2.7.0',
|
version: '11.0.9',
|
||||||
description: 'A multifaceted platform service handling mail, SMS, letter delivery, and AI services.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export class AIBridge {
|
|
||||||
|
|
||||||
}
|
|
||||||
166
ts/cache/classes.cache.cleaner.ts
vendored
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for the cache cleaner
|
||||||
|
*/
|
||||||
|
export interface ICacheCleanerOptions {
|
||||||
|
/** Cleanup interval in milliseconds (default: 1 hour) */
|
||||||
|
intervalMs?: number;
|
||||||
|
/** Enable verbose logging */
|
||||||
|
verbose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CacheCleaner - Periodically removes expired documents from the cache
|
||||||
|
*
|
||||||
|
* Runs on a configurable interval (default: hourly) and queries each
|
||||||
|
* collection for documents where expiresAt < now(), then deletes them.
|
||||||
|
*/
|
||||||
|
export class CacheCleaner {
|
||||||
|
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private isRunning: boolean = false;
|
||||||
|
private options: Required<ICacheCleanerOptions>;
|
||||||
|
private cacheDb: CacheDb;
|
||||||
|
|
||||||
|
constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) {
|
||||||
|
this.cacheDb = cacheDb;
|
||||||
|
this.options = {
|
||||||
|
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
||||||
|
verbose: options.verbose || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the periodic cleanup process
|
||||||
|
*/
|
||||||
|
public start(): void {
|
||||||
|
if (this.isRunning) {
|
||||||
|
logger.log('warn', 'CacheCleaner already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
|
||||||
|
// Run cleanup immediately on start
|
||||||
|
this.runCleanup().catch((error) => {
|
||||||
|
logger.log('error', `Initial cache cleanup failed: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schedule periodic cleanup
|
||||||
|
this.cleanupInterval = setInterval(() => {
|
||||||
|
this.runCleanup().catch((error) => {
|
||||||
|
logger.log('error', `Cache cleanup failed: ${error.message}`);
|
||||||
|
});
|
||||||
|
}, this.options.intervalMs);
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
`CacheCleaner started with interval: ${this.options.intervalMs / 1000 / 60} minutes`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the periodic cleanup process
|
||||||
|
*/
|
||||||
|
public stop(): void {
|
||||||
|
if (!this.isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
this.cleanupInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = false;
|
||||||
|
logger.log('info', 'CacheCleaner stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a single cleanup cycle
|
||||||
|
*/
|
||||||
|
public async runCleanup(): Promise<void> {
|
||||||
|
if (!this.cacheDb.isReady()) {
|
||||||
|
logger.log('warn', 'CacheDb not ready, skipping cleanup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const results: { collection: string; deleted: number }[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// Log results
|
||||||
|
const totalDeleted = results.reduce((sum, r) => sum + r.deleted, 0);
|
||||||
|
if (totalDeleted > 0 || this.options.verbose) {
|
||||||
|
const summary = results
|
||||||
|
.filter((r) => r.deleted > 0)
|
||||||
|
.map((r) => `${r.collection}: ${r.deleted}`)
|
||||||
|
.join(', ');
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Cache cleanup error: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean expired documents from a specific collection using smartdata API
|
||||||
|
*/
|
||||||
|
private async cleanExpiredDocuments<T extends { delete: () => Promise<void> }>(
|
||||||
|
documentClass: { getInstances: (filter: any) => Promise<T[]> },
|
||||||
|
now: Date
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
// Find all expired documents
|
||||||
|
const expiredDocs = await documentClass.getInstances({
|
||||||
|
expiresAt: { $lt: now },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete each expired document
|
||||||
|
let deletedCount = 0;
|
||||||
|
for (const doc of expiredDocs) {
|
||||||
|
try {
|
||||||
|
await doc.delete();
|
||||||
|
deletedCount++;
|
||||||
|
} catch (deleteError) {
|
||||||
|
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Error cleaning collection: ${error.message}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the cleaner is running
|
||||||
|
*/
|
||||||
|
public isActive(): boolean {
|
||||||
|
return this.isRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cleanup interval in milliseconds
|
||||||
|
*/
|
||||||
|
public getIntervalMs(): number {
|
||||||
|
return this.options.intervalMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
ts/cache/classes.cached.document.ts
vendored
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for all cached documents with TTL support
|
||||||
|
*
|
||||||
|
* Extends smartdata's SmartDataDbDoc to add:
|
||||||
|
* - Automatic timestamps (createdAt, lastAccessedAt)
|
||||||
|
* - TTL/expiration support (expiresAt)
|
||||||
|
* - Helper methods for TTL management
|
||||||
|
*
|
||||||
|
* NOTE: Subclasses MUST add @svDb() decorators to createdAt, expiresAt, and lastAccessedAt
|
||||||
|
* since decorators on abstract classes don't propagate correctly.
|
||||||
|
*/
|
||||||
|
export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
|
||||||
|
/**
|
||||||
|
* Timestamp when the document was created
|
||||||
|
* NOTE: Subclasses must add @svDb() decorator
|
||||||
|
*/
|
||||||
|
public createdAt: Date = new Date();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp when the document expires and should be cleaned up
|
||||||
|
* NOTE: Subclasses must add @svDb() decorator
|
||||||
|
*/
|
||||||
|
public expiresAt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp of last access (for LRU-style eviction if needed)
|
||||||
|
* NOTE: Subclasses must add @svDb() decorator
|
||||||
|
*/
|
||||||
|
public lastAccessedAt: Date = new Date();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the TTL (time to live) for this document
|
||||||
|
* @param ttlMs Time to live in milliseconds
|
||||||
|
*/
|
||||||
|
public setTTL(ttlMs: number): void {
|
||||||
|
this.expiresAt = new Date(Date.now() + ttlMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set TTL using days
|
||||||
|
* @param days Number of days until expiration
|
||||||
|
*/
|
||||||
|
public setTTLDays(days: number): void {
|
||||||
|
this.setTTL(days * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set TTL using hours
|
||||||
|
* @param hours Number of hours until expiration
|
||||||
|
*/
|
||||||
|
public setTTLHours(hours: number): void {
|
||||||
|
this.setTTL(hours * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this document has expired
|
||||||
|
*/
|
||||||
|
public isExpired(): boolean {
|
||||||
|
if (!this.expiresAt) {
|
||||||
|
return false; // No expiration set
|
||||||
|
}
|
||||||
|
return new Date() > this.expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the lastAccessedAt timestamp
|
||||||
|
*/
|
||||||
|
public touch(): void {
|
||||||
|
this.lastAccessedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining TTL in milliseconds
|
||||||
|
* Returns 0 if expired, -1 if no expiration set
|
||||||
|
*/
|
||||||
|
public getRemainingTTL(): number {
|
||||||
|
if (!this.expiresAt) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
const remaining = this.expiresAt.getTime() - Date.now();
|
||||||
|
return remaining > 0 ? remaining : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend the TTL by the specified milliseconds from now
|
||||||
|
* @param ttlMs Additional time to live in milliseconds
|
||||||
|
*/
|
||||||
|
public extendTTL(ttlMs: number): void {
|
||||||
|
this.expiresAt = new Date(Date.now() + ttlMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the document to never expire (100 years in the future)
|
||||||
|
*/
|
||||||
|
public setNeverExpires(): void {
|
||||||
|
this.expiresAt = new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TTL constants in milliseconds
|
||||||
|
*/
|
||||||
|
export const TTL = {
|
||||||
|
HOURS_1: 1 * 60 * 60 * 1000,
|
||||||
|
HOURS_24: 24 * 60 * 60 * 1000,
|
||||||
|
DAYS_7: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
DAYS_30: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
DAYS_90: 90 * 24 * 60 * 60 * 1000,
|
||||||
|
} as const;
|
||||||
155
ts/cache/classes.cachedb.ts
vendored
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { defaultTsmDbPath } from '../paths.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for CacheDb
|
||||||
|
*/
|
||||||
|
export interface ICacheDbOptions {
|
||||||
|
/** Base storage path for TsmDB data (default: ~/.serve.zone/dcrouter/tsmdb) */
|
||||||
|
storagePath?: string;
|
||||||
|
/** Database name (default: dcrouter) */
|
||||||
|
dbName?: string;
|
||||||
|
/** Enable debug logging */
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CacheDb - Wrapper around LocalTsmDb and smartdata
|
||||||
|
*
|
||||||
|
* Provides persistent caching using smartdata as the ORM layer
|
||||||
|
* and LocalTsmDb as the embedded database engine.
|
||||||
|
*/
|
||||||
|
export class CacheDb {
|
||||||
|
private static instance: CacheDb | null = null;
|
||||||
|
|
||||||
|
private localTsmDb: plugins.smartmongo.LocalTsmDb;
|
||||||
|
private smartdataDb: plugins.smartdata.SmartdataDb;
|
||||||
|
private options: Required<ICacheDbOptions>;
|
||||||
|
private isStarted: boolean = false;
|
||||||
|
|
||||||
|
constructor(options: ICacheDbOptions = {}) {
|
||||||
|
this.options = {
|
||||||
|
storagePath: options.storagePath || defaultTsmDbPath,
|
||||||
|
dbName: options.dbName || 'dcrouter',
|
||||||
|
debug: options.debug || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the singleton instance
|
||||||
|
*/
|
||||||
|
public static getInstance(options?: ICacheDbOptions): CacheDb {
|
||||||
|
if (!CacheDb.instance) {
|
||||||
|
CacheDb.instance = new CacheDb(options);
|
||||||
|
}
|
||||||
|
return CacheDb.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (useful for testing)
|
||||||
|
*/
|
||||||
|
public static resetInstance(): void {
|
||||||
|
CacheDb.instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the cache database
|
||||||
|
* - Initializes LocalTsmDb with file persistence
|
||||||
|
* - Connects smartdata to the LocalTsmDb via Unix socket
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.isStarted) {
|
||||||
|
logger.log('warn', 'CacheDb already started');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure storage directory exists
|
||||||
|
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
||||||
|
|
||||||
|
// Create LocalTsmDb instance
|
||||||
|
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
|
||||||
|
folderPath: this.options.storagePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start LocalTsmDb and get connection info
|
||||||
|
const connectionInfo = await this.localTsmDb.start();
|
||||||
|
|
||||||
|
if (this.options.debug) {
|
||||||
|
logger.log('debug', `LocalTsmDb started with URI: ${connectionInfo.connectionUri}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize smartdata with the connection URI
|
||||||
|
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||||
|
mongoDbUrl: connectionInfo.connectionUri,
|
||||||
|
mongoDbName: this.options.dbName,
|
||||||
|
});
|
||||||
|
await this.smartdataDb.init();
|
||||||
|
|
||||||
|
this.isStarted = true;
|
||||||
|
logger.log('info', `CacheDb started at ${this.options.storagePath}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Failed to start CacheDb: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the cache database
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.isStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Close smartdata connection
|
||||||
|
if (this.smartdataDb) {
|
||||||
|
await this.smartdataDb.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop LocalTsmDb
|
||||||
|
if (this.localTsmDb) {
|
||||||
|
await this.localTsmDb.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isStarted = false;
|
||||||
|
logger.log('info', 'CacheDb stopped');
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Error stopping CacheDb: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the smartdata database instance
|
||||||
|
*/
|
||||||
|
public getDb(): plugins.smartdata.SmartdataDb {
|
||||||
|
if (!this.isStarted) {
|
||||||
|
throw new Error('CacheDb not started. Call start() first.');
|
||||||
|
}
|
||||||
|
return this.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the database is ready
|
||||||
|
*/
|
||||||
|
public isReady(): boolean {
|
||||||
|
return this.isStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the storage path
|
||||||
|
*/
|
||||||
|
public getStoragePath(): string {
|
||||||
|
return this.options.storagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the database name
|
||||||
|
*/
|
||||||
|
public getDbName(): string {
|
||||||
|
return this.options.dbName;
|
||||||
|
}
|
||||||
|
}
|
||||||
240
ts/cache/documents/classes.cached.email.ts
vendored
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||||
|
import { CacheDb } from '../classes.cachedb.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email status in the cache
|
||||||
|
*/
|
||||||
|
export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get the smartdata database instance
|
||||||
|
*/
|
||||||
|
const getDb = () => CacheDb.getInstance().getDb();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CachedEmail - Stores email queue items in the cache
|
||||||
|
*
|
||||||
|
* Used for persistent email queue storage, tracking delivery status,
|
||||||
|
* and maintaining email history for the configured TTL period.
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||||
|
// 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 email
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email message ID (RFC 822 Message-ID header)
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public messageId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sender email address (envelope from)
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public from: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recipient email addresses
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public to: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CC recipients
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public cc: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BCC recipients
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public bcc: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email subject
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public subject: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw RFC822 email content
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public rawContent: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current status of the email
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public status: TCachedEmailStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of delivery attempts
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public attempts: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of delivery attempts
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public maxAttempts: number = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp for next delivery attempt
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nextAttempt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last error message if delivery failed
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastError: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp when the email was successfully delivered
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public deliveredAt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sender domain (for querying/filtering)
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public senderDomain: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority level (higher = more important)
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public priority: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-serialized route data
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public routeData: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DKIM signature status
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public dkimSigned: boolean = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.setTTL(TTL.DAYS_30); // Default 30-day TTL
|
||||||
|
this.status = 'pending';
|
||||||
|
this.to = [];
|
||||||
|
this.cc = [];
|
||||||
|
this.bcc = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new CachedEmail with a unique ID
|
||||||
|
*/
|
||||||
|
public static createNew(): CachedEmail {
|
||||||
|
const email = new CachedEmail();
|
||||||
|
email.id = plugins.uuid.v4();
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an email by ID
|
||||||
|
*/
|
||||||
|
public static async findById(id: string): Promise<CachedEmail | null> {
|
||||||
|
return await CachedEmail.getInstance({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all emails with a specific status
|
||||||
|
*/
|
||||||
|
public static async findByStatus(status: TCachedEmailStatus): Promise<CachedEmail[]> {
|
||||||
|
return await CachedEmail.getInstances({
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all emails pending delivery (status = pending and nextAttempt <= now)
|
||||||
|
*/
|
||||||
|
public static async findPendingForDelivery(): Promise<CachedEmail[]> {
|
||||||
|
const now = new Date();
|
||||||
|
return await CachedEmail.getInstances({
|
||||||
|
status: 'pending',
|
||||||
|
nextAttempt: { $lte: now },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find emails by sender domain
|
||||||
|
*/
|
||||||
|
public static async findBySenderDomain(domain: string): Promise<CachedEmail[]> {
|
||||||
|
return await CachedEmail.getInstances({
|
||||||
|
senderDomain: domain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark as delivered
|
||||||
|
*/
|
||||||
|
public markDelivered(): void {
|
||||||
|
this.status = 'delivered';
|
||||||
|
this.deliveredAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark as failed with error
|
||||||
|
*/
|
||||||
|
public markFailed(error: string): void {
|
||||||
|
this.status = 'failed';
|
||||||
|
this.lastError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment attempt counter and schedule next attempt
|
||||||
|
*/
|
||||||
|
public scheduleRetry(delayMs: number = 5 * 60 * 1000): void {
|
||||||
|
this.attempts++;
|
||||||
|
this.status = 'deferred';
|
||||||
|
this.nextAttempt = new Date(Date.now() + delayMs);
|
||||||
|
|
||||||
|
// If max attempts reached, mark as failed
|
||||||
|
if (this.attempts >= this.maxAttempts) {
|
||||||
|
this.status = 'failed';
|
||||||
|
this.lastError = `Max attempts (${this.maxAttempts}) reached`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract sender domain from email address
|
||||||
|
*/
|
||||||
|
public updateSenderDomain(): void {
|
||||||
|
if (this.from) {
|
||||||
|
const match = this.from.match(/@([^>]+)>?$/);
|
||||||
|
if (match) {
|
||||||
|
this.senderDomain = match[1].toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
247
ts/cache/documents/classes.cached.ip.reputation.ts
vendored
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP reputation result data
|
||||||
|
*/
|
||||||
|
export interface IIPReputationData {
|
||||||
|
score: number;
|
||||||
|
isSpam: boolean;
|
||||||
|
isProxy: boolean;
|
||||||
|
isTor: boolean;
|
||||||
|
isVPN: boolean;
|
||||||
|
country?: string;
|
||||||
|
asn?: string;
|
||||||
|
org?: string;
|
||||||
|
blacklists?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CachedIPReputation - Stores IP reputation lookup results
|
||||||
|
*
|
||||||
|
* Caches the results of IP reputation checks to avoid repeated
|
||||||
|
* external API calls. Default TTL is 24 hours.
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
|
||||||
|
// 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.HOURS_24);
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastAccessedAt: Date = new Date();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP address (unique identifier)
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public ipAddress: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reputation score (0-100, higher = better)
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public score: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the IP is flagged as spam source
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public isSpam: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the IP is a known proxy
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public isProxy: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the IP is a Tor exit node
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public isTor: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the IP is a VPN endpoint
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public isVPN: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Country code (ISO 3166-1 alpha-2)
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public country: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autonomous System Number
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public asn: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organization name
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public org: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of blacklists the IP appears on
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public blacklists: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of times this IP has been checked
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public checkCount: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of connections from this IP
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public connectionCount: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of emails received from this IP
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public emailCount: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of spam emails from this IP
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public spamCount: number = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.setTTL(TTL.HOURS_24); // Default 24-hour TTL
|
||||||
|
this.blacklists = [];
|
||||||
|
this.score = 50; // Default neutral score
|
||||||
|
this.isSpam = false;
|
||||||
|
this.isProxy = false;
|
||||||
|
this.isTor = false;
|
||||||
|
this.isVPN = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from reputation data
|
||||||
|
*/
|
||||||
|
public static fromReputationData(ipAddress: string, data: IIPReputationData): CachedIPReputation {
|
||||||
|
const cached = new CachedIPReputation();
|
||||||
|
cached.ipAddress = ipAddress;
|
||||||
|
cached.score = data.score;
|
||||||
|
cached.isSpam = data.isSpam;
|
||||||
|
cached.isProxy = data.isProxy;
|
||||||
|
cached.isTor = data.isTor;
|
||||||
|
cached.isVPN = data.isVPN;
|
||||||
|
cached.country = data.country || '';
|
||||||
|
cached.asn = data.asn || '';
|
||||||
|
cached.org = data.org || '';
|
||||||
|
cached.blacklists = data.blacklists || [];
|
||||||
|
cached.checkCount = 1;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to reputation data object
|
||||||
|
*/
|
||||||
|
public toReputationData(): IIPReputationData {
|
||||||
|
this.touch();
|
||||||
|
return {
|
||||||
|
score: this.score,
|
||||||
|
isSpam: this.isSpam,
|
||||||
|
isProxy: this.isProxy,
|
||||||
|
isTor: this.isTor,
|
||||||
|
isVPN: this.isVPN,
|
||||||
|
country: this.country,
|
||||||
|
asn: this.asn,
|
||||||
|
org: this.org,
|
||||||
|
blacklists: this.blacklists,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find by IP address
|
||||||
|
*/
|
||||||
|
public static async findByIP(ipAddress: string): Promise<CachedIPReputation | null> {
|
||||||
|
return await CachedIPReputation.getInstance({
|
||||||
|
ipAddress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all IPs flagged as spam
|
||||||
|
*/
|
||||||
|
public static async findSpamIPs(): Promise<CachedIPReputation[]> {
|
||||||
|
return await CachedIPReputation.getInstances({
|
||||||
|
isSpam: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find IPs with score below threshold
|
||||||
|
*/
|
||||||
|
public static async findLowScoreIPs(threshold: number): Promise<CachedIPReputation[]> {
|
||||||
|
return await CachedIPReputation.getInstances({
|
||||||
|
score: { $lt: threshold },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a connection from this IP
|
||||||
|
*/
|
||||||
|
public recordConnection(): void {
|
||||||
|
this.connectionCount++;
|
||||||
|
this.touch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an email from this IP
|
||||||
|
*/
|
||||||
|
public recordEmail(isSpam: boolean = false): void {
|
||||||
|
this.emailCount++;
|
||||||
|
if (isSpam) {
|
||||||
|
this.spamCount++;
|
||||||
|
}
|
||||||
|
this.touch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the reputation data
|
||||||
|
*/
|
||||||
|
public updateReputation(data: IIPReputationData): void {
|
||||||
|
this.score = data.score;
|
||||||
|
this.isSpam = data.isSpam;
|
||||||
|
this.isProxy = data.isProxy;
|
||||||
|
this.isTor = data.isTor;
|
||||||
|
this.isVPN = data.isVPN;
|
||||||
|
this.country = data.country || this.country;
|
||||||
|
this.asn = data.asn || this.asn;
|
||||||
|
this.org = data.org || this.org;
|
||||||
|
this.blacklists = data.blacklists || this.blacklists;
|
||||||
|
this.checkCount++;
|
||||||
|
this.touch();
|
||||||
|
// Refresh TTL on update
|
||||||
|
this.setTTL(TTL.HOURS_24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this IP should be blocked
|
||||||
|
*/
|
||||||
|
public shouldBlock(): boolean {
|
||||||
|
return this.isSpam || this.score < 20 || this.blacklists.length > 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
ts/cache/documents/index.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './classes.cached.email.js';
|
||||||
|
export * from './classes.cached.ip.reputation.js';
|
||||||
7
ts/cache/index.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Core cache infrastructure
|
||||||
|
export * from './classes.cachedb.js';
|
||||||
|
export * from './classes.cached.document.js';
|
||||||
|
export * from './classes.cache.cleaner.js';
|
||||||
|
|
||||||
|
// Document classes
|
||||||
|
export * from './documents/index.js';
|
||||||
137
ts/classes.cert-provision-scheduler.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { logger } from './logger.js';
|
||||||
|
import type { StorageManager } from './storage/index.js';
|
||||||
|
|
||||||
|
interface IBackoffEntry {
|
||||||
|
failures: number;
|
||||||
|
lastFailure: string; // ISO string
|
||||||
|
retryAfter: string; // ISO string
|
||||||
|
lastError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages certificate provisioning scheduling with:
|
||||||
|
* - Per-domain exponential backoff persisted in StorageManager
|
||||||
|
*
|
||||||
|
* Note: Serial stagger queue was removed — smartacme v9 handles
|
||||||
|
* concurrency, per-domain dedup, and rate limiting internally.
|
||||||
|
*/
|
||||||
|
export class CertProvisionScheduler {
|
||||||
|
private storageManager: StorageManager;
|
||||||
|
private maxBackoffHours: number;
|
||||||
|
|
||||||
|
// In-memory backoff cache (mirrors storage for fast lookups)
|
||||||
|
private backoffCache = new Map<string, IBackoffEntry>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
storageManager: StorageManager,
|
||||||
|
options?: { maxBackoffHours?: number }
|
||||||
|
) {
|
||||||
|
this.storageManager = storageManager;
|
||||||
|
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage key for a domain's backoff entry
|
||||||
|
*/
|
||||||
|
private backoffKey(domain: string): string {
|
||||||
|
const clean = domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
return `/cert-backoff/${clean}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load backoff entry from storage (with in-memory cache)
|
||||||
|
*/
|
||||||
|
private async loadBackoff(domain: string): Promise<IBackoffEntry | null> {
|
||||||
|
const cached = this.backoffCache.get(domain);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const entry = await this.storageManager.getJSON<IBackoffEntry>(this.backoffKey(domain));
|
||||||
|
if (entry) {
|
||||||
|
this.backoffCache.set(domain, entry);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save backoff entry to both cache and storage
|
||||||
|
*/
|
||||||
|
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
|
||||||
|
this.backoffCache.set(domain, entry);
|
||||||
|
await this.storageManager.setJSON(this.backoffKey(domain), entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a domain is currently in backoff
|
||||||
|
*/
|
||||||
|
async isInBackoff(domain: string): Promise<boolean> {
|
||||||
|
const entry = await this.loadBackoff(domain);
|
||||||
|
if (!entry) return false;
|
||||||
|
|
||||||
|
const retryAfter = new Date(entry.retryAfter);
|
||||||
|
return retryAfter.getTime() > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a provisioning failure for a domain.
|
||||||
|
* Sets exponential backoff: min(failures^2 * 1h, maxBackoffHours)
|
||||||
|
*/
|
||||||
|
async recordFailure(domain: string, error?: string): Promise<void> {
|
||||||
|
const existing = await this.loadBackoff(domain);
|
||||||
|
const failures = (existing?.failures ?? 0) + 1;
|
||||||
|
|
||||||
|
// Exponential backoff: failures^2 hours, capped
|
||||||
|
const backoffHours = Math.min(failures * failures, this.maxBackoffHours);
|
||||||
|
const retryAfter = new Date(Date.now() + backoffHours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const entry: IBackoffEntry = {
|
||||||
|
failures,
|
||||||
|
lastFailure: new Date().toISOString(),
|
||||||
|
retryAfter: retryAfter.toISOString(),
|
||||||
|
lastError: error,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.saveBackoff(domain, entry);
|
||||||
|
logger.log('warn', `Cert backoff for ${domain}: ${failures} failures, retry after ${retryAfter.toISOString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear backoff for a domain (on success or manual override)
|
||||||
|
*/
|
||||||
|
async clearBackoff(domain: string): Promise<void> {
|
||||||
|
this.backoffCache.delete(domain);
|
||||||
|
try {
|
||||||
|
await this.storageManager.delete(this.backoffKey(domain));
|
||||||
|
} catch {
|
||||||
|
// Ignore delete errors (key may not exist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all in-memory backoff cache entries
|
||||||
|
*/
|
||||||
|
public clear(): void {
|
||||||
|
this.backoffCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get backoff info for UI display
|
||||||
|
*/
|
||||||
|
async getBackoffInfo(domain: string): Promise<{
|
||||||
|
failures: number;
|
||||||
|
retryAfter?: string;
|
||||||
|
lastError?: string;
|
||||||
|
} | null> {
|
||||||
|
const entry = await this.loadBackoff(domain);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
// Only return if still in backoff
|
||||||
|
const retryAfter = new Date(entry.retryAfter);
|
||||||
|
if (retryAfter.getTime() <= Date.now()) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
failures: entry.failures,
|
||||||
|
retryAfter: entry.retryAfter,
|
||||||
|
lastError: entry.lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
1824
ts/classes.dcrouter.ts
Normal file
@@ -1,27 +0,0 @@
|
|||||||
import * as plugins from './plugins.js';
|
|
||||||
import { SzPlatformService } from './platformservice.js';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export class PlatformServiceDb {
|
|
||||||
public smartdataDb: plugins.smartdata.SmartdataDb;
|
|
||||||
public platformserviceRef: SzPlatformService;
|
|
||||||
|
|
||||||
constructor(platformserviceRefArg: SzPlatformService) {
|
|
||||||
this.platformserviceRef = platformserviceRefArg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async start() {
|
|
||||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
|
||||||
mongoDbUser: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_USER'),
|
|
||||||
mongoDbName: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_NAME'),
|
|
||||||
mongoDbPass: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_PASS'),
|
|
||||||
mongoDbUrl: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_URL'),
|
|
||||||
});
|
|
||||||
await this.smartdataDb.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stop() {
|
|
||||||
await this.smartdataDb.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
46
ts/classes.storage-cert-manager.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import { StorageManager } from './storage/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ICertManager implementation backed by StorageManager.
|
||||||
|
* Persists SmartAcme certificates under a /certs/ key prefix so they
|
||||||
|
* survive process restarts without re-hitting ACME.
|
||||||
|
*/
|
||||||
|
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
|
||||||
|
private keyPrefix = '/certs/';
|
||||||
|
|
||||||
|
constructor(private storageManager: StorageManager) {}
|
||||||
|
|
||||||
|
async init(): Promise<void> {}
|
||||||
|
|
||||||
|
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
||||||
|
const data = await this.storageManager.getJSON(this.keyPrefix + domainName);
|
||||||
|
if (!data) return null;
|
||||||
|
return new plugins.smartacme.Cert(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
||||||
|
await this.storageManager.setJSON(this.keyPrefix + cert.domainName, {
|
||||||
|
id: cert.id,
|
||||||
|
domainName: cert.domainName,
|
||||||
|
created: cert.created,
|
||||||
|
privateKey: cert.privateKey,
|
||||||
|
publicKey: cert.publicKey,
|
||||||
|
csr: cert.csr,
|
||||||
|
validUntil: cert.validUntil,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCertificate(domainName: string): Promise<void> {
|
||||||
|
await this.storageManager.delete(this.keyPrefix + domainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {}
|
||||||
|
|
||||||
|
async wipe(): Promise<void> {
|
||||||
|
const keys = await this.storageManager.list(this.keyPrefix);
|
||||||
|
for (const key of keys) {
|
||||||
|
await this.storageManager.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
ts/config/classes.api-token-manager.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import type { StorageManager } from '../storage/index.js';
|
||||||
|
import type {
|
||||||
|
IStoredApiToken,
|
||||||
|
IApiTokenInfo,
|
||||||
|
TApiTokenScope,
|
||||||
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const TOKENS_PREFIX = '/config-api/tokens/';
|
||||||
|
const TOKEN_PREFIX_STR = 'dcr_';
|
||||||
|
|
||||||
|
export class ApiTokenManager {
|
||||||
|
private tokens = new Map<string, IStoredApiToken>();
|
||||||
|
|
||||||
|
constructor(private storageManager: StorageManager) {}
|
||||||
|
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await this.loadTokens();
|
||||||
|
if (this.tokens.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Token lifecycle
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new API token. Returns the raw token value (shown once).
|
||||||
|
*/
|
||||||
|
public async createToken(
|
||||||
|
name: string,
|
||||||
|
scopes: TApiTokenScope[],
|
||||||
|
expiresInDays: number | null,
|
||||||
|
createdBy: string,
|
||||||
|
): Promise<{ id: string; rawToken: string }> {
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const randomBytes = plugins.crypto.randomBytes(32);
|
||||||
|
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
||||||
|
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
||||||
|
|
||||||
|
const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const stored: IStoredApiToken = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
tokenHash,
|
||||||
|
scopes,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
|
||||||
|
lastUsedAt: null,
|
||||||
|
createdBy,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.tokens.set(id, stored);
|
||||||
|
await this.persistToken(stored);
|
||||||
|
logger.log('info', `API token '${name}' created (id: ${id})`);
|
||||||
|
return { id, rawToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a raw token string. Returns the stored token if valid, null otherwise.
|
||||||
|
* Also updates lastUsedAt.
|
||||||
|
*/
|
||||||
|
public async validateToken(rawToken: string): Promise<IStoredApiToken | null> {
|
||||||
|
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null;
|
||||||
|
|
||||||
|
const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||||
|
|
||||||
|
for (const stored of this.tokens.values()) {
|
||||||
|
if (stored.tokenHash === hash) {
|
||||||
|
if (!stored.enabled) return null;
|
||||||
|
if (stored.expiresAt !== null && stored.expiresAt < Date.now()) return null;
|
||||||
|
|
||||||
|
// Update lastUsedAt (fire and forget)
|
||||||
|
stored.lastUsedAt = Date.now();
|
||||||
|
this.persistToken(stored).catch(() => {});
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a token has a specific scope.
|
||||||
|
*/
|
||||||
|
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
|
||||||
|
return token.scopes.includes(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all tokens (safe info only, no hashes).
|
||||||
|
*/
|
||||||
|
public listTokens(): IApiTokenInfo[] {
|
||||||
|
const result: IApiTokenInfo[] = [];
|
||||||
|
for (const stored of this.tokens.values()) {
|
||||||
|
result.push({
|
||||||
|
id: stored.id,
|
||||||
|
name: stored.name,
|
||||||
|
scopes: stored.scopes,
|
||||||
|
createdAt: stored.createdAt,
|
||||||
|
expiresAt: stored.expiresAt,
|
||||||
|
lastUsedAt: stored.lastUsedAt,
|
||||||
|
enabled: stored.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke (delete) a token.
|
||||||
|
*/
|
||||||
|
public async revokeToken(id: string): Promise<boolean> {
|
||||||
|
if (!this.tokens.has(id)) return false;
|
||||||
|
const token = this.tokens.get(id)!;
|
||||||
|
this.tokens.delete(id);
|
||||||
|
await this.storageManager.delete(`${TOKENS_PREFIX}${id}.json`);
|
||||||
|
logger.log('info', `API token '${token.name}' revoked (id: ${id})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll (regenerate) a token's secret while keeping its identity.
|
||||||
|
* Returns the new raw token value (shown once).
|
||||||
|
*/
|
||||||
|
public async rollToken(id: string): Promise<{ id: string; rawToken: string } | null> {
|
||||||
|
const stored = this.tokens.get(id);
|
||||||
|
if (!stored) return null;
|
||||||
|
|
||||||
|
const randomBytes = plugins.crypto.randomBytes(32);
|
||||||
|
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
||||||
|
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
||||||
|
|
||||||
|
stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||||
|
await this.persistToken(stored);
|
||||||
|
logger.log('info', `API token '${stored.name}' rolled (id: ${id})`);
|
||||||
|
return { id, rawToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable a token.
|
||||||
|
*/
|
||||||
|
public async toggleToken(id: string, enabled: boolean): Promise<boolean> {
|
||||||
|
const stored = this.tokens.get(id);
|
||||||
|
if (!stored) return false;
|
||||||
|
stored.enabled = enabled;
|
||||||
|
await this.persistToken(stored);
|
||||||
|
logger.log('info', `API token '${stored.name}' ${enabled ? 'enabled' : 'disabled'} (id: ${id})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async loadTokens(): Promise<void> {
|
||||||
|
const keys = await this.storageManager.list(TOKENS_PREFIX);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!key.endsWith('.json')) continue;
|
||||||
|
const stored = await this.storageManager.getJSON<IStoredApiToken>(key);
|
||||||
|
if (stored?.id) {
|
||||||
|
this.tokens.set(stored.id, stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistToken(stored: IStoredApiToken): Promise<void> {
|
||||||
|
await this.storageManager.setJSON(`${TOKENS_PREFIX}${stored.id}.json`, stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
271
ts/config/classes.route-config-manager.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import type { StorageManager } from '../storage/index.js';
|
||||||
|
import type {
|
||||||
|
IStoredRoute,
|
||||||
|
IRouteOverride,
|
||||||
|
IMergedRoute,
|
||||||
|
IRouteWarning,
|
||||||
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const ROUTES_PREFIX = '/config-api/routes/';
|
||||||
|
const OVERRIDES_PREFIX = '/config-api/overrides/';
|
||||||
|
|
||||||
|
export class RouteConfigManager {
|
||||||
|
private storedRoutes = new Map<string, IStoredRoute>();
|
||||||
|
private overrides = new Map<string, IRouteOverride>();
|
||||||
|
private warnings: IRouteWarning[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private storageManager: StorageManager,
|
||||||
|
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||||
|
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
|
||||||
|
*/
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await this.loadStoredRoutes();
|
||||||
|
await this.loadOverrides();
|
||||||
|
this.computeWarnings();
|
||||||
|
this.logWarnings();
|
||||||
|
await this.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Merged view
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
|
||||||
|
const merged: IMergedRoute[] = [];
|
||||||
|
|
||||||
|
// Hardcoded routes
|
||||||
|
for (const route of this.getHardcodedRoutes()) {
|
||||||
|
const name = route.name || '';
|
||||||
|
const override = this.overrides.get(name);
|
||||||
|
merged.push({
|
||||||
|
route,
|
||||||
|
source: 'hardcoded',
|
||||||
|
enabled: override ? override.enabled : true,
|
||||||
|
overridden: !!override,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Programmatic routes
|
||||||
|
for (const stored of this.storedRoutes.values()) {
|
||||||
|
merged.push({
|
||||||
|
route: stored.route,
|
||||||
|
source: 'programmatic',
|
||||||
|
enabled: stored.enabled,
|
||||||
|
overridden: false,
|
||||||
|
storedRouteId: stored.id,
|
||||||
|
createdAt: stored.createdAt,
|
||||||
|
updatedAt: stored.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { routes: merged, warnings: [...this.warnings] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Programmatic route CRUD
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async createRoute(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
createdBy: string,
|
||||||
|
enabled = true,
|
||||||
|
): Promise<string> {
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Ensure route has a name
|
||||||
|
if (!route.name) {
|
||||||
|
route.name = `programmatic-${id.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored: IStoredRoute = {
|
||||||
|
id,
|
||||||
|
route,
|
||||||
|
enabled,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.storedRoutes.set(id, stored);
|
||||||
|
await this.persistRoute(stored);
|
||||||
|
await this.applyRoutes();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateRoute(
|
||||||
|
id: string,
|
||||||
|
patch: { route?: Partial<plugins.smartproxy.IRouteConfig>; enabled?: boolean },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const stored = this.storedRoutes.get(id);
|
||||||
|
if (!stored) return false;
|
||||||
|
|
||||||
|
if (patch.route) {
|
||||||
|
stored.route = { ...stored.route, ...patch.route } as plugins.smartproxy.IRouteConfig;
|
||||||
|
}
|
||||||
|
if (patch.enabled !== undefined) {
|
||||||
|
stored.enabled = patch.enabled;
|
||||||
|
}
|
||||||
|
stored.updatedAt = Date.now();
|
||||||
|
|
||||||
|
await this.persistRoute(stored);
|
||||||
|
await this.applyRoutes();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteRoute(id: string): Promise<boolean> {
|
||||||
|
if (!this.storedRoutes.has(id)) return false;
|
||||||
|
this.storedRoutes.delete(id);
|
||||||
|
await this.storageManager.delete(`${ROUTES_PREFIX}${id}.json`);
|
||||||
|
await this.applyRoutes();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
|
||||||
|
return this.updateRoute(id, { enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Hardcoded route overrides
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise<void> {
|
||||||
|
const override: IRouteOverride = {
|
||||||
|
routeName,
|
||||||
|
enabled,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
updatedBy,
|
||||||
|
};
|
||||||
|
this.overrides.set(routeName, override);
|
||||||
|
await this.storageManager.setJSON(`${OVERRIDES_PREFIX}${routeName}.json`, override);
|
||||||
|
this.computeWarnings();
|
||||||
|
await this.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeOverride(routeName: string): Promise<boolean> {
|
||||||
|
if (!this.overrides.has(routeName)) return false;
|
||||||
|
this.overrides.delete(routeName);
|
||||||
|
await this.storageManager.delete(`${OVERRIDES_PREFIX}${routeName}.json`);
|
||||||
|
this.computeWarnings();
|
||||||
|
await this.applyRoutes();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: persistence
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async loadStoredRoutes(): Promise<void> {
|
||||||
|
const keys = await this.storageManager.list(ROUTES_PREFIX);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!key.endsWith('.json')) continue;
|
||||||
|
const stored = await this.storageManager.getJSON<IStoredRoute>(key);
|
||||||
|
if (stored?.id) {
|
||||||
|
this.storedRoutes.set(stored.id, stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.storedRoutes.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadOverrides(): Promise<void> {
|
||||||
|
const keys = await this.storageManager.list(OVERRIDES_PREFIX);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!key.endsWith('.json')) continue;
|
||||||
|
const override = await this.storageManager.getJSON<IRouteOverride>(key);
|
||||||
|
if (override?.routeName) {
|
||||||
|
this.overrides.set(override.routeName, override);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.overrides.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistRoute(stored: IStoredRoute): Promise<void> {
|
||||||
|
await this.storageManager.setJSON(`${ROUTES_PREFIX}${stored.id}.json`, stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: warnings
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private computeWarnings(): void {
|
||||||
|
this.warnings = [];
|
||||||
|
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
|
||||||
|
|
||||||
|
// Check overrides
|
||||||
|
for (const [routeName, override] of this.overrides) {
|
||||||
|
if (!hardcodedNames.has(routeName)) {
|
||||||
|
this.warnings.push({
|
||||||
|
type: 'orphaned-override',
|
||||||
|
routeName,
|
||||||
|
message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`,
|
||||||
|
});
|
||||||
|
} else if (!override.enabled) {
|
||||||
|
this.warnings.push({
|
||||||
|
type: 'disabled-hardcoded',
|
||||||
|
routeName,
|
||||||
|
message: `Route '${routeName}' is disabled via API override`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check disabled programmatic routes
|
||||||
|
for (const stored of this.storedRoutes.values()) {
|
||||||
|
if (!stored.enabled) {
|
||||||
|
const name = stored.route.name || stored.id;
|
||||||
|
this.warnings.push({
|
||||||
|
type: 'disabled-programmatic',
|
||||||
|
routeName: name,
|
||||||
|
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private logWarnings(): void {
|
||||||
|
for (const w of this.warnings) {
|
||||||
|
logger.log('warn', w.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: apply merged routes to SmartProxy
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async applyRoutes(): Promise<void> {
|
||||||
|
const smartProxy = this.getSmartProxy();
|
||||||
|
if (!smartProxy) return;
|
||||||
|
|
||||||
|
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
|
// Add enabled hardcoded routes (respecting overrides)
|
||||||
|
for (const route of this.getHardcodedRoutes()) {
|
||||||
|
const name = route.name || '';
|
||||||
|
const override = this.overrides.get(name);
|
||||||
|
if (override && !override.enabled) {
|
||||||
|
continue; // Skip disabled hardcoded route
|
||||||
|
}
|
||||||
|
enabledRoutes.push(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add enabled programmatic routes
|
||||||
|
for (const stored of this.storedRoutes.values()) {
|
||||||
|
if (stored.enabled) {
|
||||||
|
enabledRoutes.push(stored.route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await smartProxy.updateRoutes(enabledRoutes);
|
||||||
|
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
ts/config/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Export validation tools only
|
||||||
|
export * from './validator.js';
|
||||||
|
export { RouteConfigManager } from './classes.route-config-manager.js';
|
||||||
|
export { ApiTokenManager } from './classes.api-token-manager.js';
|
||||||
266
ts/config/validator.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { ValidationError } from '../errors/base.errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result
|
||||||
|
*/
|
||||||
|
export interface IValidationResult {
|
||||||
|
/**
|
||||||
|
* Whether the validation passed
|
||||||
|
*/
|
||||||
|
valid: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation errors if any
|
||||||
|
*/
|
||||||
|
errors?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validated configuration (may include defaults)
|
||||||
|
*/
|
||||||
|
config?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation schema types
|
||||||
|
*/
|
||||||
|
export type ValidationSchema = Record<string, {
|
||||||
|
/**
|
||||||
|
* Type of the value
|
||||||
|
*/
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the field is required
|
||||||
|
*/
|
||||||
|
required?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default value if not specified
|
||||||
|
*/
|
||||||
|
default?: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum value (for numbers)
|
||||||
|
*/
|
||||||
|
min?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum value (for numbers)
|
||||||
|
*/
|
||||||
|
max?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum length (for strings or arrays)
|
||||||
|
*/
|
||||||
|
minLength?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum length (for strings or arrays)
|
||||||
|
*/
|
||||||
|
maxLength?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern to match (for strings)
|
||||||
|
*/
|
||||||
|
pattern?: RegExp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed values (for strings, numbers)
|
||||||
|
*/
|
||||||
|
enum?: any[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nested schema (for objects)
|
||||||
|
*/
|
||||||
|
schema?: ValidationSchema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item schema (for arrays)
|
||||||
|
*/
|
||||||
|
items?: {
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'object';
|
||||||
|
schema?: ValidationSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom validation function
|
||||||
|
*/
|
||||||
|
validate?: (value: any) => boolean | string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration validator
|
||||||
|
* Validates configuration objects against schemas and provides default values
|
||||||
|
*/
|
||||||
|
export class ConfigValidator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a configuration object against a schema
|
||||||
|
*
|
||||||
|
* @param config Configuration object to validate
|
||||||
|
* @param schema Validation schema
|
||||||
|
* @returns Validation result
|
||||||
|
*/
|
||||||
|
public static validate<T>(config: T, schema: ValidationSchema): IValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const validatedConfig = { ...config };
|
||||||
|
|
||||||
|
// Validate each field against the schema
|
||||||
|
for (const [key, rules] of Object.entries(schema)) {
|
||||||
|
const value = config[key];
|
||||||
|
|
||||||
|
// Check if required
|
||||||
|
if (rules.required && (value === undefined || value === null)) {
|
||||||
|
errors.push(`${key} is required`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not present and not required, apply default if available
|
||||||
|
if ((value === undefined || value === null)) {
|
||||||
|
if (rules.default !== undefined) {
|
||||||
|
validatedConfig[key] = rules.default;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type validation
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
const valueType = Array.isArray(value) ? 'array' : typeof value;
|
||||||
|
if (valueType !== rules.type) {
|
||||||
|
errors.push(`${key} must be of type ${rules.type}, got ${valueType}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type-specific validations
|
||||||
|
switch (rules.type) {
|
||||||
|
case 'number':
|
||||||
|
if (rules.min !== undefined && value < rules.min) {
|
||||||
|
errors.push(`${key} must be at least ${rules.min}`);
|
||||||
|
}
|
||||||
|
if (rules.max !== undefined && value > rules.max) {
|
||||||
|
errors.push(`${key} must be at most ${rules.max}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'string':
|
||||||
|
if (rules.minLength !== undefined && value.length < rules.minLength) {
|
||||||
|
errors.push(`${key} must be at least ${rules.minLength} characters`);
|
||||||
|
}
|
||||||
|
if (rules.maxLength !== undefined && value.length > rules.maxLength) {
|
||||||
|
errors.push(`${key} must be at most ${rules.maxLength} characters`);
|
||||||
|
}
|
||||||
|
if (rules.pattern && !rules.pattern.test(value)) {
|
||||||
|
errors.push(`${key} must match pattern ${rules.pattern}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'array':
|
||||||
|
if (rules.minLength !== undefined && value.length < rules.minLength) {
|
||||||
|
errors.push(`${key} must have at least ${rules.minLength} items`);
|
||||||
|
}
|
||||||
|
if (rules.maxLength !== undefined && value.length > rules.maxLength) {
|
||||||
|
errors.push(`${key} must have at most ${rules.maxLength} items`);
|
||||||
|
}
|
||||||
|
if (rules.items && value.length > 0) {
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const itemType = Array.isArray(value[i]) ? 'array' : typeof value[i];
|
||||||
|
if (itemType !== rules.items.type) {
|
||||||
|
errors.push(`${key}[${i}] must be of type ${rules.items.type}, got ${itemType}`);
|
||||||
|
} else if (rules.items.schema && itemType === 'object') {
|
||||||
|
const itemResult = this.validate(value[i], rules.items.schema);
|
||||||
|
if (!itemResult.valid) {
|
||||||
|
errors.push(...itemResult.errors.map(err => `${key}[${i}].${err}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'object':
|
||||||
|
if (rules.schema) {
|
||||||
|
const nestedResult = this.validate(value, rules.schema);
|
||||||
|
if (!nestedResult.valid) {
|
||||||
|
errors.push(...nestedResult.errors.map(err => `${key}.${err}`));
|
||||||
|
}
|
||||||
|
validatedConfig[key] = nestedResult.config;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum validation
|
||||||
|
if (rules.enum && !rules.enum.includes(value)) {
|
||||||
|
errors.push(`${key} must be one of [${rules.enum.join(', ')}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom validation
|
||||||
|
if (rules.validate) {
|
||||||
|
const result = rules.validate(value);
|
||||||
|
if (result !== true) {
|
||||||
|
errors.push(typeof result === 'string' ? result : `${key} failed custom validation`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
config: validatedConfig
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply defaults to a configuration object based on a schema
|
||||||
|
*
|
||||||
|
* @param config Configuration object to apply defaults to
|
||||||
|
* @param schema Validation schema with defaults
|
||||||
|
* @returns Configuration with defaults applied
|
||||||
|
*/
|
||||||
|
public static applyDefaults<T>(config: T, schema: ValidationSchema): T {
|
||||||
|
const result = { ...config };
|
||||||
|
|
||||||
|
for (const [key, rules] of Object.entries(schema)) {
|
||||||
|
if (result[key] === undefined && rules.default !== undefined) {
|
||||||
|
result[key] = rules.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply defaults to nested objects
|
||||||
|
if (result[key] && rules.type === 'object' && rules.schema) {
|
||||||
|
result[key] = this.applyDefaults(result[key], rules.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply defaults to array items
|
||||||
|
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
||||||
|
result[key] = result[key].map(item =>
|
||||||
|
typeof item === 'object' ? this.applyDefaults(item, rules.items.schema) : item
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw a validation error if the configuration is invalid
|
||||||
|
*
|
||||||
|
* @param config Configuration to validate
|
||||||
|
* @param schema Validation schema
|
||||||
|
* @returns Validated configuration with defaults
|
||||||
|
* @throws ValidationError if validation fails
|
||||||
|
*/
|
||||||
|
public static validateOrThrow<T>(config: T, schema: ValidationSchema): T {
|
||||||
|
const result = this.validate(config, schema);
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Configuration validation failed: ${result.errors.join(', ')}`,
|
||||||
|
'CONFIG_VALIDATION_ERROR',
|
||||||
|
{ data: { errors: result.errors } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// This file is maintained for backward compatibility only
|
|
||||||
// New code should use qenv directly
|
|
||||||
|
|
||||||
import * as plugins from '../plugins.js';
|
|
||||||
import type DcRouter from './classes.dcrouter.js';
|
|
||||||
|
|
||||||
export class SzDcRouterConnector {
|
|
||||||
public qenv: plugins.qenv.Qenv;
|
|
||||||
public dcRouterRef: DcRouter;
|
|
||||||
|
|
||||||
constructor(dcRouterRef: DcRouter) {
|
|
||||||
this.dcRouterRef = dcRouterRef;
|
|
||||||
// Initialize qenv directly
|
|
||||||
this.qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getEnvVarOnDemand(varName: string): Promise<string> {
|
|
||||||
return this.qenv.getEnvVarOnDemand(varName) || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import { SmtpPortConfig, type ISmtpPortSettings } from './classes.smtp.portconfig.js';
|
|
||||||
import { EmailDomainRouter, type IEmailDomainRoutingConfig } from './classes.email.domainrouter.js';
|
|
||||||
|
|
||||||
// Certificate types are available via plugins.tsclass
|
|
||||||
|
|
||||||
// Import the consolidated email config
|
|
||||||
import type { IEmailConfig } from './classes.email.config.js';
|
|
||||||
import { DomainRouter } from './classes.domain.router.js';
|
|
||||||
|
|
||||||
export interface IDcRouterOptions {
|
|
||||||
/**
|
|
||||||
* Direct SmartProxy configuration - gives full control over HTTP/HTTPS and TCP/SNI traffic
|
|
||||||
* This is the preferred way to configure HTTP/HTTPS and general TCP/SNI traffic
|
|
||||||
*/
|
|
||||||
smartProxyConfig?: plugins.smartproxy.ISmartProxyOptions;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consolidated email configuration
|
|
||||||
* This enables all email handling with pattern-based routing
|
|
||||||
*/
|
|
||||||
emailConfig?: IEmailConfig;
|
|
||||||
|
|
||||||
/** TLS/certificate configuration */
|
|
||||||
tls?: {
|
|
||||||
/** Contact email for ACME certificates */
|
|
||||||
contactEmail: string;
|
|
||||||
/** Domain for main certificate */
|
|
||||||
domain?: string;
|
|
||||||
/** Path to certificate file (if not using auto-provisioning) */
|
|
||||||
certPath?: string;
|
|
||||||
/** Path to key file (if not using auto-provisioning) */
|
|
||||||
keyPath?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** DNS server configuration */
|
|
||||||
dnsServerConfig?: plugins.smartdns.IDnsServerOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DcRouter can be run on ingress and egress to and from a datacenter site.
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Context passed to HTTP routing rules
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Context passed to port proxy (SmartProxy) routing rules
|
|
||||||
*/
|
|
||||||
export interface PortProxyRuleContext {
|
|
||||||
proxy: plugins.smartproxy.SmartProxy;
|
|
||||||
configs: plugins.smartproxy.IPortProxySettings['domainConfigs'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DcRouter {
|
|
||||||
public options: IDcRouterOptions;
|
|
||||||
|
|
||||||
// Core services
|
|
||||||
public smartProxy?: plugins.smartproxy.SmartProxy;
|
|
||||||
public dnsServer?: plugins.smartdns.DnsServer;
|
|
||||||
|
|
||||||
// Unified email components
|
|
||||||
public domainRouter?: DomainRouter;
|
|
||||||
|
|
||||||
// Environment access
|
|
||||||
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
|
||||||
|
|
||||||
constructor(optionsArg: IDcRouterOptions) {
|
|
||||||
// Set defaults in options
|
|
||||||
this.options = {
|
|
||||||
...optionsArg
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async start() {
|
|
||||||
console.log('Starting DcRouter services...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Set up SmartProxy for HTTP/HTTPS and general TCP/SNI traffic
|
|
||||||
if (this.options.smartProxyConfig) {
|
|
||||||
await this.setupSmartProxy();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up unified email handling if configured
|
|
||||||
if (this.options.emailConfig) {
|
|
||||||
await this.setupUnifiedEmailHandling();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Set up DNS server if configured
|
|
||||||
if (this.options.dnsServerConfig) {
|
|
||||||
this.dnsServer = new plugins.smartdns.DnsServer(this.options.dnsServerConfig);
|
|
||||||
await this.dnsServer.start();
|
|
||||||
console.log('DNS server started');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('DcRouter started successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error starting DcRouter:', error);
|
|
||||||
// Try to clean up any services that may have started
|
|
||||||
await this.stop();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up SmartProxy with direct configuration
|
|
||||||
*/
|
|
||||||
private async setupSmartProxy(): Promise<void> {
|
|
||||||
if (!this.options.smartProxyConfig) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Setting up SmartProxy with direct configuration');
|
|
||||||
|
|
||||||
// Create SmartProxy instance with full configuration
|
|
||||||
this.smartProxy = new plugins.smartproxy.SmartProxy(this.options.smartProxyConfig);
|
|
||||||
|
|
||||||
// Set up event listeners
|
|
||||||
this.smartProxy.on('error', (err) => {
|
|
||||||
console.error('SmartProxy error:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.options.smartProxyConfig.acme) {
|
|
||||||
this.smartProxy.on('certificate-issued', (event) => {
|
|
||||||
console.log(`Certificate issued for ${event.domain}, expires ${event.expiryDate}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.smartProxy.on('certificate-renewed', (event) => {
|
|
||||||
console.log(`Certificate renewed for ${event.domain}, expires ${event.expiryDate}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start SmartProxy
|
|
||||||
await this.smartProxy.start();
|
|
||||||
|
|
||||||
console.log('SmartProxy started successfully');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a domain matches a pattern (including wildcard support)
|
|
||||||
* @param domain The domain to check
|
|
||||||
* @param pattern The pattern to match against (e.g., "*.example.com")
|
|
||||||
* @returns Whether the domain matches the pattern
|
|
||||||
*/
|
|
||||||
private isDomainMatch(domain: string, pattern: string): boolean {
|
|
||||||
// Normalize inputs
|
|
||||||
domain = domain.toLowerCase();
|
|
||||||
pattern = pattern.toLowerCase();
|
|
||||||
|
|
||||||
// Check for exact match
|
|
||||||
if (domain === pattern) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for wildcard match (*.example.com)
|
|
||||||
if (pattern.startsWith('*.')) {
|
|
||||||
const patternSuffix = pattern.slice(2); // Remove the "*." prefix
|
|
||||||
|
|
||||||
// Check if domain ends with the pattern suffix and has at least one character before it
|
|
||||||
return domain.endsWith(patternSuffix) && domain.length > patternSuffix.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No match
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stop() {
|
|
||||||
console.log('Stopping DcRouter services...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Stop all services in parallel for faster shutdown
|
|
||||||
await Promise.all([
|
|
||||||
// Stop unified email components if running
|
|
||||||
this.domainRouter ? this.stopUnifiedEmailComponents().catch(err => console.error('Error stopping unified email components:', err)) : Promise.resolve(),
|
|
||||||
|
|
||||||
// Stop HTTP SmartProxy if running
|
|
||||||
this.smartProxy ? this.smartProxy.stop().catch(err => console.error('Error stopping SmartProxy:', err)) : Promise.resolve(),
|
|
||||||
|
|
||||||
// Stop DNS server if running
|
|
||||||
this.dnsServer ?
|
|
||||||
this.dnsServer.stop().catch(err => console.error('Error stopping DNS server:', err)) :
|
|
||||||
Promise.resolve()
|
|
||||||
]);
|
|
||||||
|
|
||||||
console.log('All DcRouter services stopped');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error during DcRouter shutdown:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update SmartProxy configuration
|
|
||||||
* @param config New SmartProxy configuration
|
|
||||||
*/
|
|
||||||
public async updateSmartProxyConfig(config: plugins.smartproxy.ISmartProxyOptions): Promise<void> {
|
|
||||||
// Stop existing SmartProxy if running
|
|
||||||
if (this.smartProxy) {
|
|
||||||
await this.smartProxy.stop();
|
|
||||||
this.smartProxy = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update configuration
|
|
||||||
this.options.smartProxyConfig = config;
|
|
||||||
|
|
||||||
// Start new SmartProxy with updated configuration
|
|
||||||
await this.setupSmartProxy();
|
|
||||||
|
|
||||||
console.log('SmartProxy configuration updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up unified email handling with pattern-based routing
|
|
||||||
* This implements the consolidated emailConfig approach
|
|
||||||
*/
|
|
||||||
private async setupUnifiedEmailHandling(): Promise<void> {
|
|
||||||
console.log('Setting up unified email handling with pattern-based routing');
|
|
||||||
|
|
||||||
if (!this.options.emailConfig) {
|
|
||||||
throw new Error('Email configuration is required for unified email handling');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create domain router for pattern matching
|
|
||||||
this.domainRouter = new DomainRouter({
|
|
||||||
domainRules: this.options.emailConfig.domainRules,
|
|
||||||
defaultMode: this.options.emailConfig.defaultMode,
|
|
||||||
defaultServer: this.options.emailConfig.defaultServer,
|
|
||||||
defaultPort: this.options.emailConfig.defaultPort,
|
|
||||||
defaultTls: this.options.emailConfig.defaultTls
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Initialize the full unified email processing pipeline
|
|
||||||
|
|
||||||
console.log(`Unified email handling configured with ${this.options.emailConfig.domainRules.length} domain rules`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error setting up unified email handling:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the unified email configuration
|
|
||||||
* @param config New email configuration
|
|
||||||
*/
|
|
||||||
public async updateEmailConfig(config: IEmailConfig): Promise<void> {
|
|
||||||
// Stop existing email components
|
|
||||||
await this.stopUnifiedEmailComponents();
|
|
||||||
|
|
||||||
// Update configuration
|
|
||||||
this.options.emailConfig = config;
|
|
||||||
|
|
||||||
// Start email handling with new configuration
|
|
||||||
await this.setupUnifiedEmailHandling();
|
|
||||||
|
|
||||||
console.log('Unified email configuration updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop all unified email components
|
|
||||||
*/
|
|
||||||
private async stopUnifiedEmailComponents(): Promise<void> {
|
|
||||||
// TODO: Implement stopping all unified email components
|
|
||||||
|
|
||||||
// Clear the domain router
|
|
||||||
this.domainRouter = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DcRouter;
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { EventEmitter } from 'node:events';
|
|
||||||
import { type IDomainRule, type EmailProcessingMode } from './classes.email.config.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for the domain-based router
|
|
||||||
*/
|
|
||||||
export interface IDomainRouterOptions {
|
|
||||||
// Domain rules with glob pattern matching
|
|
||||||
domainRules: IDomainRule[];
|
|
||||||
|
|
||||||
// Default handling for unmatched domains
|
|
||||||
defaultMode: EmailProcessingMode;
|
|
||||||
defaultServer?: string;
|
|
||||||
defaultPort?: number;
|
|
||||||
defaultTls?: boolean;
|
|
||||||
|
|
||||||
// Pattern matching options
|
|
||||||
caseSensitive?: boolean;
|
|
||||||
priorityOrder?: 'most-specific' | 'first-match';
|
|
||||||
|
|
||||||
// Cache settings for pattern matching
|
|
||||||
enableCache?: boolean;
|
|
||||||
cacheSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of a pattern match operation
|
|
||||||
*/
|
|
||||||
export interface IPatternMatchResult {
|
|
||||||
rule: IDomainRule;
|
|
||||||
exactMatch: boolean;
|
|
||||||
wildcardMatch: boolean;
|
|
||||||
specificity: number; // Higher is more specific
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A pattern matching and routing class for email domains
|
|
||||||
*/
|
|
||||||
export class DomainRouter extends EventEmitter {
|
|
||||||
private options: IDomainRouterOptions;
|
|
||||||
private patternCache: Map<string, IDomainRule | null> = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new domain router
|
|
||||||
* @param options Router options
|
|
||||||
*/
|
|
||||||
constructor(options: IDomainRouterOptions) {
|
|
||||||
super();
|
|
||||||
this.options = {
|
|
||||||
// Default options
|
|
||||||
caseSensitive: false,
|
|
||||||
priorityOrder: 'most-specific',
|
|
||||||
enableCache: true,
|
|
||||||
cacheSize: 1000,
|
|
||||||
...options
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match an email address against defined rules
|
|
||||||
* @param email Email address to match
|
|
||||||
* @returns The matching rule or null if no match
|
|
||||||
*/
|
|
||||||
public matchRule(email: string): IDomainRule | null {
|
|
||||||
// Check cache first if enabled
|
|
||||||
if (this.options.enableCache && this.patternCache.has(email)) {
|
|
||||||
return this.patternCache.get(email) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize email if case-insensitive
|
|
||||||
const normalizedEmail = this.options.caseSensitive ? email : email.toLowerCase();
|
|
||||||
|
|
||||||
// Get all matching rules
|
|
||||||
const matches = this.getAllMatchingRules(normalizedEmail);
|
|
||||||
|
|
||||||
if (matches.length === 0) {
|
|
||||||
// Cache the result (null) if caching is enabled
|
|
||||||
if (this.options.enableCache) {
|
|
||||||
this.addToCache(email, null);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by specificity or order
|
|
||||||
let matchedRule: IDomainRule;
|
|
||||||
|
|
||||||
if (this.options.priorityOrder === 'most-specific') {
|
|
||||||
// Sort by specificity (most specific first)
|
|
||||||
const sortedMatches = matches.sort((a, b) => {
|
|
||||||
const aSpecificity = this.calculateSpecificity(a.pattern);
|
|
||||||
const bSpecificity = this.calculateSpecificity(b.pattern);
|
|
||||||
return bSpecificity - aSpecificity;
|
|
||||||
});
|
|
||||||
|
|
||||||
matchedRule = sortedMatches[0];
|
|
||||||
} else {
|
|
||||||
// First match in the list
|
|
||||||
matchedRule = matches[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the result if caching is enabled
|
|
||||||
if (this.options.enableCache) {
|
|
||||||
this.addToCache(email, matchedRule);
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchedRule;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate pattern specificity
|
|
||||||
* Higher is more specific
|
|
||||||
* @param pattern Pattern to calculate specificity for
|
|
||||||
*/
|
|
||||||
private calculateSpecificity(pattern: string): number {
|
|
||||||
let specificity = 0;
|
|
||||||
|
|
||||||
// Exact match is most specific
|
|
||||||
if (!pattern.includes('*')) {
|
|
||||||
return 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count characters that aren't wildcards
|
|
||||||
specificity += pattern.replace(/\*/g, '').length;
|
|
||||||
|
|
||||||
// Position of wildcards affects specificity
|
|
||||||
if (pattern.startsWith('*@')) {
|
|
||||||
// Wildcard in local part
|
|
||||||
specificity += 10;
|
|
||||||
} else if (pattern.includes('@*')) {
|
|
||||||
// Wildcard in domain part
|
|
||||||
specificity += 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
return specificity;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if email matches a specific pattern
|
|
||||||
* @param email Email address to check
|
|
||||||
* @param pattern Pattern to check against
|
|
||||||
* @returns True if matching, false otherwise
|
|
||||||
*/
|
|
||||||
public matchesPattern(email: string, pattern: string): boolean {
|
|
||||||
// Normalize if case-insensitive
|
|
||||||
const normalizedEmail = this.options.caseSensitive ? email : email.toLowerCase();
|
|
||||||
const normalizedPattern = this.options.caseSensitive ? pattern : pattern.toLowerCase();
|
|
||||||
|
|
||||||
// Exact match
|
|
||||||
if (normalizedEmail === normalizedPattern) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert glob pattern to regex
|
|
||||||
const regexPattern = this.globToRegExp(normalizedPattern);
|
|
||||||
return regexPattern.test(normalizedEmail);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a glob pattern to a regular expression
|
|
||||||
* @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}$`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all rules that match an email address
|
|
||||||
* @param email Email address to match
|
|
||||||
* @returns Array of matching rules
|
|
||||||
*/
|
|
||||||
public getAllMatchingRules(email: string): IDomainRule[] {
|
|
||||||
return this.options.domainRules.filter(rule => this.matchesPattern(email, rule.pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new routing rule
|
|
||||||
* @param rule Domain rule to add
|
|
||||||
*/
|
|
||||||
public addRule(rule: IDomainRule): void {
|
|
||||||
// Validate the rule
|
|
||||||
this.validateRule(rule);
|
|
||||||
|
|
||||||
// Add the rule
|
|
||||||
this.options.domainRules.push(rule);
|
|
||||||
|
|
||||||
// Clear cache since rules have changed
|
|
||||||
this.clearCache();
|
|
||||||
|
|
||||||
// Emit event
|
|
||||||
this.emit('ruleAdded', rule);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a domain rule
|
|
||||||
* @param rule Rule to validate
|
|
||||||
*/
|
|
||||||
private validateRule(rule: IDomainRule): void {
|
|
||||||
// Pattern is required
|
|
||||||
if (!rule.pattern) {
|
|
||||||
throw new Error('Domain rule pattern is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mode is required
|
|
||||||
if (!rule.mode) {
|
|
||||||
throw new Error('Domain rule mode is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward mode requires target
|
|
||||||
if (rule.mode === 'forward' && !rule.target) {
|
|
||||||
throw new Error('Forward mode requires target configuration');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward mode target requires server
|
|
||||||
if (rule.mode === 'forward' && rule.target && !rule.target.server) {
|
|
||||||
throw new Error('Forward mode target requires server');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing rule
|
|
||||||
* @param pattern Pattern to update
|
|
||||||
* @param updates Updates to apply
|
|
||||||
* @returns True if rule was found and updated, false otherwise
|
|
||||||
*/
|
|
||||||
public updateRule(pattern: string, updates: Partial<IDomainRule>): boolean {
|
|
||||||
const ruleIndex = this.options.domainRules.findIndex(r => r.pattern === pattern);
|
|
||||||
|
|
||||||
if (ruleIndex === -1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current rule
|
|
||||||
const currentRule = this.options.domainRules[ruleIndex];
|
|
||||||
|
|
||||||
// Create updated rule
|
|
||||||
const updatedRule: IDomainRule = {
|
|
||||||
...currentRule,
|
|
||||||
...updates
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate the updated rule
|
|
||||||
this.validateRule(updatedRule);
|
|
||||||
|
|
||||||
// Update the rule
|
|
||||||
this.options.domainRules[ruleIndex] = updatedRule;
|
|
||||||
|
|
||||||
// Clear cache since rules have changed
|
|
||||||
this.clearCache();
|
|
||||||
|
|
||||||
// Emit event
|
|
||||||
this.emit('ruleUpdated', updatedRule);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a rule
|
|
||||||
* @param pattern Pattern to remove
|
|
||||||
* @returns True if rule was found and removed, false otherwise
|
|
||||||
*/
|
|
||||||
public removeRule(pattern: string): boolean {
|
|
||||||
const initialLength = this.options.domainRules.length;
|
|
||||||
this.options.domainRules = this.options.domainRules.filter(r => r.pattern !== pattern);
|
|
||||||
|
|
||||||
const removed = initialLength > this.options.domainRules.length;
|
|
||||||
|
|
||||||
if (removed) {
|
|
||||||
// Clear cache since rules have changed
|
|
||||||
this.clearCache();
|
|
||||||
|
|
||||||
// Emit event
|
|
||||||
this.emit('ruleRemoved', pattern);
|
|
||||||
}
|
|
||||||
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get rule by pattern
|
|
||||||
* @param pattern Pattern to find
|
|
||||||
* @returns Rule with matching pattern or null if not found
|
|
||||||
*/
|
|
||||||
public getRule(pattern: string): IDomainRule | null {
|
|
||||||
return this.options.domainRules.find(r => r.pattern === pattern) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all rules
|
|
||||||
* @returns Array of all domain rules
|
|
||||||
*/
|
|
||||||
public getRules(): IDomainRule[] {
|
|
||||||
return [...this.options.domainRules];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update options
|
|
||||||
* @param options New options
|
|
||||||
*/
|
|
||||||
public updateOptions(options: Partial<IDomainRouterOptions>): void {
|
|
||||||
this.options = {
|
|
||||||
...this.options,
|
|
||||||
...options
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clear cache if cache settings changed
|
|
||||||
if ('enableCache' in options || 'cacheSize' in options) {
|
|
||||||
this.clearCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit event
|
|
||||||
this.emit('optionsUpdated', this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add an item to the pattern cache
|
|
||||||
* @param email Email address
|
|
||||||
* @param rule Matching rule or null
|
|
||||||
*/
|
|
||||||
private addToCache(email: string, rule: IDomainRule | null): void {
|
|
||||||
// If cache is disabled, do nothing
|
|
||||||
if (!this.options.enableCache) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to cache
|
|
||||||
this.patternCache.set(email, rule);
|
|
||||||
|
|
||||||
// Check if cache size exceeds limit
|
|
||||||
if (this.patternCache.size > (this.options.cacheSize || 1000)) {
|
|
||||||
// Remove oldest entry (first in the Map)
|
|
||||||
const firstKey = this.patternCache.keys().next().value;
|
|
||||||
this.patternCache.delete(firstKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear pattern matching cache
|
|
||||||
*/
|
|
||||||
public clearCache(): void {
|
|
||||||
this.patternCache.clear();
|
|
||||||
this.emit('cacheCleared');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email processing modes
|
|
||||||
*/
|
|
||||||
export type EmailProcessingMode = 'forward' | 'mta' | 'process';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consolidated email configuration interface
|
|
||||||
*/
|
|
||||||
export interface IEmailConfig {
|
|
||||||
// Email server settings
|
|
||||||
ports: number[];
|
|
||||||
hostname: string;
|
|
||||||
maxMessageSize?: number;
|
|
||||||
|
|
||||||
// TLS configuration for email server
|
|
||||||
tls?: {
|
|
||||||
certPath?: string;
|
|
||||||
keyPath?: string;
|
|
||||||
caPath?: string;
|
|
||||||
minVersion?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Authentication for inbound connections
|
|
||||||
auth?: {
|
|
||||||
required?: boolean;
|
|
||||||
methods?: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
|
|
||||||
users?: Array<{username: string, password: string}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Default routing for unmatched domains
|
|
||||||
defaultMode: EmailProcessingMode;
|
|
||||||
defaultServer?: string;
|
|
||||||
defaultPort?: number;
|
|
||||||
defaultTls?: boolean;
|
|
||||||
|
|
||||||
// Domain rules with glob pattern support
|
|
||||||
domainRules: IDomainRule[];
|
|
||||||
|
|
||||||
// Queue configuration for all email processing
|
|
||||||
queue?: {
|
|
||||||
storageType?: 'memory' | 'disk';
|
|
||||||
persistentPath?: string;
|
|
||||||
maxRetries?: number;
|
|
||||||
baseRetryDelay?: number;
|
|
||||||
maxRetryDelay?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Advanced MTA settings
|
|
||||||
mtaGlobalOptions?: IMtaOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Domain group configuration for applying consistent rules across related domains
|
|
||||||
*/
|
|
||||||
export interface IDomainGroup {
|
|
||||||
/** Unique identifier for the domain group */
|
|
||||||
id: string;
|
|
||||||
/** Human-readable name for the domain group */
|
|
||||||
name: string;
|
|
||||||
/** List of domains in this group */
|
|
||||||
domains: string[];
|
|
||||||
/** Priority for this domain group (higher takes precedence) */
|
|
||||||
priority?: number;
|
|
||||||
/** Description of this domain group */
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Domain pattern with wildcard support for matching domains
|
|
||||||
*/
|
|
||||||
export interface IDomainPattern {
|
|
||||||
/** The domain pattern, e.g. "example.com" or "*.example.com" */
|
|
||||||
pattern: string;
|
|
||||||
/** Whether this is an exact match or wildcard pattern */
|
|
||||||
isWildcard: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email routing rule for determining how to handle emails for specific domains
|
|
||||||
*/
|
|
||||||
export interface IEmailRoutingRule {
|
|
||||||
/** Unique identifier for this rule */
|
|
||||||
id: string;
|
|
||||||
/** Human-readable name for this rule */
|
|
||||||
name: string;
|
|
||||||
/** Source domain patterns to match (from address) */
|
|
||||||
sourceDomains?: IDomainPattern[];
|
|
||||||
/** Destination domain patterns to match (to address) */
|
|
||||||
destinationDomains?: IDomainPattern[];
|
|
||||||
/** Domain groups this rule applies to */
|
|
||||||
domainGroups?: string[];
|
|
||||||
/** Priority of this rule (higher takes precedence) */
|
|
||||||
priority: number;
|
|
||||||
/** Action to take when rule matches */
|
|
||||||
action: 'route' | 'block' | 'tag' | 'filter';
|
|
||||||
/** Target server for routing */
|
|
||||||
targetServer?: string;
|
|
||||||
/** Target port for routing */
|
|
||||||
targetPort?: number;
|
|
||||||
/** Whether to use TLS when routing */
|
|
||||||
useTls?: boolean;
|
|
||||||
/** Authentication details for routing */
|
|
||||||
auth?: {
|
|
||||||
/** Username for authentication */
|
|
||||||
username?: string;
|
|
||||||
/** Password for authentication */
|
|
||||||
password?: string;
|
|
||||||
/** Authentication type */
|
|
||||||
type?: 'PLAIN' | 'LOGIN' | 'OAUTH2';
|
|
||||||
};
|
|
||||||
/** Headers to add or modify when rule matches */
|
|
||||||
headers?: {
|
|
||||||
/** Header name */
|
|
||||||
name: string;
|
|
||||||
/** Header value */
|
|
||||||
value: string;
|
|
||||||
/** Whether to append to existing header or replace */
|
|
||||||
append?: boolean;
|
|
||||||
}[];
|
|
||||||
/** Whether this rule is enabled */
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for email domain-based routing
|
|
||||||
*/
|
|
||||||
export interface IEmailDomainRoutingConfig {
|
|
||||||
/** Whether domain-based routing is enabled */
|
|
||||||
enabled: boolean;
|
|
||||||
/** Routing rules list */
|
|
||||||
rules: IEmailRoutingRule[];
|
|
||||||
/** Domain groups for organization */
|
|
||||||
domainGroups?: IDomainGroup[];
|
|
||||||
/** Default target server for unmatched domains */
|
|
||||||
defaultTargetServer?: string;
|
|
||||||
/** Default target port for unmatched domains */
|
|
||||||
defaultTargetPort?: number;
|
|
||||||
/** Whether to use TLS for the default route */
|
|
||||||
defaultUseTls?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class for managing domain-based email routing
|
|
||||||
*/
|
|
||||||
export class EmailDomainRouter {
|
|
||||||
/** Configuration for domain-based routing */
|
|
||||||
private config: IEmailDomainRoutingConfig;
|
|
||||||
/** Domain groups indexed by ID */
|
|
||||||
private domainGroups: Map<string, IDomainGroup> = new Map();
|
|
||||||
/** Sorted rules cache for faster processing */
|
|
||||||
private sortedRules: IEmailRoutingRule[] = [];
|
|
||||||
/** Whether the rules need to be re-sorted */
|
|
||||||
private rulesSortNeeded = true;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new EmailDomainRouter
|
|
||||||
* @param config Configuration for domain-based routing
|
|
||||||
*/
|
|
||||||
constructor(config: IEmailDomainRoutingConfig) {
|
|
||||||
this.config = config;
|
|
||||||
this.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the domain router
|
|
||||||
*/
|
|
||||||
private initialize(): void {
|
|
||||||
// Return early if routing is not enabled
|
|
||||||
if (!this.config.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize domain groups
|
|
||||||
if (this.config.domainGroups) {
|
|
||||||
for (const group of this.config.domainGroups) {
|
|
||||||
this.domainGroups.set(group.id, group);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort rules by priority
|
|
||||||
this.sortRules();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sort rules by priority (higher first)
|
|
||||||
*/
|
|
||||||
private sortRules(): void {
|
|
||||||
if (!this.config.rules || !this.config.enabled) {
|
|
||||||
this.sortedRules = [];
|
|
||||||
this.rulesSortNeeded = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sortedRules = [...this.config.rules]
|
|
||||||
.filter(rule => rule.enabled)
|
|
||||||
.sort((a, b) => b.priority - a.priority);
|
|
||||||
|
|
||||||
this.rulesSortNeeded = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new routing rule
|
|
||||||
* @param rule The routing rule to add
|
|
||||||
*/
|
|
||||||
public addRule(rule: IEmailRoutingRule): void {
|
|
||||||
if (!this.config.rules) {
|
|
||||||
this.config.rules = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if rule already exists
|
|
||||||
const existingIndex = this.config.rules.findIndex(r => r.id === rule.id);
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
// Update existing rule
|
|
||||||
this.config.rules[existingIndex] = rule;
|
|
||||||
} else {
|
|
||||||
// Add new rule
|
|
||||||
this.config.rules.push(rule);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.rulesSortNeeded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a routing rule by ID
|
|
||||||
* @param ruleId ID of the rule to remove
|
|
||||||
* @returns Whether the rule was removed
|
|
||||||
*/
|
|
||||||
public removeRule(ruleId: string): boolean {
|
|
||||||
if (!this.config.rules) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialLength = this.config.rules.length;
|
|
||||||
this.config.rules = this.config.rules.filter(rule => rule.id !== ruleId);
|
|
||||||
|
|
||||||
if (initialLength !== this.config.rules.length) {
|
|
||||||
this.rulesSortNeeded = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a domain group
|
|
||||||
* @param group The domain group to add
|
|
||||||
*/
|
|
||||||
public addDomainGroup(group: IDomainGroup): void {
|
|
||||||
if (!this.config.domainGroups) {
|
|
||||||
this.config.domainGroups = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if group already exists
|
|
||||||
const existingIndex = this.config.domainGroups.findIndex(g => g.id === group.id);
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
// Update existing group
|
|
||||||
this.config.domainGroups[existingIndex] = group;
|
|
||||||
} else {
|
|
||||||
// Add new group
|
|
||||||
this.config.domainGroups.push(group);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update domain groups map
|
|
||||||
this.domainGroups.set(group.id, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a domain group by ID
|
|
||||||
* @param groupId ID of the group to remove
|
|
||||||
* @returns Whether the group was removed
|
|
||||||
*/
|
|
||||||
public removeDomainGroup(groupId: string): boolean {
|
|
||||||
if (!this.config.domainGroups) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialLength = this.config.domainGroups.length;
|
|
||||||
this.config.domainGroups = this.config.domainGroups.filter(group => group.id !== groupId);
|
|
||||||
|
|
||||||
if (initialLength !== this.config.domainGroups.length) {
|
|
||||||
this.domainGroups.delete(groupId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine routing for an email
|
|
||||||
* @param fromDomain The sender domain
|
|
||||||
* @param toDomain The recipient domain
|
|
||||||
* @returns Routing decision or null if no matching rule
|
|
||||||
*/
|
|
||||||
public getRoutingForEmail(fromDomain: string, toDomain: string): {
|
|
||||||
targetServer: string;
|
|
||||||
targetPort: number;
|
|
||||||
useTls: boolean;
|
|
||||||
auth?: {
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
type?: 'PLAIN' | 'LOGIN' | 'OAUTH2';
|
|
||||||
};
|
|
||||||
headers?: {
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
append?: boolean;
|
|
||||||
}[];
|
|
||||||
} | null {
|
|
||||||
// Return default routing if routing is not enabled
|
|
||||||
if (!this.config.enabled) {
|
|
||||||
return this.getDefaultRouting();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort rules if needed
|
|
||||||
if (this.rulesSortNeeded) {
|
|
||||||
this.sortRules();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize domains
|
|
||||||
fromDomain = fromDomain.toLowerCase();
|
|
||||||
toDomain = toDomain.toLowerCase();
|
|
||||||
|
|
||||||
// Check each rule in priority order
|
|
||||||
for (const rule of this.sortedRules) {
|
|
||||||
if (!rule.enabled) continue;
|
|
||||||
|
|
||||||
// Check if rule applies to this email
|
|
||||||
if (this.ruleMatchesEmail(rule, fromDomain, toDomain)) {
|
|
||||||
// Handle different actions
|
|
||||||
switch (rule.action) {
|
|
||||||
case 'route':
|
|
||||||
// Return routing information
|
|
||||||
return {
|
|
||||||
targetServer: rule.targetServer || this.config.defaultTargetServer || 'localhost',
|
|
||||||
targetPort: rule.targetPort || this.config.defaultTargetPort || 25,
|
|
||||||
useTls: rule.useTls ?? this.config.defaultUseTls ?? false,
|
|
||||||
auth: rule.auth,
|
|
||||||
headers: rule.headers
|
|
||||||
};
|
|
||||||
case 'block':
|
|
||||||
// Return null to indicate email should be blocked
|
|
||||||
return null;
|
|
||||||
case 'tag':
|
|
||||||
case 'filter':
|
|
||||||
// For tagging/filtering, we need to apply headers but continue checking rules
|
|
||||||
// This is simplified for now, in a real implementation we'd aggregate headers
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No rule matched, use default routing
|
|
||||||
return this.getDefaultRouting();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a rule matches an email
|
|
||||||
* @param rule The routing rule to check
|
|
||||||
* @param fromDomain The sender domain
|
|
||||||
* @param toDomain The recipient domain
|
|
||||||
* @returns Whether the rule matches the email
|
|
||||||
*/
|
|
||||||
private ruleMatchesEmail(rule: IEmailRoutingRule, fromDomain: string, toDomain: string): boolean {
|
|
||||||
// Check source domains
|
|
||||||
if (rule.sourceDomains && rule.sourceDomains.length > 0) {
|
|
||||||
const matchesSourceDomain = rule.sourceDomains.some(
|
|
||||||
pattern => this.domainMatchesPattern(fromDomain, pattern)
|
|
||||||
);
|
|
||||||
if (!matchesSourceDomain) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check destination domains
|
|
||||||
if (rule.destinationDomains && rule.destinationDomains.length > 0) {
|
|
||||||
const matchesDestinationDomain = rule.destinationDomains.some(
|
|
||||||
pattern => this.domainMatchesPattern(toDomain, pattern)
|
|
||||||
);
|
|
||||||
if (!matchesDestinationDomain) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check domain groups
|
|
||||||
if (rule.domainGroups && rule.domainGroups.length > 0) {
|
|
||||||
// Check if either domain is in any of the specified groups
|
|
||||||
const domainsInGroups = rule.domainGroups
|
|
||||||
.map(groupId => this.domainGroups.get(groupId))
|
|
||||||
.filter(Boolean)
|
|
||||||
.some(group =>
|
|
||||||
group.domains.includes(fromDomain) ||
|
|
||||||
group.domains.includes(toDomain)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!domainsInGroups) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we got here, all checks passed
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a domain matches a pattern
|
|
||||||
* @param domain The domain to check
|
|
||||||
* @param pattern The pattern to match against
|
|
||||||
* @returns Whether the domain matches the pattern
|
|
||||||
*/
|
|
||||||
private domainMatchesPattern(domain: string, pattern: IDomainPattern): boolean {
|
|
||||||
domain = domain.toLowerCase();
|
|
||||||
const patternStr = pattern.pattern.toLowerCase();
|
|
||||||
|
|
||||||
// Exact match
|
|
||||||
if (!pattern.isWildcard) {
|
|
||||||
return domain === patternStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wildcard match (*.example.com)
|
|
||||||
if (patternStr.startsWith('*.')) {
|
|
||||||
const suffix = patternStr.substring(2);
|
|
||||||
return domain.endsWith(suffix) && domain.length > suffix.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalid pattern
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get default routing information
|
|
||||||
* @returns Default routing or null if no default configured
|
|
||||||
*/
|
|
||||||
private getDefaultRouting(): {
|
|
||||||
targetServer: string;
|
|
||||||
targetPort: number;
|
|
||||||
useTls: boolean;
|
|
||||||
} | null {
|
|
||||||
if (!this.config.defaultTargetServer) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
targetServer: this.config.defaultTargetServer,
|
|
||||||
targetPort: this.config.defaultTargetPort || 25,
|
|
||||||
useTls: this.config.defaultUseTls || false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current configuration
|
|
||||||
* @returns Current domain routing configuration
|
|
||||||
*/
|
|
||||||
public getConfig(): IEmailDomainRoutingConfig {
|
|
||||||
return this.config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the configuration
|
|
||||||
* @param config New domain routing configuration
|
|
||||||
*/
|
|
||||||
public updateConfig(config: IEmailDomainRoutingConfig): void {
|
|
||||||
this.config = config;
|
|
||||||
this.rulesSortNeeded = true;
|
|
||||||
this.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable domain routing
|
|
||||||
*/
|
|
||||||
public enable(): void {
|
|
||||||
this.config.enabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disable domain routing
|
|
||||||
*/
|
|
||||||
public disable(): void {
|
|
||||||
this.config.enabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration options for TLS in SMTP connections
|
|
||||||
*/
|
|
||||||
export interface ISmtpTlsOptions {
|
|
||||||
/** Enable TLS for this SMTP port */
|
|
||||||
enabled: boolean;
|
|
||||||
/** Whether to use STARTTLS (upgrade plain connection) or implicit TLS */
|
|
||||||
useStartTls?: boolean;
|
|
||||||
/** Required TLS protocol version (defaults to TLSv1.2) */
|
|
||||||
minTlsVersion?: 'TLSv1.0' | 'TLSv1.1' | 'TLSv1.2' | 'TLSv1.3';
|
|
||||||
/** TLS ciphers to allow (comma-separated list) */
|
|
||||||
allowedCiphers?: string;
|
|
||||||
/** Whether to require client certificate for authentication */
|
|
||||||
requireClientCert?: boolean;
|
|
||||||
/** Whether to verify client certificate if provided */
|
|
||||||
verifyClientCert?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiting options for SMTP connections
|
|
||||||
*/
|
|
||||||
export interface ISmtpRateLimitOptions {
|
|
||||||
/** Maximum connections per minute from a single IP */
|
|
||||||
maxConnectionsPerMinute?: number;
|
|
||||||
/** Maximum concurrent connections from a single IP */
|
|
||||||
maxConcurrentConnections?: number;
|
|
||||||
/** Maximum emails per minute from a single IP */
|
|
||||||
maxEmailsPerMinute?: number;
|
|
||||||
/** Maximum recipients per email */
|
|
||||||
maxRecipientsPerEmail?: number;
|
|
||||||
/** Maximum email size in bytes */
|
|
||||||
maxEmailSize?: number;
|
|
||||||
/** Action to take when rate limit is exceeded (default: 'tempfail') */
|
|
||||||
rateLimitAction?: 'tempfail' | 'drop' | 'delay';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for a specific SMTP port
|
|
||||||
*/
|
|
||||||
export interface ISmtpPortSettings {
|
|
||||||
/** The port number to listen on */
|
|
||||||
port: number;
|
|
||||||
/** Whether this port is enabled */
|
|
||||||
enabled?: boolean;
|
|
||||||
/** Port description (e.g., "Submission Port") */
|
|
||||||
description?: string;
|
|
||||||
/** Whether to require authentication for this port */
|
|
||||||
requireAuth?: boolean;
|
|
||||||
/** TLS options for this port */
|
|
||||||
tls?: ISmtpTlsOptions;
|
|
||||||
/** Rate limiting settings for this port */
|
|
||||||
rateLimit?: ISmtpRateLimitOptions;
|
|
||||||
/** Maximum message size in bytes for this port */
|
|
||||||
maxMessageSize?: number;
|
|
||||||
/** Whether to enable SMTP extensions like PIPELINING, 8BITMIME, etc. */
|
|
||||||
smtpExtensions?: {
|
|
||||||
/** Enable PIPELINING extension */
|
|
||||||
pipelining?: boolean;
|
|
||||||
/** Enable 8BITMIME extension */
|
|
||||||
eightBitMime?: boolean;
|
|
||||||
/** Enable SIZE extension */
|
|
||||||
size?: boolean;
|
|
||||||
/** Enable ENHANCEDSTATUSCODES extension */
|
|
||||||
enhancedStatusCodes?: boolean;
|
|
||||||
/** Enable DSN extension */
|
|
||||||
dsn?: boolean;
|
|
||||||
};
|
|
||||||
/** Custom SMTP greeting banner */
|
|
||||||
banner?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration manager for SMTP ports
|
|
||||||
*/
|
|
||||||
export class SmtpPortConfig {
|
|
||||||
/** Port configurations */
|
|
||||||
private portConfigs: Map<number, ISmtpPortSettings> = new Map();
|
|
||||||
|
|
||||||
/** Default port configurations */
|
|
||||||
private static readonly DEFAULT_CONFIGS: Record<number, Partial<ISmtpPortSettings>> = {
|
|
||||||
// Port 25: Standard SMTP
|
|
||||||
25: {
|
|
||||||
description: 'Standard SMTP',
|
|
||||||
requireAuth: false,
|
|
||||||
tls: {
|
|
||||||
enabled: true,
|
|
||||||
useStartTls: true,
|
|
||||||
minTlsVersion: 'TLSv1.2'
|
|
||||||
},
|
|
||||||
rateLimit: {
|
|
||||||
maxConnectionsPerMinute: 60,
|
|
||||||
maxConcurrentConnections: 10,
|
|
||||||
maxEmailsPerMinute: 30
|
|
||||||
},
|
|
||||||
maxMessageSize: 20 * 1024 * 1024 // 20MB
|
|
||||||
},
|
|
||||||
// Port 587: Submission
|
|
||||||
587: {
|
|
||||||
description: 'Submission Port',
|
|
||||||
requireAuth: true,
|
|
||||||
tls: {
|
|
||||||
enabled: true,
|
|
||||||
useStartTls: true,
|
|
||||||
minTlsVersion: 'TLSv1.2'
|
|
||||||
},
|
|
||||||
rateLimit: {
|
|
||||||
maxConnectionsPerMinute: 100,
|
|
||||||
maxConcurrentConnections: 20,
|
|
||||||
maxEmailsPerMinute: 60
|
|
||||||
},
|
|
||||||
maxMessageSize: 50 * 1024 * 1024 // 50MB
|
|
||||||
},
|
|
||||||
// Port 465: SMTPS (Legacy Implicit TLS)
|
|
||||||
465: {
|
|
||||||
description: 'SMTPS (Implicit TLS)',
|
|
||||||
requireAuth: true,
|
|
||||||
tls: {
|
|
||||||
enabled: true,
|
|
||||||
useStartTls: false,
|
|
||||||
minTlsVersion: 'TLSv1.2'
|
|
||||||
},
|
|
||||||
rateLimit: {
|
|
||||||
maxConnectionsPerMinute: 100,
|
|
||||||
maxConcurrentConnections: 20,
|
|
||||||
maxEmailsPerMinute: 60
|
|
||||||
},
|
|
||||||
maxMessageSize: 50 * 1024 * 1024 // 50MB
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new SmtpPortConfig
|
|
||||||
* @param initialConfigs Optional initial port configurations
|
|
||||||
*/
|
|
||||||
constructor(initialConfigs?: ISmtpPortSettings[]) {
|
|
||||||
// Initialize with default configurations for standard SMTP ports
|
|
||||||
this.initializeDefaults();
|
|
||||||
|
|
||||||
// Apply custom configurations if provided
|
|
||||||
if (initialConfigs) {
|
|
||||||
for (const config of initialConfigs) {
|
|
||||||
this.setPortConfig(config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize port configurations with defaults
|
|
||||||
*/
|
|
||||||
private initializeDefaults(): void {
|
|
||||||
// Set up default configurations for standard SMTP ports: 25, 587, 465
|
|
||||||
Object.entries(SmtpPortConfig.DEFAULT_CONFIGS).forEach(([portStr, defaults]) => {
|
|
||||||
const port = parseInt(portStr, 10);
|
|
||||||
this.portConfigs.set(port, {
|
|
||||||
port,
|
|
||||||
enabled: true,
|
|
||||||
...defaults
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get configuration for a specific port
|
|
||||||
* @param port Port number
|
|
||||||
* @returns Port configuration or null if not found
|
|
||||||
*/
|
|
||||||
public getPortConfig(port: number): ISmtpPortSettings | null {
|
|
||||||
return this.portConfigs.get(port) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all configured ports
|
|
||||||
* @returns Array of port configurations
|
|
||||||
*/
|
|
||||||
public getAllPortConfigs(): ISmtpPortSettings[] {
|
|
||||||
return Array.from(this.portConfigs.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get only enabled port configurations
|
|
||||||
* @returns Array of enabled port configurations
|
|
||||||
*/
|
|
||||||
public getEnabledPortConfigs(): ISmtpPortSettings[] {
|
|
||||||
return this.getAllPortConfigs().filter(config => config.enabled !== false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set configuration for a specific port
|
|
||||||
* @param config Port configuration
|
|
||||||
*/
|
|
||||||
public setPortConfig(config: ISmtpPortSettings): void {
|
|
||||||
// Get existing config if any
|
|
||||||
const existingConfig = this.portConfigs.get(config.port) || { port: config.port };
|
|
||||||
|
|
||||||
// Merge with new configuration
|
|
||||||
this.portConfigs.set(config.port, {
|
|
||||||
...existingConfig,
|
|
||||||
...config
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove configuration for a specific port
|
|
||||||
* @param port Port number
|
|
||||||
* @returns Whether the configuration was removed
|
|
||||||
*/
|
|
||||||
public removePortConfig(port: number): boolean {
|
|
||||||
return this.portConfigs.delete(port);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disable a specific port
|
|
||||||
* @param port Port number
|
|
||||||
* @returns Whether the port was disabled
|
|
||||||
*/
|
|
||||||
public disablePort(port: number): boolean {
|
|
||||||
const config = this.portConfigs.get(port);
|
|
||||||
if (config) {
|
|
||||||
config.enabled = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable a specific port
|
|
||||||
* @param port Port number
|
|
||||||
* @returns Whether the port was enabled
|
|
||||||
*/
|
|
||||||
public enablePort(port: number): boolean {
|
|
||||||
const config = this.portConfigs.get(port);
|
|
||||||
if (config) {
|
|
||||||
config.enabled = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply port configurations to SmartProxy settings
|
|
||||||
* @param smartProxy SmartProxy instance
|
|
||||||
*/
|
|
||||||
public applyToSmartProxy(smartProxy: plugins.smartproxy.SmartProxy): void {
|
|
||||||
if (!smartProxy) return;
|
|
||||||
|
|
||||||
const enabledPorts = this.getEnabledPortConfigs();
|
|
||||||
const settings = smartProxy.settings;
|
|
||||||
|
|
||||||
// Initialize globalPortRanges if needed
|
|
||||||
if (!settings.globalPortRanges) {
|
|
||||||
settings.globalPortRanges = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add configured ports to globalPortRanges
|
|
||||||
for (const portConfig of enabledPorts) {
|
|
||||||
// Add port to global port ranges if not already present
|
|
||||||
if (!settings.globalPortRanges.some((r) => r.from <= portConfig.port && portConfig.port <= r.to)) {
|
|
||||||
settings.globalPortRanges.push({ from: portConfig.port, to: portConfig.port });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply TLS settings at SmartProxy level
|
|
||||||
if (portConfig.port === 465 && portConfig.tls?.enabled) {
|
|
||||||
// For implicit TLS on port 465
|
|
||||||
settings.sniEnabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group ports by TLS configuration to log them
|
|
||||||
const starttlsPorts = enabledPorts
|
|
||||||
.filter(p => p.tls?.enabled && p.tls?.useStartTls)
|
|
||||||
.map(p => p.port);
|
|
||||||
|
|
||||||
const implicitTlsPorts = enabledPorts
|
|
||||||
.filter(p => p.tls?.enabled && !p.tls?.useStartTls)
|
|
||||||
.map(p => p.port);
|
|
||||||
|
|
||||||
const nonTlsPorts = enabledPorts
|
|
||||||
.filter(p => !p.tls?.enabled)
|
|
||||||
.map(p => p.port);
|
|
||||||
|
|
||||||
if (starttlsPorts.length > 0) {
|
|
||||||
console.log(`Configured STARTTLS SMTP ports: ${starttlsPorts.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (implicitTlsPorts.length > 0) {
|
|
||||||
console.log(`Configured Implicit TLS SMTP ports: ${implicitTlsPorts.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nonTlsPorts.length > 0) {
|
|
||||||
console.log(`Configured Plain SMTP ports: ${nonTlsPorts.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup connection listeners for different port types
|
|
||||||
smartProxy.on('connection', (connection) => {
|
|
||||||
const port = connection.localPort;
|
|
||||||
|
|
||||||
// Check which type of port this is
|
|
||||||
if (implicitTlsPorts.includes(port)) {
|
|
||||||
console.log(`Implicit TLS SMTP connection on port ${port} from ${connection.remoteIP}`);
|
|
||||||
} else if (starttlsPorts.includes(port)) {
|
|
||||||
console.log(`STARTTLS SMTP connection on port ${port} from ${connection.remoteIP}`);
|
|
||||||
} else if (nonTlsPorts.includes(port)) {
|
|
||||||
console.log(`Plain SMTP connection on port ${port} from ${connection.remoteIP}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Applied SMTP port configurations to SmartProxy: ${enabledPorts.map(p => p.port).join(', ')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// Core DcRouter components
|
|
||||||
export * from './classes.dcrouter.js';
|
|
||||||
export * from './classes.smtp.portconfig.js';
|
|
||||||
export * from './classes.email.domainrouter.js';
|
|
||||||
|
|
||||||
// Unified Email Configuration
|
|
||||||
export * from './classes.email.config.js';
|
|
||||||
export * from './classes.domain.router.js';
|
|
||||||
@@ -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<IIPWarmupConfig> = {
|
|
||||||
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<IIPWarmupConfig>;
|
|
||||||
private warmupStatuses: Map<string, IIPWarmupStatus> = new Map();
|
|
||||||
private dailySendCounts: Map<string, number> = new Map();
|
|
||||||
private hourlySendCounts: Map<string, number[]> = new Map();
|
|
||||||
private isInitialized: boolean = false;
|
|
||||||
private allocationPolicies: Map<string, IIPAllocationPolicy> = 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<string, IIPWarmupStatus> {
|
|
||||||
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.smartfile.fs.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.smartfile.fs.ensureDirSync(warmupDir);
|
|
||||||
|
|
||||||
const statusFile = plugins.path.join(warmupDir, 'ip_warmup_status.json');
|
|
||||||
const statuses = Array.from(this.warmupStatuses.values());
|
|
||||||
|
|
||||||
plugins.smartfile.memory.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<string, string> = 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { EmailService } from './classes.emailservice.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
|
|
||||||
export class ApiManager {
|
|
||||||
public emailRef: EmailService;
|
|
||||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(emailRefArg: EmailService) {
|
|
||||||
this.emailRef = emailRefArg;
|
|
||||||
this.emailRef.typedrouter.addTypedRouter(this.typedRouter);
|
|
||||||
|
|
||||||
// Register API endpoints
|
|
||||||
this.registerApiEndpoints();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register API endpoints for email functionality
|
|
||||||
*/
|
|
||||||
private registerApiEndpoints() {
|
|
||||||
// Register the SendEmail endpoint
|
|
||||||
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.platformservice.mta.IReq_SendEmail>(
|
|
||||||
new plugins.typedrequest.TypedHandler('sendEmail', async (requestData) => {
|
|
||||||
const mailToSend = new plugins.smartmail.Smartmail({
|
|
||||||
body: requestData.body,
|
|
||||||
from: requestData.from,
|
|
||||||
subject: requestData.title,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (requestData.attachments) {
|
|
||||||
for (const attachment of requestData.attachments) {
|
|
||||||
mailToSend.addAttachment(
|
|
||||||
await plugins.smartfile.SmartFile.fromString(
|
|
||||||
attachment.name,
|
|
||||||
attachment.binaryAttachmentString,
|
|
||||||
'binary'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send email through the service which will route to the appropriate connector
|
|
||||||
const emailId = await this.emailRef.sendEmail(mailToSend, requestData.to, {});
|
|
||||||
|
|
||||||
logger.log(
|
|
||||||
'info',
|
|
||||||
`sent an email to ${requestData.to} with subject '${mailToSend.getSubject()}'`,
|
|
||||||
{
|
|
||||||
eventType: 'sentEmail',
|
|
||||||
email: {
|
|
||||||
to: requestData.to,
|
|
||||||
subject: mailToSend.getSubject(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
responseId: emailId,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add endpoint to check email status
|
|
||||||
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.platformservice.mta.IReq_CheckEmailStatus>(
|
|
||||||
new plugins.typedrequest.TypedHandler('checkEmailStatus', async (requestData) => {
|
|
||||||
// If MTA is enabled, use it to check status
|
|
||||||
if (this.emailRef.mtaConnector) {
|
|
||||||
const status = await this.emailRef.mtaConnector.checkEmailStatus(requestData.emailId);
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Mailgun, we don't have a status check implementation currently
|
|
||||||
return {
|
|
||||||
status: 'unknown',
|
|
||||||
details: { message: 'Status tracking not available for current provider' }
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add statistics endpoint
|
|
||||||
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.platformservice.mta.IReq_GetEMailStats>(
|
|
||||||
new plugins.typedrequest.TypedHandler('getEmailStats', async () => {
|
|
||||||
return this.emailRef.getStats();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,902 +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';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<string, string>;
|
|
||||||
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<string, {
|
|
||||||
lastBounce: number;
|
|
||||||
count: number;
|
|
||||||
type: BounceType;
|
|
||||||
category: BounceCategory;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
// Suppression list for addresses that should not receive emails
|
|
||||||
private suppressionList: Map<string, {
|
|
||||||
reason: string;
|
|
||||||
timestamp: number;
|
|
||||||
expiresAt?: number; // undefined means permanent
|
|
||||||
}> = new Map();
|
|
||||||
|
|
||||||
constructor(options?: {
|
|
||||||
retryStrategy?: Partial<RetryStrategy>;
|
|
||||||
maxCacheSize?: number;
|
|
||||||
cacheTTL?: number;
|
|
||||||
}) {
|
|
||||||
// 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<string, any>({
|
|
||||||
max: options?.maxCacheSize || 10000,
|
|
||||||
ttl: options?.cacheTTL || 30 * 24 * 60 * 60 * 1000, // 30 days default
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load suppression list from storage
|
|
||||||
this.loadSuppressionList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a bounce notification
|
|
||||||
* @param bounceData Bounce data to process
|
|
||||||
* @returns Processed bounce record
|
|
||||||
*/
|
|
||||||
public async processBounce(bounceData: Partial<BounceRecord>): Promise<BounceRecord> {
|
|
||||||
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<string, string>;
|
|
||||||
} = {}
|
|
||||||
): Promise<BounceRecord> {
|
|
||||||
// Create bounce data from SMTP failure
|
|
||||||
const bounceData: Partial<BounceRecord> = {
|
|
||||||
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: plugins.smartmail.Smartmail<any>): Promise<BounceRecord | null> {
|
|
||||||
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*<?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>?/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.options.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<BounceRecord> = {
|
|
||||||
recipient,
|
|
||||||
sender: bounceEmail.options.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<void> {
|
|
||||||
// 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
|
|
||||||
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<void> {
|
|
||||||
// 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
|
|
||||||
});
|
|
||||||
|
|
||||||
this.saveSuppressionList();
|
|
||||||
|
|
||||||
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) {
|
|
||||||
this.saveSuppressionList();
|
|
||||||
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);
|
|
||||||
this.saveSuppressionList();
|
|
||||||
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);
|
|
||||||
this.saveSuppressionList();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return suppression;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save suppression list to disk
|
|
||||||
*/
|
|
||||||
private saveSuppressionList(): void {
|
|
||||||
try {
|
|
||||||
const suppressionData = JSON.stringify(Array.from(this.suppressionList.entries()));
|
|
||||||
plugins.smartfile.memory.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 loadSuppressionList(): void {
|
|
||||||
try {
|
|
||||||
const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json');
|
|
||||||
|
|
||||||
if (plugins.fs.existsSync(suppressionPath)) {
|
|
||||||
const data = plugins.fs.readFileSync(suppressionPath, 'utf8');
|
|
||||||
const entries = JSON.parse(data);
|
|
||||||
|
|
||||||
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) {
|
|
||||||
logger.log('info', `Cleaned ${expiredCount} expired entries from suppression list`);
|
|
||||||
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 saveBounceRecord(bounce: BounceRecord): void {
|
|
||||||
try {
|
|
||||||
const bounceData = JSON.stringify(bounce);
|
|
||||||
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.smartfile.fs.ensureDirSync(bounceDir);
|
|
||||||
|
|
||||||
plugins.smartfile.memory.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,536 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { EmailService } from './classes.emailservice.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
|
|
||||||
// Import MTA classes
|
|
||||||
import {
|
|
||||||
MtaService,
|
|
||||||
Email as MtaEmail,
|
|
||||||
type IEmailOptions,
|
|
||||||
DeliveryStatus,
|
|
||||||
type IAttachment
|
|
||||||
} from '../mta/index.js';
|
|
||||||
|
|
||||||
export class MtaConnector {
|
|
||||||
public emailRef: EmailService;
|
|
||||||
private mtaService: MtaService;
|
|
||||||
|
|
||||||
constructor(emailRefArg: EmailService, mtaService?: MtaService) {
|
|
||||||
this.emailRef = emailRefArg;
|
|
||||||
this.mtaService = mtaService || this.emailRef.mtaService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email using the MTA service
|
|
||||||
* @param smartmail The email to send
|
|
||||||
* @param toAddresses Recipients (comma-separated or array)
|
|
||||||
* @param options Additional options
|
|
||||||
*/
|
|
||||||
public async sendEmail(
|
|
||||||
smartmail: plugins.smartmail.Smartmail<any>,
|
|
||||||
toAddresses: string | string[],
|
|
||||||
options: any = {}
|
|
||||||
): Promise<string> {
|
|
||||||
// Check if recipients are on the suppression list
|
|
||||||
const recipients = Array.isArray(toAddresses)
|
|
||||||
? toAddresses
|
|
||||||
: toAddresses.split(',').map(addr => addr.trim());
|
|
||||||
|
|
||||||
// Filter out suppressed recipients
|
|
||||||
const validRecipients = [];
|
|
||||||
const suppressedRecipients = [];
|
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
|
||||||
if (this.emailRef.bounceManager.isEmailSuppressed(recipient)) {
|
|
||||||
suppressedRecipients.push(recipient);
|
|
||||||
} else {
|
|
||||||
validRecipients.push(recipient);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log suppressed recipients
|
|
||||||
if (suppressedRecipients.length > 0) {
|
|
||||||
logger.log('warn', `Skipping ${suppressedRecipients.length} suppressed recipients`, {
|
|
||||||
suppressedRecipients
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// If all recipients are suppressed, throw error
|
|
||||||
if (validRecipients.length === 0) {
|
|
||||||
throw new Error('All recipients are on the suppression list');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue with valid recipients
|
|
||||||
try {
|
|
||||||
// Use filtered recipients - already an array, no need for toArray
|
|
||||||
|
|
||||||
// Add recipients to smartmail if they're not already added
|
|
||||||
if (!smartmail.options.to || smartmail.options.to.length === 0) {
|
|
||||||
for (const recipient of validRecipients) {
|
|
||||||
smartmail.addRecipient(recipient);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle options
|
|
||||||
const emailOptions: Record<string, any> = { ...options };
|
|
||||||
|
|
||||||
// Check if we should use MIME format
|
|
||||||
const useMimeFormat = options.useMimeFormat ?? true;
|
|
||||||
|
|
||||||
if (useMimeFormat) {
|
|
||||||
// Use smartmail's MIME conversion for improved handling
|
|
||||||
try {
|
|
||||||
// Convert to MIME format
|
|
||||||
const mimeEmail = await smartmail.toMimeFormat(smartmail.options.creationObjectRef);
|
|
||||||
|
|
||||||
// Parse the MIME email to create an MTA Email
|
|
||||||
return this.sendMimeEmail(mimeEmail, validRecipients);
|
|
||||||
} catch (mimeError) {
|
|
||||||
logger.log('warn', `Failed to use MIME format, falling back to direct conversion: ${mimeError.message}`);
|
|
||||||
// Fall back to direct conversion
|
|
||||||
return this.sendDirectEmail(smartmail, validRecipients);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use direct conversion
|
|
||||||
return this.sendDirectEmail(smartmail, validRecipients);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to send email via MTA: ${error.message}`, {
|
|
||||||
eventType: 'emailError',
|
|
||||||
provider: 'mta',
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if this is a bounce-related error
|
|
||||||
if (error.message.includes('550') || // Rejected
|
|
||||||
error.message.includes('551') || // User not local
|
|
||||||
error.message.includes('552') || // Mailbox full
|
|
||||||
error.message.includes('553') || // Bad mailbox name
|
|
||||||
error.message.includes('554') || // Transaction failed
|
|
||||||
error.message.includes('does not exist') ||
|
|
||||||
error.message.includes('unknown user') ||
|
|
||||||
error.message.includes('invalid recipient')) {
|
|
||||||
|
|
||||||
// Process as a bounce
|
|
||||||
for (const recipient of validRecipients) {
|
|
||||||
await this.emailRef.bounceManager.processSmtpFailure(
|
|
||||||
recipient,
|
|
||||||
error.message,
|
|
||||||
{
|
|
||||||
sender: smartmail.options.from,
|
|
||||||
statusCode: error.message.match(/\b([45]\d{2})\b/) ? error.message.match(/\b([45]\d{2})\b/)[1] : undefined
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a MIME-formatted email
|
|
||||||
* @param mimeEmail The MIME-formatted email content
|
|
||||||
* @param recipients The email recipients
|
|
||||||
*/
|
|
||||||
private async sendMimeEmail(mimeEmail: string, recipients: string[]): Promise<string> {
|
|
||||||
try {
|
|
||||||
// Parse the MIME email
|
|
||||||
const parsedEmail = await plugins.mailparser.simpleParser(mimeEmail);
|
|
||||||
|
|
||||||
// Extract necessary information for MTA Email
|
|
||||||
const mtaEmail = new MtaEmail({
|
|
||||||
from: parsedEmail.from?.text || '',
|
|
||||||
to: recipients,
|
|
||||||
subject: parsedEmail.subject || '',
|
|
||||||
text: parsedEmail.text || '',
|
|
||||||
html: parsedEmail.html || undefined,
|
|
||||||
attachments: parsedEmail.attachments?.map(attachment => ({
|
|
||||||
filename: attachment.filename || 'attachment',
|
|
||||||
content: attachment.content,
|
|
||||||
contentType: attachment.contentType || 'application/octet-stream',
|
|
||||||
contentId: attachment.contentId
|
|
||||||
})) || [],
|
|
||||||
headers: Object.fromEntries([...parsedEmail.headers].map(([key, value]) => [key, String(value)]))
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send using MTA
|
|
||||||
const emailId = await this.mtaService.send(mtaEmail);
|
|
||||||
|
|
||||||
logger.log('info', `MIME email sent via MTA to ${recipients.join(', ')}`, {
|
|
||||||
eventType: 'sentEmail',
|
|
||||||
provider: 'mta',
|
|
||||||
emailId,
|
|
||||||
to: recipients
|
|
||||||
});
|
|
||||||
|
|
||||||
return emailId;
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to send MIME email: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email using direct conversion (fallback method)
|
|
||||||
* @param smartmail The Smartmail instance
|
|
||||||
* @param recipients The email recipients
|
|
||||||
*/
|
|
||||||
private async sendDirectEmail(
|
|
||||||
smartmail: plugins.smartmail.Smartmail<any>,
|
|
||||||
recipients: string[]
|
|
||||||
): Promise<string> {
|
|
||||||
// Map SmartMail attachments to MTA attachments with improved content type handling
|
|
||||||
const attachments: IAttachment[] = smartmail.attachments.map(attachment => {
|
|
||||||
// Try to determine content type from file extension if not explicitly set
|
|
||||||
let contentType = (attachment as any)?.contentType;
|
|
||||||
|
|
||||||
if (!contentType) {
|
|
||||||
const extension = attachment.parsedPath.ext.toLowerCase();
|
|
||||||
contentType = this.getContentTypeFromExtension(extension);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
filename: attachment.parsedPath.base,
|
|
||||||
content: Buffer.from(attachment.contentBuffer),
|
|
||||||
contentType: contentType || 'application/octet-stream',
|
|
||||||
// Add content ID for inline images if available
|
|
||||||
contentId: (attachment as any)?.contentId
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create MTA Email
|
|
||||||
const mtaEmail = new MtaEmail({
|
|
||||||
from: smartmail.options.from,
|
|
||||||
to: recipients,
|
|
||||||
subject: smartmail.getSubject(),
|
|
||||||
text: smartmail.getBody(false), // Plain text version
|
|
||||||
html: smartmail.getBody(true), // HTML version
|
|
||||||
attachments
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prepare arrays for CC and BCC recipients
|
|
||||||
let ccRecipients: string[] = [];
|
|
||||||
let bccRecipients: string[] = [];
|
|
||||||
|
|
||||||
// Add CC recipients if present
|
|
||||||
if (smartmail.options.cc?.length > 0) {
|
|
||||||
// Handle CC recipients - smartmail options may contain email objects
|
|
||||||
ccRecipients = smartmail.options.cc.map(r => {
|
|
||||||
if (typeof r === 'string') return r;
|
|
||||||
return typeof (r as any).address === 'string' ? (r as any).address :
|
|
||||||
typeof (r as any).email === 'string' ? (r as any).email : '';
|
|
||||||
});
|
|
||||||
mtaEmail.cc = ccRecipients;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add BCC recipients if present
|
|
||||||
if (smartmail.options.bcc?.length > 0) {
|
|
||||||
// Handle BCC recipients - smartmail options may contain email objects
|
|
||||||
bccRecipients = smartmail.options.bcc.map(r => {
|
|
||||||
if (typeof r === 'string') return r;
|
|
||||||
return typeof (r as any).address === 'string' ? (r as any).address :
|
|
||||||
typeof (r as any).email === 'string' ? (r as any).email : '';
|
|
||||||
});
|
|
||||||
mtaEmail.bcc = bccRecipients;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send using MTA
|
|
||||||
const emailId = await this.mtaService.send(mtaEmail);
|
|
||||||
|
|
||||||
logger.log('info', `Email sent via MTA to ${recipients.join(', ')}`, {
|
|
||||||
eventType: 'sentEmail',
|
|
||||||
provider: 'mta',
|
|
||||||
emailId,
|
|
||||||
to: recipients
|
|
||||||
});
|
|
||||||
|
|
||||||
return emailId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content type from file extension
|
|
||||||
* @param extension The file extension (with or without dot)
|
|
||||||
* @returns The content type or undefined if unknown
|
|
||||||
*/
|
|
||||||
private getContentTypeFromExtension(extension: string): string | undefined {
|
|
||||||
// Remove dot if present
|
|
||||||
const ext = extension.startsWith('.') ? extension.substring(1) : extension;
|
|
||||||
|
|
||||||
// Common content types
|
|
||||||
const contentTypes: Record<string, string> = {
|
|
||||||
'pdf': 'application/pdf',
|
|
||||||
'jpg': 'image/jpeg',
|
|
||||||
'jpeg': 'image/jpeg',
|
|
||||||
'png': 'image/png',
|
|
||||||
'gif': 'image/gif',
|
|
||||||
'svg': 'image/svg+xml',
|
|
||||||
'webp': 'image/webp',
|
|
||||||
'txt': 'text/plain',
|
|
||||||
'html': 'text/html',
|
|
||||||
'csv': 'text/csv',
|
|
||||||
'json': 'application/json',
|
|
||||||
'xml': 'application/xml',
|
|
||||||
'zip': 'application/zip',
|
|
||||||
'doc': 'application/msword',
|
|
||||||
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
'xls': 'application/vnd.ms-excel',
|
|
||||||
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
'ppt': 'application/vnd.ms-powerpoint',
|
|
||||||
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
||||||
};
|
|
||||||
|
|
||||||
return contentTypes[ext.toLowerCase()];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve and process an incoming email
|
|
||||||
* For MTA, this would handle an email already received by the SMTP server
|
|
||||||
* @param emailData The raw email data or identifier
|
|
||||||
* @param options Additional processing options
|
|
||||||
*/
|
|
||||||
public async receiveEmail(
|
|
||||||
emailData: string,
|
|
||||||
options: {
|
|
||||||
preserveHeaders?: boolean;
|
|
||||||
includeRawData?: boolean;
|
|
||||||
validateSender?: boolean;
|
|
||||||
} = {}
|
|
||||||
): Promise<plugins.smartmail.Smartmail<any>> {
|
|
||||||
try {
|
|
||||||
// In a real implementation, this would retrieve an email from the MTA storage
|
|
||||||
// For now, we can use a simplified approach:
|
|
||||||
|
|
||||||
// Parse the email (assuming emailData is a raw email or a file path)
|
|
||||||
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
|
|
||||||
|
|
||||||
// Extract sender information
|
|
||||||
const sender = parsedEmail.from?.text || '';
|
|
||||||
let senderName = '';
|
|
||||||
let senderEmail = sender;
|
|
||||||
|
|
||||||
// Try to extract name and email from "Name <email>" format
|
|
||||||
const senderMatch = sender.match(/(.*?)\s*<([^>]+)>/);
|
|
||||||
if (senderMatch) {
|
|
||||||
senderName = senderMatch[1].trim();
|
|
||||||
senderEmail = senderMatch[2].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract recipients
|
|
||||||
const recipients = [];
|
|
||||||
if (parsedEmail.to) {
|
|
||||||
// Extract recipients safely
|
|
||||||
try {
|
|
||||||
// Handle AddressObject or AddressObject[]
|
|
||||||
if (parsedEmail.to && typeof parsedEmail.to === 'object' && 'value' in parsedEmail.to) {
|
|
||||||
const addressList = Array.isArray(parsedEmail.to.value)
|
|
||||||
? parsedEmail.to.value
|
|
||||||
: [parsedEmail.to.value];
|
|
||||||
|
|
||||||
for (const addr of addressList) {
|
|
||||||
if (addr && typeof addr === 'object' && 'address' in addr) {
|
|
||||||
recipients.push({
|
|
||||||
name: addr.name || '',
|
|
||||||
email: addr.address || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// If parsing fails, try to extract as string
|
|
||||||
let toStr = '';
|
|
||||||
if (parsedEmail.to && typeof parsedEmail.to === 'object' && 'text' in parsedEmail.to) {
|
|
||||||
toStr = String(parsedEmail.to.text || '');
|
|
||||||
}
|
|
||||||
if (toStr) {
|
|
||||||
recipients.push({
|
|
||||||
name: '',
|
|
||||||
email: toStr
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a more comprehensive creation object reference
|
|
||||||
const creationObjectRef: Record<string, any> = {
|
|
||||||
sender: {
|
|
||||||
name: senderName,
|
|
||||||
email: senderEmail
|
|
||||||
},
|
|
||||||
recipients: recipients,
|
|
||||||
subject: parsedEmail.subject || '',
|
|
||||||
date: parsedEmail.date || new Date(),
|
|
||||||
messageId: parsedEmail.messageId || '',
|
|
||||||
inReplyTo: parsedEmail.inReplyTo || null,
|
|
||||||
references: parsedEmail.references || []
|
|
||||||
};
|
|
||||||
|
|
||||||
// Include headers if requested
|
|
||||||
if (options.preserveHeaders) {
|
|
||||||
creationObjectRef.headers = parsedEmail.headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include raw data if requested
|
|
||||||
if (options.includeRawData) {
|
|
||||||
creationObjectRef.rawData = emailData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a Smartmail from the parsed email
|
|
||||||
const smartmail = new plugins.smartmail.Smartmail({
|
|
||||||
from: senderEmail,
|
|
||||||
subject: parsedEmail.subject || '',
|
|
||||||
body: parsedEmail.html || parsedEmail.text || '',
|
|
||||||
creationObjectRef
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add recipients
|
|
||||||
if (recipients.length > 0) {
|
|
||||||
for (const recipient of recipients) {
|
|
||||||
smartmail.addRecipient(recipient.email);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add CC recipients if present
|
|
||||||
if (parsedEmail.cc) {
|
|
||||||
try {
|
|
||||||
// Extract CC recipients safely
|
|
||||||
if (parsedEmail.cc && typeof parsedEmail.cc === 'object' && 'value' in parsedEmail.cc) {
|
|
||||||
const ccList = Array.isArray(parsedEmail.cc.value)
|
|
||||||
? parsedEmail.cc.value
|
|
||||||
: [parsedEmail.cc.value];
|
|
||||||
|
|
||||||
for (const addr of ccList) {
|
|
||||||
if (addr && typeof addr === 'object' && 'address' in addr) {
|
|
||||||
smartmail.addRecipient(addr.address, 'cc');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// If parsing fails, try to extract as string
|
|
||||||
let ccStr = '';
|
|
||||||
if (parsedEmail.cc && typeof parsedEmail.cc === 'object' && 'text' in parsedEmail.cc) {
|
|
||||||
ccStr = String(parsedEmail.cc.text || '');
|
|
||||||
}
|
|
||||||
if (ccStr) {
|
|
||||||
smartmail.addRecipient(ccStr, 'cc');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add BCC recipients if present (usually not in received emails, but just in case)
|
|
||||||
if (parsedEmail.bcc) {
|
|
||||||
try {
|
|
||||||
// Extract BCC recipients safely
|
|
||||||
if (parsedEmail.bcc && typeof parsedEmail.bcc === 'object' && 'value' in parsedEmail.bcc) {
|
|
||||||
const bccList = Array.isArray(parsedEmail.bcc.value)
|
|
||||||
? parsedEmail.bcc.value
|
|
||||||
: [parsedEmail.bcc.value];
|
|
||||||
|
|
||||||
for (const addr of bccList) {
|
|
||||||
if (addr && typeof addr === 'object' && 'address' in addr) {
|
|
||||||
smartmail.addRecipient(addr.address, 'bcc');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// If parsing fails, try to extract as string
|
|
||||||
let bccStr = '';
|
|
||||||
if (parsedEmail.bcc && typeof parsedEmail.bcc === 'object' && 'text' in parsedEmail.bcc) {
|
|
||||||
bccStr = String(parsedEmail.bcc.text || '');
|
|
||||||
}
|
|
||||||
if (bccStr) {
|
|
||||||
smartmail.addRecipient(bccStr, 'bcc');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add attachments if present
|
|
||||||
if (parsedEmail.attachments && parsedEmail.attachments.length > 0) {
|
|
||||||
for (const attachment of parsedEmail.attachments) {
|
|
||||||
// Create smartfile with proper constructor options
|
|
||||||
const file = new plugins.smartfile.SmartFile({
|
|
||||||
path: attachment.filename || 'attachment',
|
|
||||||
contentBuffer: attachment.content,
|
|
||||||
base: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set content type and content ID for proper MIME handling
|
|
||||||
if (attachment.contentType) {
|
|
||||||
(file as any).contentType = attachment.contentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attachment.contentId) {
|
|
||||||
(file as any).contentId = attachment.contentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
smartmail.addAttachment(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate sender if requested
|
|
||||||
if (options.validateSender && this.emailRef.emailValidator) {
|
|
||||||
try {
|
|
||||||
const validationResult = await this.emailRef.emailValidator.validate(senderEmail, {
|
|
||||||
checkSyntaxOnly: true // Use syntax-only for performance
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add validation info to the creation object
|
|
||||||
creationObjectRef.senderValidation = validationResult;
|
|
||||||
} catch (validationError) {
|
|
||||||
logger.log('warn', `Sender validation error: ${validationError.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return smartmail;
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to receive email via MTA: ${error.message}`, {
|
|
||||||
eventType: 'emailError',
|
|
||||||
provider: 'mta',
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check the status of a sent email
|
|
||||||
* @param emailId The email ID to check
|
|
||||||
*/
|
|
||||||
public async checkEmailStatus(emailId: string): Promise<{
|
|
||||||
status: string;
|
|
||||||
details?: any;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const status = this.mtaService.getEmailStatus(emailId);
|
|
||||||
|
|
||||||
if (!status) {
|
|
||||||
return {
|
|
||||||
status: 'unknown',
|
|
||||||
details: { message: 'Email not found' }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: status.status,
|
|
||||||
details: {
|
|
||||||
attempts: status.attempts,
|
|
||||||
lastAttempt: status.lastAttempt,
|
|
||||||
nextAttempt: status.nextAttempt,
|
|
||||||
error: status.error?.message
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to check email status: ${error.message}`, {
|
|
||||||
eventType: 'emailError',
|
|
||||||
provider: 'mta',
|
|
||||||
emailId,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'error',
|
|
||||||
details: { message: error.message }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import { MtaConnector } from './classes.connector.mta.js';
|
|
||||||
import { RuleManager } from './classes.rulemanager.js';
|
|
||||||
import { ApiManager } from './classes.apimanager.js';
|
|
||||||
import { TemplateManager } from './classes.templatemanager.js';
|
|
||||||
import { EmailValidator } from './classes.emailvalidator.js';
|
|
||||||
import { BounceManager } from './classes.bouncemanager.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
import type { SzPlatformService } from '../platformservice.js';
|
|
||||||
|
|
||||||
// Import MTA service
|
|
||||||
import { MtaService, type IMtaConfig } from '../mta/index.js';
|
|
||||||
|
|
||||||
export interface IEmailConstructorOptions {
|
|
||||||
useMta?: boolean;
|
|
||||||
mtaConfig?: IMtaConfig;
|
|
||||||
templateConfig?: {
|
|
||||||
from?: string;
|
|
||||||
replyTo?: string;
|
|
||||||
footerHtml?: string;
|
|
||||||
footerText?: string;
|
|
||||||
};
|
|
||||||
loadTemplatesFromDir?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email service with support for both Mailgun and local MTA
|
|
||||||
*/
|
|
||||||
export class EmailService {
|
|
||||||
public platformServiceRef: SzPlatformService;
|
|
||||||
|
|
||||||
// typedrouter
|
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
// connectors
|
|
||||||
public mtaConnector: MtaConnector;
|
|
||||||
public qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
|
||||||
|
|
||||||
// MTA service
|
|
||||||
public mtaService: MtaService;
|
|
||||||
|
|
||||||
// services
|
|
||||||
public apiManager: ApiManager;
|
|
||||||
public ruleManager: RuleManager;
|
|
||||||
public templateManager: TemplateManager;
|
|
||||||
public emailValidator: EmailValidator;
|
|
||||||
public bounceManager: BounceManager;
|
|
||||||
|
|
||||||
// configuration
|
|
||||||
private config: IEmailConstructorOptions;
|
|
||||||
|
|
||||||
constructor(platformServiceRefArg: SzPlatformService, options: IEmailConstructorOptions = {}) {
|
|
||||||
this.platformServiceRef = platformServiceRefArg;
|
|
||||||
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
|
|
||||||
// Set default options
|
|
||||||
this.config = {
|
|
||||||
useMta: options.useMta ?? true,
|
|
||||||
mtaConfig: options.mtaConfig || {},
|
|
||||||
templateConfig: options.templateConfig || {},
|
|
||||||
loadTemplatesFromDir: options.loadTemplatesFromDir ?? true
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize validator
|
|
||||||
this.emailValidator = new EmailValidator();
|
|
||||||
|
|
||||||
// Initialize bounce manager
|
|
||||||
this.bounceManager = new BounceManager();
|
|
||||||
|
|
||||||
// Initialize template manager
|
|
||||||
this.templateManager = new TemplateManager(this.config.templateConfig);
|
|
||||||
|
|
||||||
if (this.config.useMta) {
|
|
||||||
// Initialize MTA service
|
|
||||||
this.mtaService = new MtaService(platformServiceRefArg, this.config.mtaConfig);
|
|
||||||
// Initialize MTA connector
|
|
||||||
this.mtaConnector = new MtaConnector(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize API manager and rule manager
|
|
||||||
this.apiManager = new ApiManager(this);
|
|
||||||
this.ruleManager = new RuleManager(this);
|
|
||||||
|
|
||||||
// Set up MTA SMTP server webhook if using MTA
|
|
||||||
if (this.config.useMta) {
|
|
||||||
// The MTA SMTP server will handle incoming emails directly
|
|
||||||
// through its SMTP protocol. No additional webhook needed.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the email service
|
|
||||||
*/
|
|
||||||
public async start() {
|
|
||||||
// Initialize rule manager
|
|
||||||
await this.ruleManager.init();
|
|
||||||
|
|
||||||
// Load email templates if configured
|
|
||||||
if (this.config.loadTemplatesFromDir) {
|
|
||||||
try {
|
|
||||||
await this.templateManager.loadTemplatesFromDirectory(paths.emailTemplatesDir);
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to load email templates: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start MTA service if enabled
|
|
||||||
if (this.config.useMta && this.mtaService) {
|
|
||||||
await this.mtaService.start();
|
|
||||||
logger.log('success', 'Started MTA service');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('success', `Started email service`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the email service
|
|
||||||
*/
|
|
||||||
public async stop() {
|
|
||||||
// Stop MTA service if it's running
|
|
||||||
if (this.config.useMta && this.mtaService) {
|
|
||||||
await this.mtaService.stop();
|
|
||||||
logger.log('info', 'Stopped MTA service');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('info', 'Stopped email service');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email using the configured provider (Mailgun or MTA)
|
|
||||||
* @param email The email to send
|
|
||||||
* @param to Recipient(s)
|
|
||||||
* @param options Additional options
|
|
||||||
*/
|
|
||||||
public async sendEmail(
|
|
||||||
email: plugins.smartmail.Smartmail<any>,
|
|
||||||
to: string | string[],
|
|
||||||
options: any = {}
|
|
||||||
): Promise<string> {
|
|
||||||
// Determine which connector to use
|
|
||||||
if (this.config.useMta && this.mtaConnector) {
|
|
||||||
return this.mtaConnector.sendEmail(email, to, options);
|
|
||||||
} else {
|
|
||||||
throw new Error('No email provider configured');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email using a template
|
|
||||||
* @param templateId The template ID
|
|
||||||
* @param to Recipient email(s)
|
|
||||||
* @param context The template context data
|
|
||||||
* @param options Additional options
|
|
||||||
*/
|
|
||||||
public async sendTemplateEmail(
|
|
||||||
templateId: string,
|
|
||||||
to: string | string[],
|
|
||||||
context: any = {},
|
|
||||||
options: any = {}
|
|
||||||
): Promise<string> {
|
|
||||||
try {
|
|
||||||
// Get email from template
|
|
||||||
const smartmail = await this.templateManager.prepareEmail(templateId, context);
|
|
||||||
|
|
||||||
// Send the email
|
|
||||||
return this.sendEmail(smartmail, to, options);
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to send template email: ${error.message}`, {
|
|
||||||
templateId,
|
|
||||||
to,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate an email address
|
|
||||||
* @param email The email address to validate
|
|
||||||
* @param options Validation options
|
|
||||||
* @returns Validation result
|
|
||||||
*/
|
|
||||||
public async validateEmail(
|
|
||||||
email: string,
|
|
||||||
options: {
|
|
||||||
checkMx?: boolean;
|
|
||||||
checkDisposable?: boolean;
|
|
||||||
checkRole?: boolean;
|
|
||||||
} = {}
|
|
||||||
): Promise<any> {
|
|
||||||
return this.emailValidator.validate(email, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get email service statistics
|
|
||||||
*/
|
|
||||||
public getStats() {
|
|
||||||
const stats: any = {
|
|
||||||
activeProviders: []
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.config.useMta) {
|
|
||||||
stats.activeProviders.push('mta');
|
|
||||||
stats.mta = this.mtaService.getStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<string, string[]>;
|
|
||||||
|
|
||||||
constructor(options?: {
|
|
||||||
maxCacheSize?: number;
|
|
||||||
cacheTTL?: number;
|
|
||||||
}) {
|
|
||||||
this.validator = new plugins.smartmail.EmailAddressValidator();
|
|
||||||
|
|
||||||
// Initialize LRU cache for DNS records
|
|
||||||
this.dnsCache = new LRUCache<string, string[]>({
|
|
||||||
// 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<IEmailValidationResult> {
|
|
||||||
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<string[]> {
|
|
||||||
// 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<Record<string, IEmailValidationResult>> {
|
|
||||||
const results: Record<string, IEmailValidationResult> = {};
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { EmailService } from './classes.emailservice.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
|
|
||||||
export class RuleManager {
|
|
||||||
public emailRef: EmailService;
|
|
||||||
public smartruleInstance = new plugins.smartrule.SmartRule<
|
|
||||||
plugins.smartmail.Smartmail<any>
|
|
||||||
>();
|
|
||||||
|
|
||||||
constructor(emailRefArg: EmailService) {
|
|
||||||
this.emailRef = emailRefArg;
|
|
||||||
|
|
||||||
// Register MTA handler for incoming emails if MTA is enabled
|
|
||||||
if (this.emailRef.mtaService) {
|
|
||||||
this.setupMtaIncomingHandler();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up handler for incoming emails via MTA's SMTP server
|
|
||||||
*/
|
|
||||||
private setupMtaIncomingHandler() {
|
|
||||||
// The original MtaService doesn't have a direct callback for incoming emails,
|
|
||||||
// but we can modify this approach based on how you prefer to integrate.
|
|
||||||
// One option would be to extend the MtaService to add an event emitter.
|
|
||||||
|
|
||||||
// For now, we'll use a directory watcher as an example
|
|
||||||
// This would watch the directory where MTA saves incoming emails
|
|
||||||
const incomingDir = this.emailRef.mtaService['receivedEmailsDir'] || './received';
|
|
||||||
|
|
||||||
// Simple file watcher (in real implementation, use proper file watching)
|
|
||||||
// This is just conceptual - would need modification to work with your specific setup
|
|
||||||
this.watchIncomingEmails(incomingDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Watch directory for incoming emails (conceptual implementation)
|
|
||||||
*/
|
|
||||||
private watchIncomingEmails(directory: string) {
|
|
||||||
console.log(`Watching for incoming emails in: ${directory}`);
|
|
||||||
|
|
||||||
// Conceptual - in a real implementation, set up proper file watching
|
|
||||||
// or modify the MTA to emit events when emails are received
|
|
||||||
|
|
||||||
/*
|
|
||||||
// Example using a file watcher:
|
|
||||||
const watcher = plugins.fs.watch(directory, async (eventType, filename) => {
|
|
||||||
if (eventType === 'rename' && filename.endsWith('.eml')) {
|
|
||||||
const filePath = plugins.path.join(directory, filename);
|
|
||||||
await this.handleMtaIncomingEmail(filePath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle incoming email received via MTA
|
|
||||||
*/
|
|
||||||
public async handleMtaIncomingEmail(emailPath: string) {
|
|
||||||
try {
|
|
||||||
// Process the email file
|
|
||||||
const fetchedSmartmail = await this.emailRef.mtaConnector.receiveEmail(emailPath);
|
|
||||||
|
|
||||||
console.log('=======================');
|
|
||||||
console.log('Received a mail via MTA:');
|
|
||||||
console.log(`From: ${fetchedSmartmail.options.creationObjectRef.From}`);
|
|
||||||
console.log(`To: ${fetchedSmartmail.options.creationObjectRef.To}`);
|
|
||||||
console.log(`Subject: ${fetchedSmartmail.options.creationObjectRef.Subject}`);
|
|
||||||
console.log('^^^^^^^^^^^^^^^^^^^^^^^');
|
|
||||||
|
|
||||||
logger.log(
|
|
||||||
'info',
|
|
||||||
`email from ${fetchedSmartmail.options.creationObjectRef.From} to ${fetchedSmartmail.options.creationObjectRef.To} with subject '${fetchedSmartmail.options.creationObjectRef.Subject}'`,
|
|
||||||
{
|
|
||||||
eventType: 'receivedEmail',
|
|
||||||
provider: 'mta',
|
|
||||||
email: {
|
|
||||||
from: fetchedSmartmail.options.creationObjectRef.From,
|
|
||||||
to: fetchedSmartmail.options.creationObjectRef.To,
|
|
||||||
subject: fetchedSmartmail.options.creationObjectRef.Subject,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process with rules
|
|
||||||
this.smartruleInstance.makeDecision(fetchedSmartmail);
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to process incoming MTA email: ${error.message}`, {
|
|
||||||
eventType: 'emailError',
|
|
||||||
provider: 'mta',
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async init() {
|
|
||||||
// Setup email rules
|
|
||||||
await this.createForwards();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* creates the default forwards
|
|
||||||
*/
|
|
||||||
public async createForwards() {
|
|
||||||
const forwards: { originalToAddress: string[]; forwardedToAddress: string[] }[] = [];
|
|
||||||
console.log(`${forwards.length} forward rules configured:`);
|
|
||||||
for (const forward of forwards) {
|
|
||||||
console.log(forward);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const forward of forwards) {
|
|
||||||
this.smartruleInstance.createRule(
|
|
||||||
10,
|
|
||||||
async (smartmailArg) => {
|
|
||||||
const matched = forward.originalToAddress.reduce<boolean>((prevValue, currentValue) => {
|
|
||||||
return smartmailArg.options.creationObjectRef.To.includes(currentValue) || prevValue;
|
|
||||||
}, false);
|
|
||||||
if (matched) {
|
|
||||||
console.log('Forward rule matched');
|
|
||||||
console.log(forward);
|
|
||||||
return 'apply-continue';
|
|
||||||
} else {
|
|
||||||
return 'continue';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async (smartmailArg: plugins.smartmail.Smartmail<any>) => {
|
|
||||||
forward.forwardedToAddress.map(async (toArg) => {
|
|
||||||
const forwardedSmartMail = new plugins.smartmail.Smartmail({
|
|
||||||
body:
|
|
||||||
`
|
|
||||||
<div style="background: #CCC; padding: 10px; border-radius: 3px;">
|
|
||||||
<div><b>Original Sender:</b></div>
|
|
||||||
<div>${smartmailArg.options.creationObjectRef.From}</div>
|
|
||||||
<div><b>Original Recipient:</b></div>
|
|
||||||
<div>${smartmailArg.options.creationObjectRef.To}</div>
|
|
||||||
<div><b>Forwarded to:</b></div>
|
|
||||||
<div>${forward.forwardedToAddress.reduce<string>((pVal, cVal) => {
|
|
||||||
return `${pVal ? pVal + ', ' : ''}${cVal}`;
|
|
||||||
}, null)}</div>
|
|
||||||
<div><b>Subject:</b></div>
|
|
||||||
<div>${smartmailArg.getSubject()}</div>
|
|
||||||
<div><b>The original body can be found below.</b></div>
|
|
||||||
</div>
|
|
||||||
` + smartmailArg.getBody(),
|
|
||||||
from: 'forwarder@mail.lossless.one',
|
|
||||||
subject: `Forwarded mail for '${smartmailArg.options.creationObjectRef.To}'`,
|
|
||||||
});
|
|
||||||
for (const attachment of smartmailArg.attachments) {
|
|
||||||
forwardedSmartMail.addAttachment(attachment);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the EmailService's sendEmail method to send with the appropriate provider
|
|
||||||
await this.emailRef.sendEmail(forwardedSmartMail, toArg);
|
|
||||||
|
|
||||||
console.log(`forwarded mail to ${toArg}`);
|
|
||||||
logger.log(
|
|
||||||
'info',
|
|
||||||
`email from ${
|
|
||||||
smartmailArg.options.creationObjectRef.From
|
|
||||||
} to ${toArg} with subject '${smartmailArg.getSubject()}'`,
|
|
||||||
{
|
|
||||||
eventType: 'forwardedEmail',
|
|
||||||
email: {
|
|
||||||
from: smartmailArg.options.creationObjectRef.From,
|
|
||||||
to: smartmailArg.options.creationObjectRef.To,
|
|
||||||
forwardedTo: toArg,
|
|
||||||
subject: smartmailArg.options.creationObjectRef.Subject,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email template type definition
|
|
||||||
*/
|
|
||||||
export interface IEmailTemplate<T = any> {
|
|
||||||
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 smartmail's capabilities
|
|
||||||
*/
|
|
||||||
export class TemplateManager {
|
|
||||||
private templates: Map<string, IEmailTemplate> = 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: `
|
|
||||||
<h1>Welcome, {{firstName}}!</h1>
|
|
||||||
<p>Thank you for joining {{serviceName}}. We're excited to have you on board.</p>
|
|
||||||
<p>To get started, <a href="{{accountUrl}}">visit your account</a>.</p>
|
|
||||||
`,
|
|
||||||
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: `
|
|
||||||
<h2>Password Reset Request</h2>
|
|
||||||
<p>You recently requested to reset your password. Click the link below to reset it:</p>
|
|
||||||
<p><a href="{{resetUrl}}">Reset Password</a></p>
|
|
||||||
<p>This link will expire in {{expiryHours}} hours.</p>
|
|
||||||
<p>If you didn't request a password reset, please ignore this email.</p>
|
|
||||||
`,
|
|
||||||
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: `
|
|
||||||
<h2>{{title}}</h2>
|
|
||||||
<div>{{message}}</div>
|
|
||||||
`,
|
|
||||||
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<T = any>(template: IEmailTemplate<T>): 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<T = any>(templateId: string): IEmailTemplate<T> | undefined {
|
|
||||||
return this.templates.get(templateId) as IEmailTemplate<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 a Smartmail instance from a template
|
|
||||||
* @param templateId The template ID
|
|
||||||
* @param context The template context data
|
|
||||||
* @returns A configured Smartmail instance
|
|
||||||
*/
|
|
||||||
public async createSmartmail<T = any>(
|
|
||||||
templateId: string,
|
|
||||||
context?: ITemplateContext
|
|
||||||
): Promise<plugins.smartmail.Smartmail<T>> {
|
|
||||||
const template = this.getTemplate(templateId);
|
|
||||||
|
|
||||||
if (!template) {
|
|
||||||
throw new Error(`Template with ID '${templateId}' not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Smartmail instance with template content
|
|
||||||
const smartmail = new plugins.smartmail.Smartmail<T>({
|
|
||||||
from: template.from || this.defaultConfig.from,
|
|
||||||
subject: template.subject,
|
|
||||||
body: template.bodyHtml || template.bodyText || '',
|
|
||||||
creationObjectRef: context as T
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add any template attachments
|
|
||||||
if (template.attachments && template.attachments.length > 0) {
|
|
||||||
for (const attachment of template.attachments) {
|
|
||||||
// Load attachment file
|
|
||||||
try {
|
|
||||||
const attachmentPath = plugins.path.isAbsolute(attachment.path)
|
|
||||||
? attachment.path
|
|
||||||
: plugins.path.join(paths.MtaAttachmentsDir, attachment.path);
|
|
||||||
|
|
||||||
// Use appropriate SmartFile method - either read from file or create with empty buffer
|
|
||||||
// For a file path, use the fromFilePath static method
|
|
||||||
const file = await plugins.smartfile.SmartFile.fromFilePath(attachmentPath);
|
|
||||||
|
|
||||||
// Set content type if specified
|
|
||||||
if (attachment.contentType) {
|
|
||||||
(file as any).contentType = attachment.contentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
smartmail.addAttachment(file);
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to add attachment '${attachment.name}': ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply template variables if context provided
|
|
||||||
if (context) {
|
|
||||||
// Use applyVariables from smartmail v2.1.0+
|
|
||||||
smartmail.applyVariables(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
return smartmail;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and completely process a Smartmail instance from a template
|
|
||||||
* @param templateId The template ID
|
|
||||||
* @param context The template context data
|
|
||||||
* @returns A complete, processed Smartmail instance ready to send
|
|
||||||
*/
|
|
||||||
public async prepareEmail<T = any>(
|
|
||||||
templateId: string,
|
|
||||||
context: ITemplateContext = {}
|
|
||||||
): Promise<plugins.smartmail.Smartmail<T>> {
|
|
||||||
const smartmail = await this.createSmartmail<T>(templateId, context);
|
|
||||||
|
|
||||||
// Pre-compile all mustache templates (subject, body)
|
|
||||||
smartmail.getSubject();
|
|
||||||
smartmail.getBody();
|
|
||||||
|
|
||||||
return smartmail;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<string> {
|
|
||||||
const smartmail = await this.prepareEmail(templateId, context);
|
|
||||||
return smartmail.toMimeFormat();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load templates from a directory
|
|
||||||
* @param directory The directory containing template JSON files
|
|
||||||
*/
|
|
||||||
public async loadTemplatesFromDirectory(directory: string): Promise<void> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { EmailService } from './classes.emailservice.js';
|
|
||||||
import { BounceManager, BounceType, BounceCategory } from './classes.bouncemanager.js';
|
|
||||||
import { EmailValidator } from './classes.emailvalidator.js';
|
|
||||||
import { TemplateManager } from './classes.templatemanager.js';
|
|
||||||
import { RuleManager } from './classes.rulemanager.js';
|
|
||||||
import { ApiManager } from './classes.apimanager.js';
|
|
||||||
import { MtaConnector } from './classes.connector.mta.js';
|
|
||||||
|
|
||||||
export {
|
|
||||||
EmailService as Email,
|
|
||||||
BounceManager,
|
|
||||||
BounceType,
|
|
||||||
BounceCategory,
|
|
||||||
EmailValidator,
|
|
||||||
TemplateManager,
|
|
||||||
RuleManager,
|
|
||||||
ApiManager,
|
|
||||||
MtaConnector
|
|
||||||
};
|
|
||||||
525
ts/errors/base.errors.ts
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
import { ErrorSeverity, ErrorCategory, ErrorRecoverability } from './error.codes.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
// Import TLogLevel from plugins
|
||||||
|
import type { TLogLevel } from '../plugins.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context information added to structured errors
|
||||||
|
*/
|
||||||
|
export interface IErrorContext {
|
||||||
|
/** Component or service where the error occurred */
|
||||||
|
component?: string;
|
||||||
|
|
||||||
|
/** Operation that was being performed */
|
||||||
|
operation?: string;
|
||||||
|
|
||||||
|
/** Unique request ID if available */
|
||||||
|
requestId?: string;
|
||||||
|
|
||||||
|
/** Error occurred at timestamp */
|
||||||
|
timestamp?: number;
|
||||||
|
|
||||||
|
/** User-visible message (safe to display to end-users) */
|
||||||
|
userMessage?: string;
|
||||||
|
|
||||||
|
/** Additional structured data for debugging */
|
||||||
|
data?: Record<string, any>;
|
||||||
|
|
||||||
|
/** Related entity IDs if applicable */
|
||||||
|
entity?: {
|
||||||
|
type: string;
|
||||||
|
id: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stack trace (if enabled in configuration) */
|
||||||
|
stack?: string;
|
||||||
|
|
||||||
|
/** Retry information if applicable */
|
||||||
|
retry?: {
|
||||||
|
/** Maximum number of retries allowed */
|
||||||
|
maxRetries?: number;
|
||||||
|
|
||||||
|
/** Current retry count */
|
||||||
|
currentRetry?: number;
|
||||||
|
|
||||||
|
/** Next retry timestamp */
|
||||||
|
nextRetryAt?: number;
|
||||||
|
|
||||||
|
/** Delay between retries (in ms) */
|
||||||
|
retryDelay?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for all errors in the Platform Service
|
||||||
|
* Adds structured error information, logging, and error tracking
|
||||||
|
*/
|
||||||
|
export class PlatformError extends Error {
|
||||||
|
/** Error code identifying the specific error type */
|
||||||
|
public readonly code: string;
|
||||||
|
|
||||||
|
/** Error severity level */
|
||||||
|
public readonly severity: ErrorSeverity;
|
||||||
|
|
||||||
|
/** Error category for grouping related errors */
|
||||||
|
public readonly category: ErrorCategory;
|
||||||
|
|
||||||
|
/** Whether the error can be recovered from automatically */
|
||||||
|
public readonly recoverability: ErrorRecoverability;
|
||||||
|
|
||||||
|
/** Additional context information */
|
||||||
|
public readonly context: IErrorContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new PlatformError
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param code Error code from error.codes.ts
|
||||||
|
* @param severity Error severity level
|
||||||
|
* @param category Error category
|
||||||
|
* @param recoverability Error recoverability indication
|
||||||
|
* @param context Additional context information
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: string,
|
||||||
|
severity: ErrorSeverity = ErrorSeverity.MEDIUM,
|
||||||
|
category: ErrorCategory = ErrorCategory.OTHER,
|
||||||
|
recoverability: ErrorRecoverability = ErrorRecoverability.NON_RECOVERABLE,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
|
||||||
|
// Set error metadata
|
||||||
|
this.name = this.constructor.name;
|
||||||
|
this.code = code;
|
||||||
|
this.severity = severity;
|
||||||
|
this.category = category;
|
||||||
|
this.recoverability = recoverability;
|
||||||
|
|
||||||
|
// Add timestamp if not provided
|
||||||
|
this.context = {
|
||||||
|
...context,
|
||||||
|
timestamp: context.timestamp || Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture stack trace
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
|
||||||
|
// Log the error automatically unless explicitly disabled
|
||||||
|
if (!context.data?.skipLogging) {
|
||||||
|
this.logError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the error using the platform logger
|
||||||
|
*/
|
||||||
|
private logError(): void {
|
||||||
|
const logLevel = this.getLogLevelFromSeverity() as TLogLevel;
|
||||||
|
|
||||||
|
// Construct structured log entry
|
||||||
|
const logData = {
|
||||||
|
error_code: this.code,
|
||||||
|
error_name: this.name,
|
||||||
|
severity: this.severity,
|
||||||
|
category: this.category,
|
||||||
|
recoverability: this.recoverability,
|
||||||
|
...this.context
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log with appropriate level
|
||||||
|
logger.log(logLevel, this.message, logData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps severity levels to log levels
|
||||||
|
*/
|
||||||
|
private getLogLevelFromSeverity(): string {
|
||||||
|
switch (this.severity) {
|
||||||
|
case ErrorSeverity.CRITICAL:
|
||||||
|
case ErrorSeverity.HIGH:
|
||||||
|
return 'error';
|
||||||
|
case ErrorSeverity.MEDIUM:
|
||||||
|
return 'warn';
|
||||||
|
case ErrorSeverity.LOW:
|
||||||
|
return 'info';
|
||||||
|
case ErrorSeverity.INFO:
|
||||||
|
return 'debug';
|
||||||
|
default:
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a JSON representation of the error
|
||||||
|
*/
|
||||||
|
public toJSON(): Record<string, any> {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
message: this.message,
|
||||||
|
code: this.code,
|
||||||
|
severity: this.severity,
|
||||||
|
category: this.category,
|
||||||
|
recoverability: this.recoverability,
|
||||||
|
context: this.context,
|
||||||
|
stack: process.env.NODE_ENV !== 'production' ? this.stack : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance with retry information
|
||||||
|
*
|
||||||
|
* @param maxRetries Maximum number of retries
|
||||||
|
* @param currentRetry Current retry count
|
||||||
|
* @param retryDelay Delay between retries in ms
|
||||||
|
*/
|
||||||
|
public withRetry(
|
||||||
|
maxRetries: number,
|
||||||
|
currentRetry: number = 0,
|
||||||
|
retryDelay: number = 1000
|
||||||
|
): PlatformError {
|
||||||
|
const nextRetryAt = Date.now() + retryDelay;
|
||||||
|
|
||||||
|
// Clone the error with updated context
|
||||||
|
const newContext = {
|
||||||
|
...this.context,
|
||||||
|
retry: {
|
||||||
|
maxRetries,
|
||||||
|
currentRetry,
|
||||||
|
nextRetryAt,
|
||||||
|
retryDelay
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a new instance using the protected method that subclasses can override
|
||||||
|
const newError = this.createWithContext(newContext);
|
||||||
|
|
||||||
|
// Update recoverability if we can retry
|
||||||
|
if (currentRetry < maxRetries && newError.recoverability === ErrorRecoverability.NON_RECOVERABLE) {
|
||||||
|
(newError as any).recoverability = ErrorRecoverability.MAYBE_RECOVERABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protected method to create a new instance with updated context
|
||||||
|
* Subclasses can override this to handle their own constructor signatures
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
// Default implementation for PlatformError
|
||||||
|
return new (this.constructor as typeof PlatformError)(
|
||||||
|
this.message,
|
||||||
|
this.code,
|
||||||
|
this.severity,
|
||||||
|
this.category,
|
||||||
|
this.recoverability,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the error should be retried based on retry information
|
||||||
|
*/
|
||||||
|
public shouldRetry(): boolean {
|
||||||
|
const { retry } = this.context;
|
||||||
|
if (!retry) return false;
|
||||||
|
|
||||||
|
return retry.currentRetry < retry.maxRetries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a user-friendly message that is safe to display to end users
|
||||||
|
*/
|
||||||
|
public getUserMessage(): string {
|
||||||
|
return this.context.userMessage || 'An unexpected error occurred.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for validation errors
|
||||||
|
*/
|
||||||
|
export class ValidationError extends PlatformError {
|
||||||
|
/**
|
||||||
|
* Creates a new validation error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param code Error code
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
ErrorSeverity.LOW,
|
||||||
|
ErrorCategory.VALIDATION,
|
||||||
|
ErrorRecoverability.NON_RECOVERABLE,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
* Overrides the base implementation to handle ValidationError's constructor signature
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof ValidationError)(
|
||||||
|
this.message,
|
||||||
|
this.code,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for configuration errors
|
||||||
|
*/
|
||||||
|
export class ConfigurationError extends PlatformError {
|
||||||
|
/**
|
||||||
|
* Creates a new configuration error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param code Error code
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
ErrorSeverity.MEDIUM,
|
||||||
|
ErrorCategory.CONFIGURATION,
|
||||||
|
ErrorRecoverability.NON_RECOVERABLE,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
* Overrides the base implementation to handle ConfigurationError's constructor signature
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof ConfigurationError)(
|
||||||
|
this.message,
|
||||||
|
this.code,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for network-related errors
|
||||||
|
*/
|
||||||
|
export class NetworkError extends PlatformError {
|
||||||
|
/**
|
||||||
|
* Creates a new network error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param code Error code
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
ErrorSeverity.MEDIUM,
|
||||||
|
ErrorCategory.CONNECTIVITY,
|
||||||
|
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
* Overrides the base implementation to handle NetworkError's constructor signature
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof NetworkError)(
|
||||||
|
this.message,
|
||||||
|
this.code,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for resource availability errors (rate limits, quotas)
|
||||||
|
*/
|
||||||
|
export class ResourceError extends PlatformError {
|
||||||
|
/**
|
||||||
|
* Creates a new resource error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param code Error code
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
ErrorSeverity.MEDIUM,
|
||||||
|
ErrorCategory.RESOURCE,
|
||||||
|
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
* Overrides the base implementation to handle ResourceError's constructor signature
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof ResourceError)(
|
||||||
|
this.message,
|
||||||
|
this.code,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for authentication/authorization errors
|
||||||
|
*/
|
||||||
|
export class AuthenticationError extends PlatformError {
|
||||||
|
/**
|
||||||
|
* Creates a new authentication error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param code Error code
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
ErrorSeverity.HIGH,
|
||||||
|
ErrorCategory.AUTHENTICATION,
|
||||||
|
ErrorRecoverability.NON_RECOVERABLE,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
* Overrides the base implementation to handle AuthenticationError's constructor signature
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof AuthenticationError)(
|
||||||
|
this.message,
|
||||||
|
this.code,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for operation errors (API calls, processing)
|
||||||
|
*/
|
||||||
|
export class OperationError extends PlatformError {
|
||||||
|
/**
|
||||||
|
* Creates a new operation error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param code Error code
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
ErrorSeverity.MEDIUM,
|
||||||
|
ErrorCategory.OPERATION,
|
||||||
|
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
* Overrides the base implementation to handle OperationError's constructor signature
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof OperationError)(
|
||||||
|
this.message,
|
||||||
|
this.code,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for critical system errors
|
||||||
|
*/
|
||||||
|
export class SystemError extends PlatformError {
|
||||||
|
/**
|
||||||
|
* Creates a new system error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param code Error code
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
ErrorSeverity.CRITICAL,
|
||||||
|
ErrorCategory.OTHER,
|
||||||
|
ErrorRecoverability.NON_RECOVERABLE,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get the appropriate error class based on error category
|
||||||
|
*
|
||||||
|
* @param category Error category
|
||||||
|
* @returns The appropriate error class
|
||||||
|
*/
|
||||||
|
export function getErrorClassForCategory(category: ErrorCategory): any {
|
||||||
|
switch (category) {
|
||||||
|
case ErrorCategory.VALIDATION:
|
||||||
|
return ValidationError;
|
||||||
|
case ErrorCategory.CONFIGURATION:
|
||||||
|
return ConfigurationError;
|
||||||
|
case ErrorCategory.CONNECTIVITY:
|
||||||
|
return NetworkError;
|
||||||
|
case ErrorCategory.RESOURCE:
|
||||||
|
return ResourceError;
|
||||||
|
case ErrorCategory.AUTHENTICATION:
|
||||||
|
return AuthenticationError;
|
||||||
|
case ErrorCategory.OPERATION:
|
||||||
|
return OperationError;
|
||||||
|
default:
|
||||||
|
return PlatformError;
|
||||||
|
}
|
||||||
|
}
|
||||||
412
ts/errors/error-handler.ts
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
import { PlatformError } from './base.errors.js';
|
||||||
|
import type { IErrorContext } from './base.errors.js';
|
||||||
|
import { ErrorCategory, ErrorRecoverability, ErrorSeverity } from './error.codes.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error handler configuration
|
||||||
|
*/
|
||||||
|
export interface IErrorHandlerConfig {
|
||||||
|
/** Whether to log errors automatically */
|
||||||
|
logErrors: boolean;
|
||||||
|
|
||||||
|
/** Whether to include stack traces in prod environment */
|
||||||
|
includeStacksInProd: boolean;
|
||||||
|
|
||||||
|
/** Default retry options */
|
||||||
|
retry: {
|
||||||
|
/** Maximum retry attempts */
|
||||||
|
maxAttempts: number;
|
||||||
|
|
||||||
|
/** Base delay between retries in ms */
|
||||||
|
baseDelay: number;
|
||||||
|
|
||||||
|
/** Maximum delay between retries in ms */
|
||||||
|
maxDelay: number;
|
||||||
|
|
||||||
|
/** Backoff factor for exponential backoff */
|
||||||
|
backoffFactor: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global error handler configuration
|
||||||
|
*/
|
||||||
|
const config: IErrorHandlerConfig = {
|
||||||
|
logErrors: true,
|
||||||
|
includeStacksInProd: false,
|
||||||
|
retry: {
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelay: 1000,
|
||||||
|
maxDelay: 30000,
|
||||||
|
backoffFactor: 2
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error handler utility
|
||||||
|
* Provides methods for consistent error handling across the platform
|
||||||
|
*/
|
||||||
|
export class ErrorHandler {
|
||||||
|
/**
|
||||||
|
* Current configuration
|
||||||
|
*/
|
||||||
|
public static config = config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update error handler configuration
|
||||||
|
*
|
||||||
|
* @param newConfig New configuration (partial)
|
||||||
|
*/
|
||||||
|
public static configure(newConfig: Partial<IErrorHandlerConfig>): void {
|
||||||
|
ErrorHandler.config = {
|
||||||
|
...ErrorHandler.config,
|
||||||
|
...newConfig,
|
||||||
|
retry: {
|
||||||
|
...ErrorHandler.config.retry,
|
||||||
|
...(newConfig.retry || {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert any error to a PlatformError
|
||||||
|
*
|
||||||
|
* @param error Error to convert
|
||||||
|
* @param defaultCode Default error code if not a PlatformError
|
||||||
|
* @param context Additional context
|
||||||
|
* @returns PlatformError instance
|
||||||
|
*/
|
||||||
|
public static toPlatformError(
|
||||||
|
error: any,
|
||||||
|
defaultCode: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): PlatformError {
|
||||||
|
// If already a PlatformError, just add context
|
||||||
|
if (error instanceof PlatformError) {
|
||||||
|
// Add context if provided
|
||||||
|
if (Object.keys(context).length > 0) {
|
||||||
|
return new (error.constructor as typeof PlatformError)(
|
||||||
|
error.message,
|
||||||
|
error.code,
|
||||||
|
error.severity,
|
||||||
|
error.category,
|
||||||
|
error.recoverability,
|
||||||
|
{
|
||||||
|
...error.context,
|
||||||
|
...context,
|
||||||
|
data: {
|
||||||
|
...(error.context.data || {}),
|
||||||
|
...(context.data || {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert standard Error to PlatformError
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return new PlatformError(
|
||||||
|
error.message,
|
||||||
|
defaultCode,
|
||||||
|
ErrorSeverity.MEDIUM,
|
||||||
|
ErrorCategory.OPERATION,
|
||||||
|
ErrorRecoverability.NON_RECOVERABLE,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
data: {
|
||||||
|
...(context.data || {}),
|
||||||
|
originalError: {
|
||||||
|
name: error.name,
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not an Error instance
|
||||||
|
return new PlatformError(
|
||||||
|
typeof error === 'string' ? error : 'Unknown error',
|
||||||
|
defaultCode,
|
||||||
|
ErrorSeverity.MEDIUM,
|
||||||
|
ErrorCategory.OPERATION,
|
||||||
|
ErrorRecoverability.NON_RECOVERABLE,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an error for API responses
|
||||||
|
* Sanitizes errors for safe external exposure
|
||||||
|
*
|
||||||
|
* @param error Error to format
|
||||||
|
* @param includeDetails Whether to include detailed information
|
||||||
|
* @returns Formatted error object
|
||||||
|
*/
|
||||||
|
public static formatErrorForResponse(
|
||||||
|
error: any,
|
||||||
|
includeDetails: boolean = false
|
||||||
|
): Record<string, any> {
|
||||||
|
const platformError = ErrorHandler.toPlatformError(
|
||||||
|
error,
|
||||||
|
'PLATFORM_OPERATION_ERROR'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Basic error information
|
||||||
|
const responseError: Record<string, any> = {
|
||||||
|
code: platformError.code,
|
||||||
|
message: platformError.getUserMessage(),
|
||||||
|
requestId: platformError.context.requestId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include more details if requested
|
||||||
|
if (includeDetails) {
|
||||||
|
responseError.details = {
|
||||||
|
severity: platformError.severity,
|
||||||
|
category: platformError.category,
|
||||||
|
rawMessage: platformError.message,
|
||||||
|
data: platformError.context.data
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include stack trace in non-production or if explicitly enabled
|
||||||
|
if (process.env.NODE_ENV !== 'production' || ErrorHandler.config.includeStacksInProd) {
|
||||||
|
responseError.details.stack = platformError.stack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an error with consistent logging and formatting
|
||||||
|
*
|
||||||
|
* @param error Error to handle
|
||||||
|
* @param defaultCode Default error code if not a PlatformError
|
||||||
|
* @param context Additional context
|
||||||
|
* @returns Formatted error for response
|
||||||
|
*/
|
||||||
|
public static handleError(
|
||||||
|
error: any,
|
||||||
|
defaultCode: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): Record<string, any> {
|
||||||
|
const platformError = ErrorHandler.toPlatformError(
|
||||||
|
error,
|
||||||
|
defaultCode,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log the error if enabled
|
||||||
|
if (ErrorHandler.config.logErrors) {
|
||||||
|
logger.error(platformError.message, {
|
||||||
|
error_code: platformError.code,
|
||||||
|
error_name: platformError.name,
|
||||||
|
error_severity: platformError.severity,
|
||||||
|
error_category: platformError.category,
|
||||||
|
error_recoverability: platformError.recoverability,
|
||||||
|
...platformError.context,
|
||||||
|
stack: platformError.stack
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return formatted error for response
|
||||||
|
const isDetailedMode = process.env.NODE_ENV !== 'production';
|
||||||
|
return ErrorHandler.formatErrorForResponse(platformError, isDetailedMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a function with error handling
|
||||||
|
*
|
||||||
|
* @param fn Function to execute
|
||||||
|
* @param defaultCode Default error code if the function throws
|
||||||
|
* @param context Additional context
|
||||||
|
* @returns Function result or error
|
||||||
|
*/
|
||||||
|
public static async execute<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
defaultCode: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
throw ErrorHandler.toPlatformError(error, defaultCode, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a function with retries and exponential backoff
|
||||||
|
*
|
||||||
|
* @param fn Function to execute
|
||||||
|
* @param defaultCode Default error code if the function throws
|
||||||
|
* @param options Retry options
|
||||||
|
* @param context Additional context
|
||||||
|
* @returns Function result or error after max retries
|
||||||
|
*/
|
||||||
|
public static async executeWithRetry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
defaultCode: string,
|
||||||
|
options: {
|
||||||
|
maxAttempts?: number;
|
||||||
|
baseDelay?: number;
|
||||||
|
maxDelay?: number;
|
||||||
|
backoffFactor?: number;
|
||||||
|
retryableErrorCodes?: string[];
|
||||||
|
retryableErrorPatterns?: RegExp[];
|
||||||
|
onRetry?: (error: PlatformError, attempt: number, delay: number) => void;
|
||||||
|
} = {},
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
maxAttempts = ErrorHandler.config.retry.maxAttempts,
|
||||||
|
baseDelay = ErrorHandler.config.retry.baseDelay,
|
||||||
|
maxDelay = ErrorHandler.config.retry.maxDelay,
|
||||||
|
backoffFactor = ErrorHandler.config.retry.backoffFactor,
|
||||||
|
retryableErrorCodes = [],
|
||||||
|
retryableErrorPatterns = [],
|
||||||
|
onRetry = () => {}
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let lastError: PlatformError;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
// Convert to PlatformError
|
||||||
|
const platformError = ErrorHandler.toPlatformError(
|
||||||
|
error,
|
||||||
|
defaultCode,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
retry: {
|
||||||
|
currentRetry: attempt,
|
||||||
|
maxRetries: maxAttempts,
|
||||||
|
nextRetryAt: 0 // Will be set below if retrying
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
lastError = platformError;
|
||||||
|
|
||||||
|
// Check if we should retry
|
||||||
|
const isLastAttempt = attempt >= maxAttempts - 1;
|
||||||
|
|
||||||
|
if (isLastAttempt) {
|
||||||
|
// No more retries
|
||||||
|
throw platformError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if error is retryable
|
||||||
|
const isRetryable =
|
||||||
|
// Built-in recoverability
|
||||||
|
platformError.recoverability === ErrorRecoverability.RECOVERABLE ||
|
||||||
|
platformError.recoverability === ErrorRecoverability.MAYBE_RECOVERABLE ||
|
||||||
|
platformError.recoverability === ErrorRecoverability.TRANSIENT ||
|
||||||
|
// Specifically included error codes
|
||||||
|
retryableErrorCodes.includes(platformError.code) ||
|
||||||
|
// Matches error message patterns
|
||||||
|
retryableErrorPatterns.some(pattern => pattern.test(platformError.message));
|
||||||
|
|
||||||
|
if (!isRetryable) {
|
||||||
|
throw platformError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate delay with exponential backoff
|
||||||
|
const delay = Math.min(baseDelay * Math.pow(backoffFactor, attempt), maxDelay);
|
||||||
|
|
||||||
|
// Add jitter to prevent thundering herd problem (±20%)
|
||||||
|
const jitter = 0.8 + Math.random() * 0.4;
|
||||||
|
const actualDelay = Math.floor(delay * jitter);
|
||||||
|
|
||||||
|
// Update nextRetryAt in error context
|
||||||
|
const nextRetryAt = Date.now() + actualDelay;
|
||||||
|
platformError.context.retry!.nextRetryAt = nextRetryAt;
|
||||||
|
|
||||||
|
// Log retry attempt
|
||||||
|
logger.warn(`Retrying operation after error (attempt ${attempt + 1}/${maxAttempts}): ${platformError.message}`, {
|
||||||
|
error_code: platformError.code,
|
||||||
|
retry_attempt: attempt + 1,
|
||||||
|
retry_max_attempts: maxAttempts,
|
||||||
|
retry_delay_ms: actualDelay,
|
||||||
|
retry_next_at: new Date(nextRetryAt).toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call onRetry callback
|
||||||
|
onRetry(platformError, attempt + 1, actualDelay);
|
||||||
|
|
||||||
|
// Wait before next retry
|
||||||
|
await new Promise(resolve => setTimeout(resolve, actualDelay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should never happen, but TypeScript needs it
|
||||||
|
throw lastError!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a middleware for handling errors in HTTP requests
|
||||||
|
*
|
||||||
|
* @returns Middleware function
|
||||||
|
*/
|
||||||
|
export function createErrorHandlerMiddleware() {
|
||||||
|
return (error: any, req: any, res: any, next: any) => {
|
||||||
|
// Add request context
|
||||||
|
const context: IErrorContext = {
|
||||||
|
requestId: req.headers['x-request-id'] || req.headers['x-correlation-id'],
|
||||||
|
component: 'HttpServer',
|
||||||
|
operation: `${req.method} ${req.url}`,
|
||||||
|
data: {
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
query: req.query,
|
||||||
|
params: req.params,
|
||||||
|
ip: req.ip || req.connection.remoteAddress,
|
||||||
|
userAgent: req.headers['user-agent']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle the error
|
||||||
|
const formattedError = ErrorHandler.handleError(
|
||||||
|
error,
|
||||||
|
'PLATFORM_OPERATION_ERROR',
|
||||||
|
context
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set status code based on error type
|
||||||
|
let statusCode = 500;
|
||||||
|
|
||||||
|
if (error instanceof PlatformError) {
|
||||||
|
// Map error categories to HTTP status codes
|
||||||
|
switch (error.category) {
|
||||||
|
case ErrorCategory.VALIDATION:
|
||||||
|
statusCode = 400;
|
||||||
|
break;
|
||||||
|
case ErrorCategory.AUTHENTICATION:
|
||||||
|
statusCode = 401;
|
||||||
|
break;
|
||||||
|
case ErrorCategory.RESOURCE:
|
||||||
|
statusCode = 429;
|
||||||
|
break;
|
||||||
|
case ErrorCategory.OPERATION:
|
||||||
|
statusCode = 400;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusCode = 500;
|
||||||
|
}
|
||||||
|
} else if (error.statusCode) {
|
||||||
|
// Use provided status code if available
|
||||||
|
statusCode = error.statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send error response
|
||||||
|
res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
error: formattedError
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
165
ts/errors/error.codes.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Platform Service Error Codes
|
||||||
|
*
|
||||||
|
* This file contains all error codes used across the platform service.
|
||||||
|
*
|
||||||
|
* Format: PREFIX_ERROR_TYPE
|
||||||
|
* - PREFIX: Component/domain prefix (e.g., EMAIL, MTA, SMS)
|
||||||
|
* - ERROR_TYPE: Specific error type within the domain
|
||||||
|
*/
|
||||||
|
|
||||||
|
// General platform errors (PLATFORM_*)
|
||||||
|
export const PLATFORM_INITIALIZATION_ERROR = 'PLATFORM_INITIALIZATION_ERROR';
|
||||||
|
export const PLATFORM_CONFIGURATION_ERROR = 'PLATFORM_CONFIGURATION_ERROR';
|
||||||
|
export const PLATFORM_OPERATION_ERROR = 'PLATFORM_OPERATION_ERROR';
|
||||||
|
export const PLATFORM_NOT_IMPLEMENTED = 'PLATFORM_NOT_IMPLEMENTED';
|
||||||
|
export const PLATFORM_NOT_SUPPORTED = 'PLATFORM_NOT_SUPPORTED';
|
||||||
|
export const PLATFORM_SERVICE_UNAVAILABLE = 'PLATFORM_SERVICE_UNAVAILABLE';
|
||||||
|
|
||||||
|
// Email service errors (EMAIL_*)
|
||||||
|
export const EMAIL_SERVICE_ERROR = 'EMAIL_SERVICE_ERROR';
|
||||||
|
export const EMAIL_TEMPLATE_ERROR = 'EMAIL_TEMPLATE_ERROR';
|
||||||
|
export const EMAIL_VALIDATION_ERROR = 'EMAIL_VALIDATION_ERROR';
|
||||||
|
export const EMAIL_SEND_ERROR = 'EMAIL_SEND_ERROR';
|
||||||
|
export const EMAIL_RECEIVE_ERROR = 'EMAIL_RECEIVE_ERROR';
|
||||||
|
export const EMAIL_ATTACHMENT_ERROR = 'EMAIL_ATTACHMENT_ERROR';
|
||||||
|
export const EMAIL_PARSE_ERROR = 'EMAIL_PARSE_ERROR';
|
||||||
|
export const EMAIL_RATE_LIMIT_EXCEEDED = 'EMAIL_RATE_LIMIT_EXCEEDED';
|
||||||
|
|
||||||
|
// MTA-specific errors (MTA_*)
|
||||||
|
export const MTA_CONNECTION_ERROR = 'MTA_CONNECTION_ERROR';
|
||||||
|
export const MTA_AUTHENTICATION_ERROR = 'MTA_AUTHENTICATION_ERROR';
|
||||||
|
export const MTA_DELIVERY_ERROR = 'MTA_DELIVERY_ERROR';
|
||||||
|
export const MTA_CONFIGURATION_ERROR = 'MTA_CONFIGURATION_ERROR';
|
||||||
|
export const MTA_DNS_ERROR = 'MTA_DNS_ERROR';
|
||||||
|
export const MTA_TIMEOUT_ERROR = 'MTA_TIMEOUT_ERROR';
|
||||||
|
export const MTA_PROTOCOL_ERROR = 'MTA_PROTOCOL_ERROR';
|
||||||
|
|
||||||
|
// Bounce management errors (BOUNCE_*)
|
||||||
|
export const BOUNCE_PROCESSING_ERROR = 'BOUNCE_PROCESSING_ERROR';
|
||||||
|
export const BOUNCE_STORAGE_ERROR = 'BOUNCE_STORAGE_ERROR';
|
||||||
|
export const BOUNCE_CLASSIFICATION_ERROR = 'BOUNCE_CLASSIFICATION_ERROR';
|
||||||
|
|
||||||
|
// Email authentication errors (AUTH_*)
|
||||||
|
export const AUTH_SPF_ERROR = 'AUTH_SPF_ERROR';
|
||||||
|
export const AUTH_DKIM_ERROR = 'AUTH_DKIM_ERROR';
|
||||||
|
export const AUTH_DMARC_ERROR = 'AUTH_DMARC_ERROR';
|
||||||
|
export const AUTH_KEY_ERROR = 'AUTH_KEY_ERROR';
|
||||||
|
|
||||||
|
// Content scanning errors (SCAN_*)
|
||||||
|
export const SCAN_ANALYSIS_ERROR = 'SCAN_ANALYSIS_ERROR';
|
||||||
|
export const SCAN_MALWARE_DETECTED = 'SCAN_MALWARE_DETECTED';
|
||||||
|
export const SCAN_PHISHING_DETECTED = 'SCAN_PHISHING_DETECTED';
|
||||||
|
export const SCAN_CONTENT_REJECTED = 'SCAN_CONTENT_REJECTED';
|
||||||
|
|
||||||
|
// IP and reputation errors (REPUTATION_*)
|
||||||
|
export const REPUTATION_CHECK_ERROR = 'REPUTATION_CHECK_ERROR';
|
||||||
|
export const REPUTATION_DATA_ERROR = 'REPUTATION_DATA_ERROR';
|
||||||
|
export const REPUTATION_BLOCKLIST_ERROR = 'REPUTATION_BLOCKLIST_ERROR';
|
||||||
|
export const REPUTATION_UPDATE_ERROR = 'REPUTATION_UPDATE_ERROR';
|
||||||
|
|
||||||
|
// IP warmup errors (WARMUP_*)
|
||||||
|
export const WARMUP_ALLOCATION_ERROR = 'WARMUP_ALLOCATION_ERROR';
|
||||||
|
export const WARMUP_LIMIT_EXCEEDED = 'WARMUP_LIMIT_EXCEEDED';
|
||||||
|
export const WARMUP_SCHEDULE_ERROR = 'WARMUP_SCHEDULE_ERROR';
|
||||||
|
|
||||||
|
// Network and connectivity errors (NETWORK_*)
|
||||||
|
export const NETWORK_CONNECTION_ERROR = 'NETWORK_CONNECTION_ERROR';
|
||||||
|
export const NETWORK_TIMEOUT = 'NETWORK_TIMEOUT';
|
||||||
|
export const NETWORK_DNS_ERROR = 'NETWORK_DNS_ERROR';
|
||||||
|
export const NETWORK_TLS_ERROR = 'NETWORK_TLS_ERROR';
|
||||||
|
|
||||||
|
// Queue and processing errors (QUEUE_*)
|
||||||
|
export const QUEUE_FULL_ERROR = 'QUEUE_FULL_ERROR';
|
||||||
|
export const QUEUE_PROCESSING_ERROR = 'QUEUE_PROCESSING_ERROR';
|
||||||
|
export const QUEUE_PERSISTENCE_ERROR = 'QUEUE_PERSISTENCE_ERROR';
|
||||||
|
export const QUEUE_ITEM_NOT_FOUND = 'QUEUE_ITEM_NOT_FOUND';
|
||||||
|
|
||||||
|
// DcRouter errors (DCR_*)
|
||||||
|
export const DCR_ROUTING_ERROR = 'DCR_ROUTING_ERROR';
|
||||||
|
export const DCR_CONFIGURATION_ERROR = 'DCR_CONFIGURATION_ERROR';
|
||||||
|
export const DCR_PROXY_ERROR = 'DCR_PROXY_ERROR';
|
||||||
|
export const DCR_DOMAIN_ERROR = 'DCR_DOMAIN_ERROR';
|
||||||
|
|
||||||
|
// SMS service errors (SMS_*)
|
||||||
|
export const SMS_SERVICE_ERROR = 'SMS_SERVICE_ERROR';
|
||||||
|
export const SMS_SEND_ERROR = 'SMS_SEND_ERROR';
|
||||||
|
export const SMS_VALIDATION_ERROR = 'SMS_VALIDATION_ERROR';
|
||||||
|
export const SMS_RATE_LIMIT_EXCEEDED = 'SMS_RATE_LIMIT_EXCEEDED';
|
||||||
|
|
||||||
|
// Storage errors (STORAGE_*)
|
||||||
|
export const STORAGE_WRITE_ERROR = 'STORAGE_WRITE_ERROR';
|
||||||
|
export const STORAGE_READ_ERROR = 'STORAGE_READ_ERROR';
|
||||||
|
export const STORAGE_DELETE_ERROR = 'STORAGE_DELETE_ERROR';
|
||||||
|
export const STORAGE_QUOTA_EXCEEDED = 'STORAGE_QUOTA_EXCEEDED';
|
||||||
|
|
||||||
|
// Rule management errors (RULE_*)
|
||||||
|
export const RULE_VALIDATION_ERROR = 'RULE_VALIDATION_ERROR';
|
||||||
|
export const RULE_EXECUTION_ERROR = 'RULE_EXECUTION_ERROR';
|
||||||
|
export const RULE_NOT_FOUND = 'RULE_NOT_FOUND';
|
||||||
|
|
||||||
|
// Type definitions for error severity
|
||||||
|
export enum ErrorSeverity {
|
||||||
|
/** Critical errors that require immediate attention */
|
||||||
|
CRITICAL = 'CRITICAL',
|
||||||
|
|
||||||
|
/** High-impact errors that may affect service functioning */
|
||||||
|
HIGH = 'HIGH',
|
||||||
|
|
||||||
|
/** Medium-impact errors that cause partial degradation */
|
||||||
|
MEDIUM = 'MEDIUM',
|
||||||
|
|
||||||
|
/** Low-impact errors that have minimal or local impact */
|
||||||
|
LOW = 'LOW',
|
||||||
|
|
||||||
|
/** Informational errors that are not problematic */
|
||||||
|
INFO = 'INFO'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type definitions for error categories
|
||||||
|
export enum ErrorCategory {
|
||||||
|
/** Errors related to configuration */
|
||||||
|
CONFIGURATION = 'CONFIGURATION',
|
||||||
|
|
||||||
|
/** Errors related to network connectivity */
|
||||||
|
CONNECTIVITY = 'CONNECTIVITY',
|
||||||
|
|
||||||
|
/** Errors related to authentication/authorization */
|
||||||
|
AUTHENTICATION = 'AUTHENTICATION',
|
||||||
|
|
||||||
|
/** Errors related to data validation */
|
||||||
|
VALIDATION = 'VALIDATION',
|
||||||
|
|
||||||
|
/** Errors related to resource availability */
|
||||||
|
RESOURCE = 'RESOURCE',
|
||||||
|
|
||||||
|
/** Errors related to service operations */
|
||||||
|
OPERATION = 'OPERATION',
|
||||||
|
|
||||||
|
/** Errors related to third-party integrations */
|
||||||
|
INTEGRATION = 'INTEGRATION',
|
||||||
|
|
||||||
|
/** Errors related to security */
|
||||||
|
SECURITY = 'SECURITY',
|
||||||
|
|
||||||
|
/** Errors related to data storage */
|
||||||
|
STORAGE = 'STORAGE',
|
||||||
|
|
||||||
|
/** Errors that don't fit into other categories */
|
||||||
|
OTHER = 'OTHER'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type definitions for error recoverability
|
||||||
|
export enum ErrorRecoverability {
|
||||||
|
/** Error cannot be automatically recovered from */
|
||||||
|
NON_RECOVERABLE = 'NON_RECOVERABLE',
|
||||||
|
|
||||||
|
/** Error might be recoverable with retry */
|
||||||
|
MAYBE_RECOVERABLE = 'MAYBE_RECOVERABLE',
|
||||||
|
|
||||||
|
/** Error is definitely recoverable with retries */
|
||||||
|
RECOVERABLE = 'RECOVERABLE',
|
||||||
|
|
||||||
|
/** Error is transient and should resolve without action */
|
||||||
|
TRANSIENT = 'TRANSIENT'
|
||||||
|
}
|
||||||
193
ts/errors/index.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Platform Service Error System
|
||||||
|
*
|
||||||
|
* This module provides a comprehensive error handling system for the Platform Service,
|
||||||
|
* with structured error types, error codes, and consistent patterns for logging and recovery.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Export error codes and types
|
||||||
|
export * from './error.codes.js';
|
||||||
|
|
||||||
|
// Export base error classes
|
||||||
|
export * from './base.errors.js';
|
||||||
|
|
||||||
|
// Export domain-specific error classes
|
||||||
|
export * from './reputation.errors.js';
|
||||||
|
|
||||||
|
// Export error handler
|
||||||
|
export * from './error-handler.js';
|
||||||
|
|
||||||
|
// Export utility function to create specific error types based on the error category
|
||||||
|
import { getErrorClassForCategory } from './base.errors.js';
|
||||||
|
export { getErrorClassForCategory };
|
||||||
|
|
||||||
|
// Import needed classes for utility functions
|
||||||
|
import { PlatformError } from './base.errors.js';
|
||||||
|
import { ErrorSeverity, ErrorCategory, ErrorRecoverability } from './error.codes.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a typed error from a standard Error
|
||||||
|
* Useful for converting errors from external libraries or APIs
|
||||||
|
*
|
||||||
|
* @param error Standard error to convert
|
||||||
|
* @param code Error code to assign
|
||||||
|
* @param contextData Additional context data
|
||||||
|
* @returns Typed PlatformError
|
||||||
|
*/
|
||||||
|
export function fromError(
|
||||||
|
error: Error,
|
||||||
|
code: string,
|
||||||
|
contextData: Record<string, any> = {}
|
||||||
|
): PlatformError {
|
||||||
|
return new PlatformError(
|
||||||
|
error.message,
|
||||||
|
code,
|
||||||
|
ErrorSeverity.MEDIUM,
|
||||||
|
ErrorCategory.OPERATION,
|
||||||
|
ErrorRecoverability.NON_RECOVERABLE,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
...contextData,
|
||||||
|
originalError: {
|
||||||
|
name: error.name,
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if an error is retryable
|
||||||
|
*
|
||||||
|
* @param error Error to check
|
||||||
|
* @returns Boolean indicating if the error should be retried
|
||||||
|
*/
|
||||||
|
export function isRetryable(error: any): boolean {
|
||||||
|
// If it's our platform error, use its recoverability property
|
||||||
|
if (error && typeof error === 'object' && 'recoverability' in error) {
|
||||||
|
return error.recoverability === ErrorRecoverability.RECOVERABLE ||
|
||||||
|
error.recoverability === ErrorRecoverability.MAYBE_RECOVERABLE ||
|
||||||
|
error.recoverability === ErrorRecoverability.TRANSIENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a network error (these are often transient)
|
||||||
|
if (error && typeof error === 'object' && error.code) {
|
||||||
|
const networkErrors = [
|
||||||
|
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EHOSTUNREACH',
|
||||||
|
'ENETUNREACH', 'ENOTFOUND', 'EPROTO', 'ECONNABORTED'
|
||||||
|
];
|
||||||
|
|
||||||
|
return networkErrors.includes(error.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// By default, we can't determine if the error is retryable
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a wrapped version of a function that catches errors
|
||||||
|
* and converts them to typed PlatformErrors
|
||||||
|
*
|
||||||
|
* @param fn Function to wrap
|
||||||
|
* @param errorCode Default error code to use
|
||||||
|
* @param contextData Additional context data
|
||||||
|
* @returns Wrapped function
|
||||||
|
*/
|
||||||
|
export function withErrorHandling<T extends (...args: any[]) => Promise<any>>(
|
||||||
|
fn: T,
|
||||||
|
errorCode: string,
|
||||||
|
contextData: Record<string, any> = {}
|
||||||
|
): T {
|
||||||
|
return (async function(...args: Parameters<T>): Promise<ReturnType<T>> {
|
||||||
|
try {
|
||||||
|
return await fn(...args);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error) {
|
||||||
|
// Already a typed error, rethrow
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw fromError(
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
errorCode,
|
||||||
|
{
|
||||||
|
...contextData,
|
||||||
|
fnName: fn.name,
|
||||||
|
args: args.map(arg =>
|
||||||
|
typeof arg === 'object'
|
||||||
|
? '[Object]'
|
||||||
|
: String(arg).substring(0, 100)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a function with exponential backoff
|
||||||
|
*
|
||||||
|
* @param fn Function to retry
|
||||||
|
* @param options Retry options
|
||||||
|
* @returns Function result or throws after max retries
|
||||||
|
*/
|
||||||
|
export async function retry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: {
|
||||||
|
maxRetries?: number;
|
||||||
|
initialDelay?: number;
|
||||||
|
maxDelay?: number;
|
||||||
|
backoffFactor?: number;
|
||||||
|
retryableErrors?: Array<string | RegExp>;
|
||||||
|
} = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
maxRetries = 3,
|
||||||
|
initialDelay = 1000,
|
||||||
|
maxDelay = 30000,
|
||||||
|
backoffFactor = 2,
|
||||||
|
retryableErrors = []
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let lastError: Error;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error));
|
||||||
|
|
||||||
|
// Check if we should retry
|
||||||
|
const shouldRetry = attempt < maxRetries && (
|
||||||
|
isRetryable(error) ||
|
||||||
|
retryableErrors.some(pattern => {
|
||||||
|
if (typeof pattern === 'string') {
|
||||||
|
return lastError.message.includes(pattern);
|
||||||
|
}
|
||||||
|
return pattern.test(lastError.message);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shouldRetry) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate delay with exponential backoff
|
||||||
|
const delay = Math.min(initialDelay * Math.pow(backoffFactor, attempt), maxDelay);
|
||||||
|
|
||||||
|
// Add jitter to prevent thundering herd problem (±20%)
|
||||||
|
const jitter = 0.8 + Math.random() * 0.4;
|
||||||
|
const actualDelay = Math.floor(delay * jitter);
|
||||||
|
|
||||||
|
// Wait before next retry
|
||||||
|
await new Promise(resolve => setTimeout(resolve, actualDelay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should never happen, but TypeScript needs it
|
||||||
|
throw lastError!;
|
||||||
|
}
|
||||||
422
ts/errors/reputation.errors.ts
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
import {
|
||||||
|
PlatformError,
|
||||||
|
OperationError,
|
||||||
|
ResourceError
|
||||||
|
} from './base.errors.js';
|
||||||
|
import type { IErrorContext } from './base.errors.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
REPUTATION_CHECK_ERROR,
|
||||||
|
REPUTATION_DATA_ERROR,
|
||||||
|
REPUTATION_BLOCKLIST_ERROR,
|
||||||
|
REPUTATION_UPDATE_ERROR,
|
||||||
|
WARMUP_ALLOCATION_ERROR,
|
||||||
|
WARMUP_LIMIT_EXCEEDED,
|
||||||
|
WARMUP_SCHEDULE_ERROR
|
||||||
|
} from './error.codes.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for reputation-related errors
|
||||||
|
*/
|
||||||
|
export class ReputationError extends OperationError {
|
||||||
|
/**
|
||||||
|
* Creates a new reputation error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param code Error code
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, code, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for reputation check errors
|
||||||
|
*/
|
||||||
|
export class ReputationCheckError extends ReputationError {
|
||||||
|
/**
|
||||||
|
* Creates a new reputation check error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, REPUTATION_CHECK_ERROR, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof ReputationCheckError)(
|
||||||
|
this.message,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance for an IP reputation check error
|
||||||
|
*
|
||||||
|
* @param ip IP address
|
||||||
|
* @param provider Reputation provider
|
||||||
|
* @param originalError Original error
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
public static ipCheckFailed(
|
||||||
|
ip: string,
|
||||||
|
provider: string,
|
||||||
|
originalError?: Error,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): ReputationCheckError {
|
||||||
|
const errorMsg = originalError ? `: ${originalError.message}` : '';
|
||||||
|
return new ReputationCheckError(
|
||||||
|
`Failed to check reputation for IP ${ip} with provider ${provider}${errorMsg}`,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
data: {
|
||||||
|
...context.data,
|
||||||
|
ip,
|
||||||
|
provider,
|
||||||
|
originalError: originalError ? {
|
||||||
|
message: originalError.message,
|
||||||
|
stack: originalError.stack
|
||||||
|
} : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance for a domain reputation check error
|
||||||
|
*
|
||||||
|
* @param domain Domain
|
||||||
|
* @param provider Reputation provider
|
||||||
|
* @param originalError Original error
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
public static domainCheckFailed(
|
||||||
|
domain: string,
|
||||||
|
provider: string,
|
||||||
|
originalError?: Error,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): ReputationCheckError {
|
||||||
|
const errorMsg = originalError ? `: ${originalError.message}` : '';
|
||||||
|
return new ReputationCheckError(
|
||||||
|
`Failed to check reputation for domain ${domain} with provider ${provider}${errorMsg}`,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
data: {
|
||||||
|
...context.data,
|
||||||
|
domain,
|
||||||
|
provider,
|
||||||
|
originalError: originalError ? {
|
||||||
|
message: originalError.message,
|
||||||
|
stack: originalError.stack
|
||||||
|
} : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for reputation data errors
|
||||||
|
*/
|
||||||
|
export class ReputationDataError extends ReputationError {
|
||||||
|
/**
|
||||||
|
* Creates a new reputation data error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, REPUTATION_DATA_ERROR, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof ReputationDataError)(
|
||||||
|
this.message,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance for a data access error
|
||||||
|
*
|
||||||
|
* @param entity Entity type (domain, ip)
|
||||||
|
* @param entityId Entity identifier
|
||||||
|
* @param operation Operation that failed (read, write, update)
|
||||||
|
* @param originalError Original error
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
public static dataAccessFailed(
|
||||||
|
entity: string,
|
||||||
|
entityId: string,
|
||||||
|
operation: string,
|
||||||
|
originalError?: Error,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): ReputationDataError {
|
||||||
|
const errorMsg = originalError ? `: ${originalError.message}` : '';
|
||||||
|
return new ReputationDataError(
|
||||||
|
`Failed to ${operation} reputation data for ${entity} ${entityId}${errorMsg}`,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
data: {
|
||||||
|
...context.data,
|
||||||
|
entity,
|
||||||
|
entityId,
|
||||||
|
operation,
|
||||||
|
originalError: originalError ? {
|
||||||
|
message: originalError.message,
|
||||||
|
stack: originalError.stack
|
||||||
|
} : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for blocklist-related errors
|
||||||
|
*/
|
||||||
|
export class BlocklistError extends ReputationError {
|
||||||
|
/**
|
||||||
|
* Creates a new blocklist error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, REPUTATION_BLOCKLIST_ERROR, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof BlocklistError)(
|
||||||
|
this.message,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance for an entity found on a blocklist
|
||||||
|
*
|
||||||
|
* @param entity Entity type (domain, ip)
|
||||||
|
* @param entityId Entity identifier
|
||||||
|
* @param blocklist Blocklist name
|
||||||
|
* @param reason Reason for listing (if available)
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
public static entityBlocked(
|
||||||
|
entity: string,
|
||||||
|
entityId: string,
|
||||||
|
blocklist: string,
|
||||||
|
reason?: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): BlocklistError {
|
||||||
|
const reasonText = reason ? ` (${reason})` : '';
|
||||||
|
return new BlocklistError(
|
||||||
|
`${entity.charAt(0).toUpperCase() + entity.slice(1)} ${entityId} is listed on blocklist ${blocklist}${reasonText}`,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
data: {
|
||||||
|
...context.data,
|
||||||
|
entity,
|
||||||
|
entityId,
|
||||||
|
blocklist,
|
||||||
|
reason
|
||||||
|
},
|
||||||
|
userMessage: `The ${entity} ${entityId} is on a blocklist. This may affect email deliverability.`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for reputation update errors
|
||||||
|
*/
|
||||||
|
export class ReputationUpdateError extends ReputationError {
|
||||||
|
/**
|
||||||
|
* Creates a new reputation update error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, REPUTATION_UPDATE_ERROR, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof ReputationUpdateError)(
|
||||||
|
this.message,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for IP warmup allocation errors
|
||||||
|
*/
|
||||||
|
export class WarmupAllocationError extends ReputationError {
|
||||||
|
/**
|
||||||
|
* Creates a new warmup allocation error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, WARMUP_ALLOCATION_ERROR, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof WarmupAllocationError)(
|
||||||
|
this.message,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance for no available IPs
|
||||||
|
*
|
||||||
|
* @param domain Domain requesting an IP
|
||||||
|
* @param policy Allocation policy that was used
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
public static noAvailableIps(
|
||||||
|
domain: string,
|
||||||
|
policy: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): WarmupAllocationError {
|
||||||
|
return new WarmupAllocationError(
|
||||||
|
`No available IPs for domain ${domain} using ${policy} allocation policy`,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
data: {
|
||||||
|
...context.data,
|
||||||
|
domain,
|
||||||
|
policy
|
||||||
|
},
|
||||||
|
userMessage: `No available sending IPs for ${domain}.`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for IP warmup limit exceeded errors
|
||||||
|
*/
|
||||||
|
export class WarmupLimitError extends ResourceError {
|
||||||
|
/**
|
||||||
|
* Creates a new warmup limit error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, WARMUP_LIMIT_EXCEEDED, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof WarmupLimitError)(
|
||||||
|
this.message,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance for daily sending limit exceeded
|
||||||
|
*
|
||||||
|
* @param ip IP address
|
||||||
|
* @param domain Domain
|
||||||
|
* @param limit Daily limit
|
||||||
|
* @param sent Number of emails sent
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
public static dailyLimitExceeded(
|
||||||
|
ip: string,
|
||||||
|
domain: string,
|
||||||
|
limit: number,
|
||||||
|
sent: number,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): WarmupLimitError {
|
||||||
|
return new WarmupLimitError(
|
||||||
|
`Daily sending limit exceeded for IP ${ip} and domain ${domain}: ${sent}/${limit}`,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
data: {
|
||||||
|
...context.data,
|
||||||
|
ip,
|
||||||
|
domain,
|
||||||
|
limit,
|
||||||
|
sent
|
||||||
|
},
|
||||||
|
userMessage: `Daily sending limit reached for ${domain}.`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for IP warmup schedule errors
|
||||||
|
*/
|
||||||
|
export class WarmupScheduleError extends ReputationError {
|
||||||
|
/**
|
||||||
|
* Creates a new warmup schedule error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, WARMUP_SCHEDULE_ERROR, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof WarmupScheduleError)(
|
||||||
|
this.message,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
ts/index.ts
@@ -1,4 +1,16 @@
|
|||||||
export * from './00_commitinfo_data.js';
|
export * from './00_commitinfo_data.js';
|
||||||
import { SzPlatformService } from './platformservice.js';
|
|
||||||
|
|
||||||
export const runCli = async () => {}
|
// 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';
|
||||||
|
|
||||||
|
// RADIUS module
|
||||||
|
export * from './radius/index.js';
|
||||||
|
|
||||||
|
// Remote Ingress module
|
||||||
|
export * from './remoteingress/index.js';
|
||||||
|
|
||||||
|
export const runCli = async () => {};
|
||||||
|
|||||||
93
ts/logger.ts
@@ -1,9 +1,98 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { SmartlogDestinationBuffer } from '@push.rocks/smartlog/destination-buffer';
|
||||||
|
|
||||||
export const logger = new plugins.smartlog.Smartlog({
|
// Map NODE_ENV to valid TEnvironment
|
||||||
|
const nodeEnv = process.env.NODE_ENV || 'production';
|
||||||
|
const envMap: Record<string, 'local' | 'test' | 'staging' | 'production'> = {
|
||||||
|
'development': 'local',
|
||||||
|
'test': 'test',
|
||||||
|
'staging': 'staging',
|
||||||
|
'production': 'production'
|
||||||
|
};
|
||||||
|
|
||||||
|
// In-memory log buffer for the OpsServer UI
|
||||||
|
export const logBuffer = new SmartlogDestinationBuffer({ maxEntries: 2000 });
|
||||||
|
|
||||||
|
// Default Smartlog instance (exported so OpsServer can add push destinations)
|
||||||
|
export const baseLogger = new plugins.smartlog.Smartlog({
|
||||||
logContext: {
|
logContext: {
|
||||||
environment: 'production',
|
environment: envMap[nodeEnv] || 'production',
|
||||||
runtime: 'node',
|
runtime: 'node',
|
||||||
zone: 'serve.zone',
|
zone: 'serve.zone',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wire the buffer destination so all logs are captured
|
||||||
|
baseLogger.addLogDestination(logBuffer);
|
||||||
|
|
||||||
|
// Extended logger compatible with the original enhanced logger API
|
||||||
|
class StandardLogger {
|
||||||
|
private defaultContext: Record<string, any> = {};
|
||||||
|
private correlationId: string | null = null;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
// Log methods
|
||||||
|
public log(level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context: Record<string, any> = {}) {
|
||||||
|
const combinedContext = {
|
||||||
|
...this.defaultContext,
|
||||||
|
...context
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.correlationId) {
|
||||||
|
combinedContext.correlation_id = this.correlationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
baseLogger.log(level, message, combinedContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
public error(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('error', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public warn(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('warn', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public info(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('info', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public success(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('success', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public debug(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('debug', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context management
|
||||||
|
public setContext(context: Record<string, any>, overwrite: boolean = false) {
|
||||||
|
if (overwrite) {
|
||||||
|
this.defaultContext = context;
|
||||||
|
} else {
|
||||||
|
this.defaultContext = {
|
||||||
|
...this.defaultContext,
|
||||||
|
...context
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correlation ID management
|
||||||
|
public setCorrelationId(id: string | null = null): string {
|
||||||
|
this.correlationId = id || randomUUID();
|
||||||
|
return this.correlationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCorrelationId(): string | null {
|
||||||
|
return this.correlationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearCorrelationId(): void {
|
||||||
|
this.correlationId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a singleton instance
|
||||||
|
export const logger = new StandardLogger();
|
||||||
|
|||||||
75
ts/monitoring/classes.metricscache.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
export interface ICacheEntry<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MetricsCache {
|
||||||
|
private cache = new Map<string, ICacheEntry<any>>();
|
||||||
|
private readonly defaultTTL: number;
|
||||||
|
|
||||||
|
constructor(defaultTTL: number = 500) {
|
||||||
|
this.defaultTTL = defaultTTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached data or compute and cache it
|
||||||
|
*/
|
||||||
|
public get<T>(key: string, computeFn: () => T | Promise<T>, ttl?: number): T | Promise<T> {
|
||||||
|
const cached = this.cache.get(key);
|
||||||
|
const now = Date.now();
|
||||||
|
const actualTTL = ttl ?? this.defaultTTL;
|
||||||
|
|
||||||
|
if (cached && (now - cached.timestamp) < actualTTL) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = computeFn();
|
||||||
|
|
||||||
|
// Handle both sync and async compute functions
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
return result.then(data => {
|
||||||
|
this.cache.set(key, { data, timestamp: now });
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.cache.set(key, { data: result, timestamp: now });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate a specific cache entry
|
||||||
|
*/
|
||||||
|
public invalidate(key: string): void {
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cache entries
|
||||||
|
*/
|
||||||
|
public clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics
|
||||||
|
*/
|
||||||
|
public getStats(): { size: number; keys: string[] } {
|
||||||
|
return {
|
||||||
|
size: this.cache.size,
|
||||||
|
keys: Array.from(this.cache.keys())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired entries
|
||||||
|
*/
|
||||||
|
public cleanup(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of this.cache.entries()) {
|
||||||
|
if (now - entry.timestamp > this.defaultTTL) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||