From 1a108fa8b716782a44826037f9900172c1657c68 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 2 Feb 2026 00:36:19 +0000 Subject: [PATCH] BREAKING CHANGE(deps): upgrade major dependencies, migrate action.target to action.targets (array), adapt to SmartRequest API changes, and add RADIUS server support --- .playwright-mcp/dcrouter-scrollbar-issue.png | Bin 0 -> 21866 bytes .../page-2026-02-01T23-10-23-737Z.png | Bin 0 -> 6134 bytes .../page-2026-02-01T23-11-19-449Z.png | Bin 0 -> 6134 bytes .../page-2026-02-01T23-12-03-126Z.png | Bin 0 -> 21866 bytes .../page-2026-02-01T23-12-15-576Z.png | Bin 0 -> 21866 bytes changelog.md | 5 + package.json | 18 +- pnpm-lock.yaml | 219 ++--- readme.hints.md | 136 ++- readme.md | 37 +- ts/00_commitinfo_data.ts | 2 +- ts/classes.dcrouter.ts | 4 +- ts/opsserver/classes.opsserver.ts | 2 + ts/opsserver/handlers/admin.handler.ts | 2 +- ts/opsserver/handlers/email-ops.handler.ts | 325 +++++++ ts/opsserver/handlers/index.ts | 3 +- ts/sms/classes.smsservice.ts | 4 +- ts_interfaces/requests/email-ops.ts | 239 +++++ ts_interfaces/requests/index.ts | 3 +- ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 266 +++++- ts_web/elements/ops-dashboard.ts | 70 +- ts_web/elements/ops-view-config.ts | 9 +- ts_web/elements/ops-view-emails.ts | 859 ++++++++++-------- ts_web/index.ts | 4 + ts_web/router.ts | 181 ++++ 26 files changed, 1808 insertions(+), 582 deletions(-) create mode 100644 .playwright-mcp/dcrouter-scrollbar-issue.png create mode 100644 .playwright-mcp/page-2026-02-01T23-10-23-737Z.png create mode 100644 .playwright-mcp/page-2026-02-01T23-11-19-449Z.png create mode 100644 .playwright-mcp/page-2026-02-01T23-12-03-126Z.png create mode 100644 .playwright-mcp/page-2026-02-01T23-12-15-576Z.png create mode 100644 ts/opsserver/handlers/email-ops.handler.ts create mode 100644 ts_interfaces/requests/email-ops.ts create mode 100644 ts_web/router.ts diff --git a/.playwright-mcp/dcrouter-scrollbar-issue.png b/.playwright-mcp/dcrouter-scrollbar-issue.png new file mode 100644 index 0000000000000000000000000000000000000000..923213c861a254b3e8e769ac1ce7c3352538e1ed GIT binary patch literal 21866 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 PAlG`j`njxgN@xNAx>_7; literal 0 HcmV?d00001 diff --git a/.playwright-mcp/page-2026-02-01T23-11-19-449Z.png b/.playwright-mcp/page-2026-02-01T23-11-19-449Z.png new file mode 100644 index 0000000000000000000000000000000000000000..6f4f4e84e3b06da40e42ca735ec975a61ef68e7b GIT binary patch literal 6134 zcmeAS@N?(olHy`uVBq!ia0y~yU~gbxV6os}0*a(>3=;uTjKx9jP7LeL$-HD>5Z~bG z;uumf=gmPzMxa!~gn#Sbae#D>g3%Bd4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=CVI2Yux0{)TKt6Q*AOBrBWLW!k)N`XDFd71*Aut*OqaiR9LSP5y*If*Z{LXCN PAlG`j`njxgN@xNAx>_7; literal 0 HcmV?d00001 diff --git a/.playwright-mcp/page-2026-02-01T23-12-03-126Z.png b/.playwright-mcp/page-2026-02-01T23-12-03-126Z.png new file mode 100644 index 0000000000000000000000000000000000000000..923213c861a254b3e8e769ac1ce7c3352538e1ed GIT binary patch literal 21866 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.0.0'} @@ -359,11 +362,8 @@ packages: '@configvault.io/interfaces@1.0.17': resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==} - '@design.estate/dees-catalog@1.12.4': - resolution: {integrity: sha512-tzNW3b1BQkbE7W2DcwFqXHy+igHzxMHoR+awCAE4/EzHl8kOJc4jF1RNyCe34LsuNaELgZ95Hde/HGgEC8JasA==} - - '@design.estate/dees-catalog@3.41.4': - resolution: {integrity: sha512-tVut61OuMF+o/1dzcKEvfom6sl8dMC5WzxIevN9jVq4sQgMO9DOrFd+UfKwRL9FfLZeqAFyxJDzU8389e4Pg0Q==} + '@design.estate/dees-catalog@3.41.5': + resolution: {integrity: sha512-2LOUh92h2ndzlEKOyDqGE2Mdjhmxt6ZeAqHt5KKslNHzmNhdWFKUe6C1Vm2nU6vRvFLXXC/ex56KSP3XcCPD8g==} '@design.estate/dees-comms@1.0.30': resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==} @@ -374,9 +374,6 @@ packages: '@design.estate/dees-element@2.1.6': resolution: {integrity: sha512-7zyHkUjB8UEQgT9VbB2IJtc/yuPt9CI5JGel3b6BxA1kecY64ceIjFvof1uIkc0QP8q2fMLLY45r1c+9zDTjzg==} - '@design.estate/dees-wcctools@1.3.0': - resolution: {integrity: sha512-+yd8c1gTIKNRQYCvG0xu6Am8dHsRm7ymluX2gnoBQN4aFOpZgIBi/v9CvGyPhTD1p/VRouIBz1wsUCejnwrFCA==} - '@design.estate/dees-wcctools@3.8.0': resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==} @@ -1066,8 +1063,8 @@ packages: '@push.rocks/smartpromise@4.2.3': resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} - '@push.rocks/smartproxy@19.6.17': - resolution: {integrity: sha512-5y6lVxlHXoVQXAQLr5S+2ifxZf9EID32twyeuZTS9tDyof0wJKppLzKQepwB7hfQXS2J06JBN7oa9n0mguELBg==} + '@push.rocks/smartproxy@22.4.2': + resolution: {integrity: sha512-JdDa1VxGOnWfF5HuJRvkX3/zHuIKz+IV9n/XOsNZQA9zMZdLVlWPqjGio9GLWsPOWA2l1YZKymjMH4ybPbGQtA==} '@push.rocks/smartpuppeteer@2.0.5': resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==} @@ -1876,6 +1873,10 @@ packages: '@types/minimatch@5.1.2': resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} + '@types/minimatch@6.0.0': + resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} + deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -1894,8 +1895,8 @@ packages: '@types/node@22.19.7': resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} - '@types/node@25.1.0': - resolution: {integrity: sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==} + '@types/node@25.2.0': + resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} '@types/pidusage@2.0.5': resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==} @@ -1969,9 +1970,6 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@webcontainer/api@1.2.0': - resolution: {integrity: sha512-tzoKBd4lLdhHy5GHFpUkl+ndoSba8JqmB7x0ZQFnWfjbcbQOvKQfxA8MEMUYhgqjWHnbrWdAfnBEHz5f5lYG5A==} - '@yr/monotone-cubic-spline@1.0.3': resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==} @@ -3097,9 +3095,6 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} - lucide@0.544.0: - resolution: {integrity: sha512-U5ORwr5z9Sx7bNTDFaW55RbjVdQEnAcT3vws9uz3vRT1G4XXJUDAhRZdxhFoIyHEvjmTkzzlEhjSLYM5n4mb5w==} - lucide@0.563.0: resolution: {integrity: sha512-2zBzDJ5n2Plj3d0ksj6h9TWPOSiKu9gtxJxnBAye11X/8gfWied6IYJn6ADYBp1NPoJmgpyOYP3wMrVx69+2AA==} @@ -3343,9 +3338,6 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - monaco-editor@0.52.2: - resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} - monaco-editor@0.55.1: resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} @@ -4206,8 +4198,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=} - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true uuid@9.0.1: @@ -4438,7 +4430,7 @@ snapshots: '@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedsocket': 4.1.0(@push.rocks/smartserve@2.0.1) '@cloudflare/workers-types': 4.20260131.0 - '@design.estate/dees-catalog': 3.41.4(@tiptap/pm@2.27.2) + '@design.estate/dees-catalog': 3.41.5(@tiptap/pm@2.27.2) '@design.estate/dees-comms': 1.0.30 '@push.rocks/lik': 6.2.2 '@push.rocks/smartdelay': 3.0.5 @@ -4485,7 +4477,7 @@ snapshots: '@push.rocks/isohash': 2.0.1 '@push.rocks/smartjson': 5.2.0 '@push.rocks/smartrx': 3.0.10 - '@push.rocks/smartsocket': 2.1.0(@push.rocks/smartserve@2.0.1) + '@push.rocks/smartsocket': 2.1.0 '@push.rocks/smartstring': 4.1.0 '@push.rocks/smarturl': 3.1.0 optionalDependencies: @@ -4523,6 +4515,18 @@ snapshots: transitivePeerDependencies: - encoding + '@apiclient.xyz/cloudflare@7.1.0': + dependencies: + '@push.rocks/smartdelay': 3.0.5 + '@push.rocks/smartlog': 3.1.10 + '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartrequest': 5.0.1 + '@push.rocks/smartstring': 4.1.0 + '@tsclass/tsclass': 9.3.0 + cloudflare: 5.2.0 + transitivePeerDependencies: + - encoding + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -5028,43 +5032,7 @@ snapshots: dependencies: '@api.global/typedrequest-interfaces': 3.0.19 - '@design.estate/dees-catalog@1.12.4(@tiptap/pm@2.27.2)': - dependencies: - '@design.estate/dees-domtools': 2.3.8 - '@design.estate/dees-element': 2.1.6 - '@design.estate/dees-wcctools': 1.3.0 - '@fortawesome/fontawesome-svg-core': 7.1.0 - '@fortawesome/free-brands-svg-icons': 7.1.0 - '@fortawesome/free-regular-svg-icons': 7.1.0 - '@fortawesome/free-solid-svg-icons': 7.1.0 - '@push.rocks/smarti18n': 1.0.4 - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smartstring': 4.1.0 - '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) - '@tiptap/extension-link': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2) - '@tiptap/extension-text-align': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) - '@tiptap/extension-typography': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) - '@tiptap/extension-underline': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) - '@tiptap/starter-kit': 2.27.2 - '@tsclass/tsclass': 9.3.0 - '@webcontainer/api': 1.2.0 - apexcharts: 5.3.6 - highlight.js: 11.11.1 - ibantools: 4.5.1 - lit: 3.3.2 - lucide: 0.544.0 - monaco-editor: 0.52.2 - pdfjs-dist: 4.10.38 - xterm: 5.3.0 - xterm-addon-fit: 0.8.0(xterm@5.3.0) - transitivePeerDependencies: - - '@nuxt/kit' - - '@tiptap/pm' - - react - - supports-color - - vue - - '@design.estate/dees-catalog@3.41.4(@tiptap/pm@2.27.2)': + '@design.estate/dees-catalog@3.41.5(@tiptap/pm@2.27.2)': dependencies: '@design.estate/dees-domtools': 2.3.8 '@design.estate/dees-element': 2.1.6 @@ -5144,18 +5112,6 @@ snapshots: - supports-color - vue - '@design.estate/dees-wcctools@1.3.0': - dependencies: - '@design.estate/dees-domtools': 2.3.8 - '@design.estate/dees-element': 2.1.6 - '@push.rocks/smartdelay': 3.0.5 - lit: 3.3.2 - transitivePeerDependencies: - - '@nuxt/kit' - - react - - supports-color - - vue - '@design.estate/dees-wcctools@3.8.0': dependencies: '@design.estate/dees-domtools': 2.3.8 @@ -5920,7 +5876,7 @@ snapshots: '@push.rocks/smartlog': 3.1.10 '@push.rocks/smartpath': 6.0.0 - '@push.rocks/smartacme@8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)': + '@push.rocks/smartacme@8.0.0(socks@2.8.7)': dependencies: '@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1) '@apiclient.xyz/cloudflare': 6.4.3 @@ -5942,7 +5898,6 @@ snapshots: - '@aws-sdk/credential-providers' - '@mongodb-js/zstd' - '@nuxt/kit' - - '@push.rocks/smartserve' - bare-abort-controller - encoding - gcp-metadata @@ -6454,22 +6409,22 @@ snapshots: '@push.rocks/smartpromise@4.2.3': {} - '@push.rocks/smartproxy@19.6.17(@push.rocks/smartserve@2.0.1)(socks@2.8.7)': + '@push.rocks/smartproxy@22.4.2(socks@2.8.7)': dependencies: '@push.rocks/lik': 6.2.2 - '@push.rocks/smartacme': 8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7) + '@push.rocks/smartacme': 8.0.0(socks@2.8.7) '@push.rocks/smartcrypto': 2.0.4 '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartfile': 11.2.7 + '@push.rocks/smartfile': 13.1.2 '@push.rocks/smartlog': 3.1.10 '@push.rocks/smartnetwork': 4.4.0 '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smartrequest': 2.1.0 + '@push.rocks/smartrequest': 5.0.1 '@push.rocks/smartrx': 3.0.10 '@push.rocks/smartstring': 4.1.0 '@push.rocks/taskbuffer': 3.5.0 '@tsclass/tsclass': 9.3.0 - '@types/minimatch': 5.1.2 + '@types/minimatch': 6.0.0 '@types/ws': 8.18.1 minimatch: 10.1.1 pretty-ms: 9.3.0 @@ -6478,7 +6433,6 @@ snapshots: - '@aws-sdk/credential-providers' - '@mongodb-js/zstd' - '@nuxt/kit' - - '@push.rocks/smartserve' - bare-abort-controller - bufferutil - encoding @@ -6592,7 +6546,7 @@ snapshots: '@push.rocks/webrequest': 4.0.1 '@tsclass/tsclass': 9.3.0 - '@push.rocks/smartsocket@2.1.0(@push.rocks/smartserve@2.0.1)': + '@push.rocks/smartsocket@2.1.0': dependencies: '@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1) @@ -6611,7 +6565,6 @@ snapshots: socket.io-client: 4.8.1 transitivePeerDependencies: - '@nuxt/kit' - - '@push.rocks/smartserve' - bufferutil - react - supports-color @@ -7462,27 +7415,27 @@ snapshots: '@types/bn.js@5.2.0': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/buffer-json@2.0.3': {} '@types/clean-css@4.2.11': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 source-map: 0.6.1 '@types/connect@3.4.38': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/cors@2.8.19': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/debug@4.1.12': dependencies: @@ -7490,7 +7443,7 @@ snapshots: '@types/dns-packet@5.6.5': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/elliptic@6.4.18': dependencies: @@ -7498,7 +7451,7 @@ snapshots: '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -7511,17 +7464,17 @@ snapshots: '@types/from2@2.3.6': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/glob@8.1.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/hast@3.0.4': dependencies: @@ -7543,18 +7496,18 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/linkify-it@5.0.0': {} '@types/mailparser@3.4.6': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 iconv-lite: 0.6.3 '@types/markdown-it@14.1.2': @@ -7572,20 +7525,24 @@ snapshots: '@types/minimatch@5.1.2': {} + '@types/minimatch@6.0.0': + dependencies: + minimatch: 10.1.1 + '@types/ms@2.1.0': {} '@types/mute-stream@0.0.4': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/node-fetch@2.6.13': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 form-data: 4.0.5 '@types/node-forge@1.3.14': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/node@18.19.130': dependencies: @@ -7595,7 +7552,7 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@25.1.0': + '@types/node@25.2.0': dependencies: undici-types: 7.16.0 @@ -7615,22 +7572,22 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/symbol-tree@3.2.5': {} '@types/tar-stream@3.1.4': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/through2@2.0.41': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/trusted-types@2.0.7': {} @@ -7656,17 +7613,15 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.2.0 optional: true '@ungap/structured-clone@1.3.0': {} - '@webcontainer/api@1.2.0': {} - '@yr/monotone-cubic-spline@1.0.3': {} '@zone-eu/mailsplit@5.4.8': @@ -8146,7 +8101,7 @@ snapshots: engine.io@6.6.4: dependencies: '@types/cors': 2.8.19 - '@types/node': 25.1.0 + '@types/node': 25.2.0 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -8918,8 +8873,6 @@ snapshots: lru-cache@7.18.3: {} - lucide@0.544.0: {} - lucide@0.563.0: {} mailauth@4.12.0: @@ -9352,8 +9305,6 @@ snapshots: mitt@3.0.1: {} - monaco-editor@0.52.2: {} - monaco-editor@0.55.1: dependencies: dompurify: 3.2.7 @@ -10372,7 +10323,7 @@ snapshots: util-deprecate@1.0.2: {} - uuid@11.1.0: {} + uuid@13.0.0: {} uuid@9.0.1: {} diff --git a/readme.hints.md b/readme.hints.md index 76f9e7a..506f299 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -1,5 +1,71 @@ # Implementation Hints and Learnings +## 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) + + // New + + + ``` + +### 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 @@ -1130,4 +1196,72 @@ The throughput was showing 0 because: 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. \ No newline at end of file +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 \ No newline at end of file diff --git a/readme.md b/readme.md index f7f9ab6..f38222e 100644 --- a/readme.md +++ b/readme.md @@ -40,6 +40,11 @@ A comprehensive traffic routing solution that provides unified gateway capabilit - **DKIM, SPF, DMARC** authentication and verification - **Enterprise deliverability** with IP warmup and reputation management +### 📡 **RADIUS Server** +- **MAC Authentication Bypass (MAB)** for network device authentication +- **VLAN assignment** based on MAC address or OUI patterns +- **RADIUS accounting** for session tracking and billing + ### ⚡ **High Performance** - **Connection pooling** and efficient resource management - **Load balancing** with automatic failover @@ -79,7 +84,7 @@ const router = new DcRouter({ match: { domains: ['example.com'], ports: [443] }, action: { type: 'forward', - target: { host: '192.168.1.10', port: 8080 }, + targets: [{ host: '192.168.1.10', port: 8080 }], tls: { mode: 'terminate', certificate: 'auto' } } } @@ -271,10 +276,10 @@ interface IRouteConfig { }; action: { type: 'forward' | 'redirect' | 'serve'; - target?: { + targets?: Array<{ host: string; port: number | 'preserve' | ((context: any) => number); - }; + }>; tls?: { mode: 'terminate' | 'passthrough'; certificate?: 'auto' | string; @@ -640,15 +645,7 @@ const routes = [ }, action: { type: 'forward', - target: { - host: '192.168.1.20', - port: (context) => { - // Route based on path - if (context.path.startsWith('/v1/')) return 8080; - if (context.path.startsWith('/v2/')) return 8081; - return 8080; - } - }, + targets: [{ host: '192.168.1.20', port: 8080 }], tls: { mode: 'terminate', certificate: 'auto' @@ -687,10 +684,7 @@ const tcpRoutes = [ }, action: { type: 'forward', - target: { - host: '192.168.1.30', - port: 'preserve' - }, + targets: [{ host: '192.168.1.30', port: 'preserve' }], security: { ipAllowList: ['192.168.1.0/24'] } @@ -706,10 +700,7 @@ const tcpRoutes = [ }, action: { type: 'forward', - target: { - host: '192.168.1.40', - port: 8443 - }, + targets: [{ host: '192.168.1.40', port: 8443 }], tls: { mode: 'passthrough' } @@ -893,7 +884,7 @@ const router = new DcRouter({ match: { domains: ['example.com', 'www.example.com'], ports: [443] }, action: { type: 'forward', - target: { host: '192.168.1.10', port: 80 }, + targets: [{ host: '192.168.1.10', port: 80 }], tls: { mode: 'terminate', certificate: 'auto' } } }, @@ -905,7 +896,7 @@ const router = new DcRouter({ match: { domains: ['api.example.com'], ports: [443] }, action: { type: 'forward', - target: { host: '192.168.1.20', port: 8080 }, + targets: [{ host: '192.168.1.20', port: 8080 }], tls: { mode: 'terminate', certificate: 'auto' } } }, @@ -916,7 +907,7 @@ const router = new DcRouter({ match: { ports: [{ from: 8000, to: 8999 }] }, action: { type: 'forward', - target: { host: '192.168.1.30', port: 'preserve' }, + targets: [{ host: '192.168.1.30', port: 'preserve' }], security: { ipAllowList: ['192.168.0.0/16'] } } } diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index ad8af5e..d95dd61 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '2.13.0', + version: '3.0.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 200d7b6..8651a5c 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -517,10 +517,10 @@ export class DcRouter { }, action: { type: 'forward', - target: route.action.type === 'forward' && route.action.forward ? { + targets: route.action.type === 'forward' && route.action.forward ? [{ host: route.action.forward.host, port: route.action.forward.port || 25 - } : undefined, + }] : undefined, tls: { mode: 'passthrough' } diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts index 6bd762c..0906bd0 100644 --- a/ts/opsserver/classes.opsserver.ts +++ b/ts/opsserver/classes.opsserver.ts @@ -17,6 +17,7 @@ export class OpsServer { private securityHandler: handlers.SecurityHandler; private statsHandler: handlers.StatsHandler; private radiusHandler: handlers.RadiusHandler; + private emailOpsHandler: handlers.EmailOpsHandler; constructor(dcRouterRefArg: DcRouter) { this.dcRouterRef = dcRouterRefArg; @@ -55,6 +56,7 @@ export class OpsServer { this.securityHandler = new handlers.SecurityHandler(this); this.statsHandler = new handlers.StatsHandler(this); this.radiusHandler = new handlers.RadiusHandler(this); + this.emailOpsHandler = new handlers.EmailOpsHandler(this); console.log('✅ OpsServer TypedRequest handlers initialized'); } diff --git a/ts/opsserver/handlers/admin.handler.ts b/ts/opsserver/handlers/admin.handler.ts index aa8692d..e93c080 100644 --- a/ts/opsserver/handlers/admin.handler.ts +++ b/ts/opsserver/handlers/admin.handler.ts @@ -73,7 +73,7 @@ export class AdminHandler { throw new plugins.typedrequest.TypedResponseError('login failed'); } - const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24 * 7; // 7 days + const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours const jwt = await this.smartjwtInstance.createJWT({ userId: user.id, diff --git a/ts/opsserver/handlers/email-ops.handler.ts b/ts/opsserver/handlers/email-ops.handler.ts new file mode 100644 index 0000000..bbe11d5 --- /dev/null +++ b/ts/opsserver/handlers/email-ops.handler.ts @@ -0,0 +1,325 @@ +import * as plugins from '../../plugins.js'; +import type { OpsServer } from '../classes.opsserver.js'; +import * as interfaces from '../../../ts_interfaces/index.js'; +import { SecurityLogger } from '../../security/index.js'; + +export class EmailOpsHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + // Add this handler's router to the parent + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private registerHandlers(): void { + // Get Queued Emails Handler + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getQueuedEmails', + async (dataArg) => { + const emailServer = this.opsServerRef.dcRouterRef.emailServer; + if (!emailServer?.deliveryQueue) { + return { items: [], total: 0 }; + } + + const queue = emailServer.deliveryQueue; + const stats = queue.getStats(); + + // Get all queue items and filter by status if provided + const items = this.getQueueItems( + dataArg.status, + dataArg.limit || 50, + dataArg.offset || 0 + ); + + return { + items, + total: stats.queueSize, + }; + } + ) + ); + + // Get Sent Emails Handler + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getSentEmails', + async (dataArg) => { + const items = this.getQueueItems( + 'delivered', + dataArg.limit || 50, + dataArg.offset || 0 + ); + + return { + items, + total: items.length, // Note: total would ideally come from a counter + }; + } + ) + ); + + // Get Failed Emails Handler + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getFailedEmails', + async (dataArg) => { + const items = this.getQueueItems( + 'failed', + dataArg.limit || 50, + dataArg.offset || 0 + ); + + return { + items, + total: items.length, + }; + } + ) + ); + + // Resend Failed Email Handler + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'resendEmail', + async (dataArg) => { + const emailServer = this.opsServerRef.dcRouterRef.emailServer; + if (!emailServer?.deliveryQueue) { + return { success: false, error: 'Email server not available' }; + } + + const queue = emailServer.deliveryQueue; + const item = queue.getItem(dataArg.emailId); + + if (!item) { + return { success: false, error: 'Email not found in queue' }; + } + + if (item.status !== 'failed') { + return { success: false, error: `Email is not in failed state (current: ${item.status})` }; + } + + try { + // Re-enqueue the failed email by creating a new queue entry + // with the same data but reset attempt count + const newQueueId = await queue.enqueue( + item.processingResult, + item.processingMode, + item.route + ); + + // Optionally remove the old failed entry + await queue.removeItem(dataArg.emailId); + + return { success: true, newQueueId }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to resend email' + }; + } + } + ) + ); + + // Get Security Incidents Handler + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getSecurityIncidents', + async (dataArg) => { + const securityLogger = SecurityLogger.getInstance(); + + const filter: { + level?: any; + type?: any; + } = {}; + + if (dataArg.level) { + filter.level = dataArg.level; + } + + if (dataArg.type) { + filter.type = dataArg.type; + } + + const incidents = securityLogger.getRecentEvents( + dataArg.limit || 100, + Object.keys(filter).length > 0 ? filter : undefined + ); + + return { + incidents: incidents.map(event => ({ + timestamp: event.timestamp, + level: event.level as interfaces.requests.TSecurityLogLevel, + type: event.type as interfaces.requests.TSecurityEventType, + message: event.message, + details: event.details, + ipAddress: event.ipAddress, + userId: event.userId, + sessionId: event.sessionId, + emailId: event.emailId, + domain: event.domain, + action: event.action, + result: event.result, + success: event.success, + })), + total: incidents.length, + }; + } + ) + ); + + // Get Bounce Records Handler + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getBounceRecords', + async (dataArg) => { + const emailServer = this.opsServerRef.dcRouterRef.emailServer; + + // Get bounce manager from email server via reflection + // BounceManager is private but we need to access it + const bounceManager = (emailServer as any)?.bounceManager; + + if (!bounceManager) { + return { records: [], suppressionList: [], total: 0 }; + } + + // Get suppression list + const suppressionList = bounceManager.getSuppressionList(); + + // Get hard bounced addresses and convert to records + const hardBouncedAddresses = bounceManager.getHardBouncedAddresses(); + + // Create bounce records from the available data + const records: interfaces.requests.IBounceRecord[] = []; + + for (const email of hardBouncedAddresses) { + const bounceInfo = bounceManager.getBounceInfo(email); + if (bounceInfo) { + records.push({ + id: `bounce-${email}`, + recipient: email, + sender: '', + domain: email.split('@')[1] || '', + bounceType: bounceInfo.type as interfaces.requests.TBounceType, + bounceCategory: bounceInfo.category as interfaces.requests.TBounceCategory, + timestamp: bounceInfo.lastBounce, + processed: true, + }); + } + } + + // Apply limit and offset + const limit = dataArg.limit || 50; + const offset = dataArg.offset || 0; + const paginatedRecords = records.slice(offset, offset + limit); + + return { + records: paginatedRecords, + suppressionList, + total: records.length, + }; + } + ) + ); + + // Remove from Suppression List Handler + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'removeFromSuppressionList', + async (dataArg) => { + const emailServer = this.opsServerRef.dcRouterRef.emailServer; + const bounceManager = (emailServer as any)?.bounceManager; + + if (!bounceManager) { + return { success: false, error: 'Bounce manager not available' }; + } + + try { + bounceManager.removeFromSuppressionList(dataArg.email); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to remove from suppression list' + }; + } + } + ) + ); + } + + /** + * Helper method to get queue items with filtering and pagination + */ + private getQueueItems( + status?: interfaces.requests.TEmailQueueStatus, + limit: number = 50, + offset: number = 0 + ): interfaces.requests.IEmailQueueItem[] { + const emailServer = this.opsServerRef.dcRouterRef.emailServer; + if (!emailServer?.deliveryQueue) { + return []; + } + + const queue = emailServer.deliveryQueue; + const items: interfaces.requests.IEmailQueueItem[] = []; + + // Access the internal queue map via reflection + // This is necessary because the queue doesn't expose iteration methods + const queueMap = (queue as any).queue as Map; + + if (!queueMap) { + return []; + } + + // Filter and convert items + for (const [id, item] of queueMap.entries()) { + // Apply status filter if provided + if (status && item.status !== status) { + continue; + } + + // Extract email details from processingResult if available + const processingResult = item.processingResult; + let from = ''; + let to: string[] = []; + let subject = ''; + + if (processingResult) { + // Check if it's an Email object or raw email data + if (processingResult.email) { + from = processingResult.email.from || ''; + to = processingResult.email.to || []; + subject = processingResult.email.subject || ''; + } else if (processingResult.from) { + from = processingResult.from; + to = processingResult.to || []; + subject = processingResult.subject || ''; + } + } + + items.push({ + id: item.id, + processingMode: item.processingMode, + status: item.status, + attempts: item.attempts, + nextAttempt: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : item.nextAttempt, + lastError: item.lastError, + createdAt: item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt, + updatedAt: item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt, + deliveredAt: item.deliveredAt instanceof Date ? item.deliveredAt.getTime() : item.deliveredAt, + from, + to, + subject, + }); + } + + // Sort by createdAt descending (newest first) + items.sort((a, b) => b.createdAt - a.createdAt); + + // Apply pagination + return items.slice(offset, offset + limit); + } +} diff --git a/ts/opsserver/handlers/index.ts b/ts/opsserver/handlers/index.ts index fb72c50..43ef784 100644 --- a/ts/opsserver/handlers/index.ts +++ b/ts/opsserver/handlers/index.ts @@ -3,4 +3,5 @@ export * from './config.handler.js'; export * from './logs.handler.js'; export * from './security.handler.js'; export * from './stats.handler.js'; -export * from './radius.handler.js'; \ No newline at end of file +export * from './radius.handler.js'; +export * from './email-ops.handler.js'; \ No newline at end of file diff --git a/ts/sms/classes.smsservice.ts b/ts/sms/classes.smsservice.ts index fcee9f0..80860eb 100644 --- a/ts/sms/classes.smsservice.ts +++ b/ts/sms/classes.smsservice.ts @@ -68,13 +68,13 @@ export class SmsService { recipients: [{ msisdn: toNumber }], }; - const resp = await plugins.smartrequest.SmartRequestClient.create() + const resp = await plugins.smartrequest.SmartRequest.create() .url('https://gatewayapi.com/rest/mtsms') .header('Authorization', `Basic ${Buffer.from(`${this.config.apiGatewayApiToken}:`).toString('base64')}`) .header('Content-Type', 'application/json') .json(payload) .post(); - const json = resp.body; + const json = await resp.json(); logger.log('info', `sent an sms to ${toNumber} with text '${messageText}'`, { eventType: 'sentSms', sms: { diff --git a/ts_interfaces/requests/email-ops.ts b/ts_interfaces/requests/email-ops.ts new file mode 100644 index 0000000..6591e9d --- /dev/null +++ b/ts_interfaces/requests/email-ops.ts @@ -0,0 +1,239 @@ +import * as plugins from '../plugins.js'; +import * as authInterfaces from '../data/auth.js'; + +// ============================================================================ +// Email Queue Item Interface (matches backend IQueueItem) +// ============================================================================ +export type TEmailQueueStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred'; + +export interface IEmailQueueItem { + id: string; + processingMode: 'forward' | 'mta' | 'process'; + status: TEmailQueueStatus; + attempts: number; + nextAttempt: number; // timestamp + lastError?: string; + createdAt: number; // timestamp + updatedAt: number; // timestamp + deliveredAt?: number; // timestamp + // Email details extracted from processingResult + from?: string; + to?: string[]; + subject?: string; +} + +// ============================================================================ +// Bounce Record Interface (matches backend BounceRecord) +// ============================================================================ +export type TBounceType = + | 'invalid_recipient' + | 'domain_not_found' + | 'mailbox_full' + | 'mailbox_inactive' + | 'blocked' + | 'spam_related' + | 'policy_related' + | 'server_unavailable' + | 'temporary_failure' + | 'quota_exceeded' + | 'network_error' + | 'timeout' + | 'auto_response' + | 'challenge_response' + | 'unknown'; + +export type TBounceCategory = 'hard' | 'soft' | 'auto_response' | 'unknown'; + +export interface IBounceRecord { + id: string; + originalEmailId?: string; + recipient: string; + sender: string; + domain: string; + subject?: string; + bounceType: TBounceType; + bounceCategory: TBounceCategory; + timestamp: number; + smtpResponse?: string; + diagnosticCode?: string; + statusCode?: string; + processed: boolean; + retryCount?: number; + nextRetryTime?: number; +} + +// ============================================================================ +// Security Incident Interface (matches backend ISecurityEvent) +// ============================================================================ +export type TSecurityLogLevel = 'info' | 'warn' | 'error' | 'critical'; + +export type TSecurityEventType = + | 'authentication' + | 'access_control' + | 'email_validation' + | 'email_processing' + | 'email_forwarding' + | 'email_delivery' + | 'dkim' + | 'spf' + | 'dmarc' + | 'rate_limit' + | 'rate_limiting' + | 'spam' + | 'malware' + | 'connection' + | 'data_exposure' + | 'configuration' + | 'ip_reputation' + | 'rejected_connection'; + +export interface ISecurityIncident { + timestamp: number; + level: TSecurityLogLevel; + type: TSecurityEventType; + message: string; + details?: any; + ipAddress?: string; + userId?: string; + sessionId?: string; + emailId?: string; + domain?: string; + action?: string; + result?: string; + success?: boolean; +} + +// ============================================================================ +// Get Queued Emails Request +// ============================================================================ +export interface IReq_GetQueuedEmails extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetQueuedEmails +> { + method: 'getQueuedEmails'; + request: { + identity?: authInterfaces.IIdentity; + status?: TEmailQueueStatus; + limit?: number; + offset?: number; + }; + response: { + items: IEmailQueueItem[]; + total: number; + }; +} + +// ============================================================================ +// Get Sent Emails Request +// ============================================================================ +export interface IReq_GetSentEmails extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetSentEmails +> { + method: 'getSentEmails'; + request: { + identity?: authInterfaces.IIdentity; + limit?: number; + offset?: number; + }; + response: { + items: IEmailQueueItem[]; + total: number; + }; +} + +// ============================================================================ +// Get Failed Emails Request +// ============================================================================ +export interface IReq_GetFailedEmails extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetFailedEmails +> { + method: 'getFailedEmails'; + request: { + identity?: authInterfaces.IIdentity; + limit?: number; + offset?: number; + }; + response: { + items: IEmailQueueItem[]; + total: number; + }; +} + +// ============================================================================ +// Resend Failed Email Request +// ============================================================================ +export interface IReq_ResendEmail extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ResendEmail +> { + method: 'resendEmail'; + request: { + identity?: authInterfaces.IIdentity; + emailId: string; + }; + response: { + success: boolean; + newQueueId?: string; + error?: string; + }; +} + +// ============================================================================ +// Get Security Incidents Request +// ============================================================================ +export interface IReq_GetSecurityIncidents extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetSecurityIncidents +> { + method: 'getSecurityIncidents'; + request: { + identity?: authInterfaces.IIdentity; + type?: TSecurityEventType; + level?: TSecurityLogLevel; + limit?: number; + }; + response: { + incidents: ISecurityIncident[]; + total: number; + }; +} + +// ============================================================================ +// Get Bounce Records Request +// ============================================================================ +export interface IReq_GetBounceRecords extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetBounceRecords +> { + method: 'getBounceRecords'; + request: { + identity?: authInterfaces.IIdentity; + limit?: number; + offset?: number; + }; + response: { + records: IBounceRecord[]; + suppressionList: string[]; + total: number; + }; +} + +// ============================================================================ +// Remove from Suppression List Request +// ============================================================================ +export interface IReq_RemoveFromSuppressionList extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_RemoveFromSuppressionList +> { + method: 'removeFromSuppressionList'; + request: { + identity?: authInterfaces.IIdentity; + email: string; + }; + response: { + success: boolean; + error?: string; + }; +} diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index 93812e4..567e306 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -3,4 +3,5 @@ export * from './config.js'; export * from './logs.js'; export * from './stats.js'; export * from './combined.stats.js'; -export * from './radius.js'; \ No newline at end of file +export * from './radius.js'; +export * from './email-ops.js'; \ No newline at end of file diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index ad8af5e..d95dd61 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '2.13.0', + version: '3.0.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index c368bf0..3ad0331 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -53,6 +53,20 @@ export interface INetworkState { error: string | null; } +export interface IEmailOpsState { + currentView: 'queued' | 'sent' | 'failed' | 'received' | 'security'; + queuedEmails: interfaces.requests.IEmailQueueItem[]; + sentEmails: interfaces.requests.IEmailQueueItem[]; + failedEmails: interfaces.requests.IEmailQueueItem[]; + securityIncidents: interfaces.requests.ISecurityIncident[]; + bounceRecords: interfaces.requests.IBounceRecord[]; + suppressionList: string[]; + selectedEmailId: string | null; + isLoading: boolean; + error: string | null; + lastUpdated: number; +} + // Create state parts with appropriate persistence export const loginStatePart = await appState.getStatePart( 'login', @@ -60,7 +74,7 @@ export const loginStatePart = await appState.getStatePart( identity: null, isLoggedIn: false, }, - 'soft' // Login state persists across sessions + 'persistent' // Login state persists across browser sessions ); export const statsStatePart = await appState.getStatePart( @@ -121,6 +135,24 @@ export const networkStatePart = await appState.getStatePart( 'soft' ); +export const emailOpsStatePart = await appState.getStatePart( + 'emailOps', + { + currentView: 'queued', + queuedEmails: [], + sentEmails: [], + failedEmails: [], + securityIncidents: [], + bounceRecords: [], + suppressionList: [], + selectedEmailId: null, + isLoading: false, + error: null, + lastUpdated: 0, + }, + 'soft' +); + // Actions for state management interface IActionContext { identity: interfaces.data.IIdentity | null; @@ -397,6 +429,238 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat } }); +// ============================================================================ +// Email Operations Actions +// ============================================================================ + +// Set Email Ops View Action +export const setEmailOpsViewAction = emailOpsStatePart.createAction( + async (statePartArg, view) => { + return { + ...statePartArg.getState(), + currentView: view, + }; + } +); + +// Fetch Queued Emails Action +export const fetchQueuedEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetQueuedEmails + >('/typedrequest', 'getQueuedEmails'); + + const response = await request.fire({ + identity: context.identity, + status: 'pending', + limit: 100, + }); + + return { + ...currentState, + queuedEmails: response.items, + isLoading: false, + error: null, + lastUpdated: Date.now(), + }; + } catch (error) { + return { + ...currentState, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch queued emails', + }; + } +}); + +// Fetch Sent Emails Action +export const fetchSentEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetSentEmails + >('/typedrequest', 'getSentEmails'); + + const response = await request.fire({ + identity: context.identity, + limit: 100, + }); + + return { + ...currentState, + sentEmails: response.items, + isLoading: false, + error: null, + lastUpdated: Date.now(), + }; + } catch (error) { + return { + ...currentState, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch sent emails', + }; + } +}); + +// Fetch Failed Emails Action +export const fetchFailedEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetFailedEmails + >('/typedrequest', 'getFailedEmails'); + + const response = await request.fire({ + identity: context.identity, + limit: 100, + }); + + return { + ...currentState, + failedEmails: response.items, + isLoading: false, + error: null, + lastUpdated: Date.now(), + }; + } catch (error) { + return { + ...currentState, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch failed emails', + }; + } +}); + +// Fetch Security Incidents Action +export const fetchSecurityIncidentsAction = emailOpsStatePart.createAction(async (statePartArg) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetSecurityIncidents + >('/typedrequest', 'getSecurityIncidents'); + + const response = await request.fire({ + identity: context.identity, + limit: 100, + }); + + return { + ...currentState, + securityIncidents: response.incidents, + isLoading: false, + error: null, + lastUpdated: Date.now(), + }; + } catch (error) { + return { + ...currentState, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch security incidents', + }; + } +}); + +// Fetch Bounce Records Action +export const fetchBounceRecordsAction = emailOpsStatePart.createAction(async (statePartArg) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetBounceRecords + >('/typedrequest', 'getBounceRecords'); + + const response = await request.fire({ + identity: context.identity, + limit: 100, + }); + + return { + ...currentState, + bounceRecords: response.records, + suppressionList: response.suppressionList, + isLoading: false, + error: null, + lastUpdated: Date.now(), + }; + } catch (error) { + return { + ...currentState, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch bounce records', + }; + } +}); + +// Resend Failed Email Action +export const resendEmailAction = emailOpsStatePart.createAction(async (statePartArg, emailId) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ResendEmail + >('/typedrequest', 'resendEmail'); + + const response = await request.fire({ + identity: context.identity, + emailId, + }); + + if (response.success) { + // Refresh failed emails list + await emailOpsStatePart.dispatchAction(fetchFailedEmailsAction, null); + await emailOpsStatePart.dispatchAction(fetchQueuedEmailsAction, null); + } + + return statePartArg.getState(); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to resend email', + }; + } +}); + +// Remove from Suppression List Action +export const removeFromSuppressionListAction = emailOpsStatePart.createAction( + async (statePartArg, email) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_RemoveFromSuppressionList + >('/typedrequest', 'removeFromSuppressionList'); + + const response = await request.fire({ + identity: context.identity, + email, + }); + + if (response.success) { + // Refresh bounce records + await emailOpsStatePart.dispatchAction(fetchBounceRecordsAction, null); + } + + return statePartArg.getState(); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to remove from suppression list', + }; + } + } +); + // Combined refresh action for efficient polling async function dispatchCombinedRefreshAction() { const context = getActionContext(); diff --git a/ts_web/elements/ops-dashboard.ts b/ts_web/elements/ops-dashboard.ts index 7292d04..aa16dc4 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -1,5 +1,6 @@ import * as plugins from '../plugins.js'; import * as appstate from '../appstate.js'; +import { appRouter } from '../router.js'; import { DeesElement, @@ -84,17 +85,55 @@ export class OpsDashboard extends DeesElement { .select((stateArg) => stateArg) .subscribe((uiState) => { this.uiState = uiState; + // Sync appdash view when state changes (e.g., from URL navigation) + this.syncAppdashView(uiState.activeView); }); this.rxSubscriptions.push(uiSubscription); } + /** + * Sync the dees-simple-appdash view selection with the current state. + * This is needed when the URL changes and we need to update the UI. + */ + private syncAppdashView(viewName: string): void { + const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any; + if (!appDash) return; + + const targetTab = this.viewTabs.find(t => t.name.toLowerCase() === viewName); + if (!targetTab) return; + + // Check if we need to switch (avoid unnecessary updates) + if (appDash.selectedView === targetTab) return; + + // Update the selected view programmatically + appDash.selectedView = targetTab; + + // Update the displayed content + const content = appDash.shadowRoot?.querySelector('.appcontent'); + if (content) { + if (appDash.currentView) { + appDash.currentView.remove(); + } + const view = new targetTab.element(); + content.appendChild(view); + appDash.currentView = view; + } + } + public static styles = [ cssManager.defaultStyles, css` + :host { + display: block; + width: 100%; + height: 100vh; + overflow: hidden; + } + .maincontainer { position: relative; - width: 100vw; - height: 100vh; + width: 100%; + height: 100%; } `, ]; @@ -126,24 +165,31 @@ export class OpsDashboard extends DeesElement { const appDash = this.shadowRoot.querySelector('dees-simple-appdash'); if (appDash) { appDash.addEventListener('view-select', (e: CustomEvent) => { - const viewName = e.detail.view.name; - appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, viewName.toLowerCase()); + const viewName = e.detail.view.name.toLowerCase(); + // Use router for navigation instead of direct state update + appRouter.navigateToView(viewName); }); - + // Handle logout event appDash.addEventListener('logout', async () => { await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); }); } - // Handle initial state + // Handle initial state - check if we have a stored session that's still valid const loginState = appstate.loginStatePart.getState(); - // Check initial login state - if (loginState.identity) { - this.loginState = loginState; - await simpleLogin.switchToSlottedContent(); - await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null); - await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null); + if (loginState.identity?.jwt) { + // Verify JWT hasn't expired + if (loginState.identity.expiresAt > Date.now()) { + // JWT still valid, restore logged-in state + this.loginState = loginState; + await simpleLogin.switchToSlottedContent(); + await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null); + await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null); + } else { + // JWT expired, clear the stored state + await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); + } } } diff --git a/ts_web/elements/ops-view-config.ts b/ts_web/elements/ops-view-config.ts index 5f5bf87..00d6f22 100644 --- a/ts_web/elements/ops-view-config.ts +++ b/ts_web/elements/ops-view-config.ts @@ -155,11 +155,10 @@ export class OpsViewConfig extends DeesElement { Changes to configuration will take effect immediately. Please be careful when editing production settings. - ${this.renderConfigSection('server', 'Server Configuration', this.configState.config.server)} - ${this.renderConfigSection('email', 'Email Configuration', this.configState.config.email)} - ${this.renderConfigSection('dns', 'DNS Configuration', this.configState.config.dns)} - ${this.renderConfigSection('security', 'Security Configuration', this.configState.config.security)} - ${this.renderConfigSection('storage', 'Storage Configuration', this.configState.config.storage)} + ${this.renderConfigSection('email', 'Email Configuration', this.configState.config?.email)} + ${this.renderConfigSection('dns', 'DNS Configuration', this.configState.config?.dns)} + ${this.renderConfigSection('proxy', 'Proxy Configuration', this.configState.config?.proxy)} + ${this.renderConfigSection('security', 'Security Configuration', this.configState.config?.security)} ` : html`
No configuration loaded
`} diff --git a/ts_web/elements/ops-view-emails.ts b/ts_web/elements/ops-view-emails.ts index 10826f9..b0124d6 100644 --- a/ts_web/elements/ops-view-emails.ts +++ b/ts_web/elements/ops-view-emails.ts @@ -1,6 +1,8 @@ import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element'; import * as appstate from '../appstate.js'; import * as shared from './shared/index.js'; +import * as interfaces from '../../dist_ts_interfaces/index.js'; +import { appRouter } from '../router.js'; declare global { interface HTMLElementTagNameMap { @@ -8,38 +10,30 @@ declare global { } } -interface IEmail { - id: string; - from: string; - to: string[]; - cc?: string[]; - bcc?: string[]; - subject: string; - body: string; - html?: string; - attachments?: Array<{ - filename: string; - size: number; - contentType: string; - }>; - date: number; - read: boolean; - folder: 'inbox' | 'sent' | 'draft' | 'trash'; - flags?: string[]; - messageId?: string; - inReplyTo?: string; -} +type TEmailFolder = 'queued' | 'sent' | 'failed' | 'received' | 'security'; @customElement('ops-view-emails') export class OpsViewEmails extends DeesElement { @state() - accessor selectedFolder: 'inbox' | 'sent' | 'draft' | 'trash' = 'inbox'; + accessor selectedFolder: TEmailFolder = 'queued'; @state() - accessor emails: IEmail[] = []; + accessor queuedEmails: interfaces.requests.IEmailQueueItem[] = []; @state() - accessor selectedEmail: IEmail | null = null; + accessor sentEmails: interfaces.requests.IEmailQueueItem[] = []; + + @state() + accessor failedEmails: interfaces.requests.IEmailQueueItem[] = []; + + @state() + accessor securityIncidents: interfaces.requests.ISecurityIncident[] = []; + + @state() + accessor selectedEmail: interfaces.requests.IEmailQueueItem | null = null; + + @state() + accessor selectedIncident: interfaces.requests.ISecurityIncident | null = null; @state() accessor showCompose = false; @@ -53,12 +47,39 @@ export class OpsViewEmails extends DeesElement { @state() accessor emailDomains: string[] = []; + private stateSubscription: any; + constructor() { super(); - this.loadEmails(); + this.loadData(); this.loadEmailDomains(); } + async connectedCallback() { + await super.connectedCallback(); + // Subscribe to state changes + this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => { + this.queuedEmails = state.queuedEmails; + this.sentEmails = state.sentEmails; + this.failedEmails = state.failedEmails; + this.securityIncidents = state.securityIncidents; + this.isLoading = state.isLoading; + + // Sync folder from state (e.g., when URL changes) + if (state.currentView !== this.selectedFolder) { + this.selectedFolder = state.currentView as TEmailFolder; + this.loadFolderData(state.currentView as TEmailFolder); + } + }); + } + + async disconnectedCallback() { + await super.disconnectedCallback(); + if (this.stateSubscription) { + this.stateSubscription.unsubscribe(); + } + } + public static styles = [ cssManager.defaultStyles, shared.viewHostCss, @@ -143,7 +164,7 @@ export class OpsViewEmails extends DeesElement { .emailMetaLabel { font-weight: 600; - min-width: 60px; + min-width: 80px; } .emailBody { @@ -167,7 +188,7 @@ export class OpsViewEmails extends DeesElement { flex-direction: column; align-items: center; justify-content: center; - height: 100%; + height: 400px; color: ${cssManager.bdTheme('#999', '#666')}; } @@ -181,278 +202,445 @@ export class OpsViewEmails extends DeesElement { font-size: 18px; } - .email-read { - color: ${cssManager.bdTheme('#999', '#666')}; + .status-pending { + color: ${cssManager.bdTheme('#f59e0b', '#fbbf24')}; } - .email-unread { - color: ${cssManager.bdTheme('#1976d2', '#4a90e2')}; + .status-processing { + color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')}; } - .attachment-icon { + .status-delivered { + color: ${cssManager.bdTheme('#10b981', '#34d399')}; + } + + .status-failed { + color: ${cssManager.bdTheme('#ef4444', '#f87171')}; + } + + .status-deferred { + color: ${cssManager.bdTheme('#f97316', '#fb923c')}; + } + + .severity-info { + color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')}; + } + + .severity-warn { + color: ${cssManager.bdTheme('#f59e0b', '#fbbf24')}; + } + + .severity-error { + color: ${cssManager.bdTheme('#ef4444', '#f87171')}; + } + + .severity-critical { + color: ${cssManager.bdTheme('#dc2626', '#ef4444')}; + font-weight: bold; + } + + .incidentDetails { + padding: 24px; + background: ${cssManager.bdTheme('#fff', '#222')}; + border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')}; + border-radius: 8px; + } + + .incidentHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; + } + + .incidentTitle { + font-size: 20px; + font-weight: 600; + } + + .incidentMeta { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + margin-top: 16px; + } + + .incidentField { + padding: 12px; + background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')}; + border-radius: 6px; + } + + .incidentFieldLabel { + font-size: 12px; color: ${cssManager.bdTheme('#666', '#999')}; + margin-bottom: 4px; + } + + .incidentFieldValue { + font-size: 14px; + word-break: break-all; } `, ]; public render() { if (this.selectedEmail) { - return html` - Emails -
- -
- ${this.renderEmailPreview()} -
-
- `; + return this.renderEmailDetail(); + } + + if (this.selectedIncident) { + return this.renderIncidentDetail(); } return html` - Emails - + Email Operations +
this.openComposeModal()} type="highlighted"> - + Compose this.searchTerm = (e.target as any).value} > - + - this.refreshEmails()}> - ${this.isLoading ? html`` : html``} + this.refreshData()}> + ${this.isLoading ? html`` : html``} Refresh - this.markAllAsRead()}> - - Mark all read - -
- this.selectFolder('inbox')} - .type=${this.selectedFolder === 'inbox' ? 'highlighted' : 'normal'} + this.selectFolder('queued')} + .type=${this.selectedFolder === 'queued' ? 'highlighted' : 'normal'} > - Inbox ${this.getEmailCount('inbox') > 0 ? `(${this.getEmailCount('inbox')})` : ''} + Queued ${this.queuedEmails.length > 0 ? `(${this.queuedEmails.length})` : ''} - this.selectFolder('sent')} .type=${this.selectedFolder === 'sent' ? 'highlighted' : 'normal'} > Sent - this.selectFolder('draft')} - .type=${this.selectedFolder === 'draft' ? 'highlighted' : 'normal'} + this.selectFolder('failed')} + .type=${this.selectedFolder === 'failed' ? 'highlighted' : 'normal'} > - Drafts ${this.getEmailCount('draft') > 0 ? `(${this.getEmailCount('draft')})` : ''} + Failed ${this.failedEmails.length > 0 ? `(${this.failedEmails.length})` : ''} - this.selectFolder('trash')} - .type=${this.selectedFolder === 'trash' ? 'highlighted' : 'normal'} + this.selectFolder('security')} + .type=${this.selectedFolder === 'security' ? 'highlighted' : 'normal'} > - Trash + Security ${this.securityIncidents.length > 0 ? `(${this.securityIncidents.length})` : ''}
- ${this.renderEmailList()} + ${this.renderContent()} `; } + private renderContent() { + switch (this.selectedFolder) { + case 'queued': + return this.renderEmailTable(this.queuedEmails, 'Queued Emails', 'Emails waiting to be delivered'); + case 'sent': + return this.renderEmailTable(this.sentEmails, 'Sent Emails', 'Successfully delivered emails'); + case 'failed': + return this.renderEmailTable(this.failedEmails, 'Failed Emails', 'Emails that failed to deliver', true); + case 'security': + return this.renderSecurityIncidents(); + default: + return this.renderEmptyState('Select a folder'); + } + } - private renderEmailList() { - const filteredEmails = this.getFilteredEmails(); + private renderEmailTable( + emails: interfaces.requests.IEmailQueueItem[], + heading1: string, + heading2: string, + showResend = false + ) { + const filteredEmails = this.filterEmails(emails); if (filteredEmails.length === 0) { - return html` -
- -
No emails in ${this.selectedFolder}
-
- `; + return this.renderEmptyState(`No emails in ${this.selectedFolder}`); + } + + const actions = [ + { + name: 'View Details', + iconName: 'lucide:eye', + type: ['doubleClick', 'inRow'] as any, + actionFunc: async (actionData: any) => { + this.selectedEmail = actionData.item; + } + } + ]; + + if (showResend) { + actions.push({ + name: 'Resend', + iconName: 'lucide:send', + type: ['inRow'] as any, + actionFunc: async (actionData: any) => { + await this.resendEmail(actionData.item.id); + } + }); } return html` ({ - 'Status': html``, - From: email.from, - Subject: html`${email.subject}`, - Date: this.formatDate(email.date), - 'Attach': html` - ${email.attachments?.length ? html`` : ''} - `, + .displayFunction=${(email: interfaces.requests.IEmailQueueItem) => ({ + 'Status': html`${email.status}`, + 'From': email.from || 'N/A', + 'To': email.to?.join(', ') || 'N/A', + 'Subject': email.subject || 'No subject', + 'Attempts': email.attempts, + 'Created': this.formatDate(email.createdAt), })} - .dataActions=${[ - { - name: 'Read', - iconName: 'eye', - type: ['doubleClick', 'inRow'], - actionFunc: async (actionData) => { - this.selectedEmail = actionData.item; - if (!actionData.item.read) { - this.markAsRead(actionData.item.id); - } - } - }, - { - name: 'Reply', - iconName: 'reply', - type: ['contextmenu'], - actionFunc: async (actionData) => { - this.replyToEmail(actionData.item); - } - }, - { - name: 'Forward', - iconName: 'share', - type: ['contextmenu'], - actionFunc: async (actionData) => { - this.forwardEmail(actionData.item); - } - }, - { - name: 'Delete', - iconName: 'trash', - type: ['contextmenu'], - actionFunc: async (actionData) => { - this.deleteEmail(actionData.item.id); - } - } - ]} + .dataActions=${actions} .selectionMode=${'single'} - heading1=${this.selectedFolder.charAt(0).toUpperCase() + this.selectedFolder.slice(1)} - heading2=${`${filteredEmails.length} emails`} + heading1=${heading1} + heading2=${`${filteredEmails.length} emails - ${heading2}`} > `; } - private renderEmailPreview() { + private renderSecurityIncidents() { + const incidents = this.securityIncidents; + + if (incidents.length === 0) { + return this.renderEmptyState('No security incidents'); + } + + return html` + ({ + 'Severity': html`${incident.level.toUpperCase()}`, + 'Type': incident.type, + 'Message': incident.message, + 'IP': incident.ipAddress || 'N/A', + 'Domain': incident.domain || 'N/A', + 'Time': this.formatDate(incident.timestamp), + })} + .dataActions=${[ + { + name: 'View Details', + iconName: 'lucide:eye', + type: ['doubleClick', 'inRow'], + actionFunc: async (actionData: any) => { + this.selectedIncident = actionData.item; + } + } + ]} + .selectionMode=${'single'} + heading1="Security Incidents" + heading2=${`${incidents.length} incidents`} + > + `; + } + + private renderEmailDetail() { if (!this.selectedEmail) return ''; return html` -
-
-
${this.selectedEmail.subject}
-
-
- From: - ${this.selectedEmail.from} -
-
- To: - ${this.selectedEmail.to.join(', ')} -
- ${this.selectedEmail.cc?.length ? html` -
- CC: - ${this.selectedEmail.cc.join(', ')} + Email Details +
+ +
+
+
+
${this.selectedEmail.subject || 'No subject'}
+
+
+ Status: + ${this.selectedEmail.status} +
+
+ From: + ${this.selectedEmail.from || 'N/A'} +
+
+ To: + ${this.selectedEmail.to?.join(', ') || 'N/A'} +
+
+ Mode: + ${this.selectedEmail.processingMode} +
+
+ Attempts: + ${this.selectedEmail.attempts} +
+
+ Created: + ${new Date(this.selectedEmail.createdAt).toLocaleString()} +
+ ${this.selectedEmail.deliveredAt ? html` +
+ Delivered: + ${new Date(this.selectedEmail.deliveredAt).toLocaleString()} +
+ ` : ''} + ${this.selectedEmail.lastError ? html` +
+ Last Error: + ${this.selectedEmail.lastError} +
+ ` : ''}
- ` : ''} -
- Date: - ${new Date(this.selectedEmail.date).toLocaleString()}
-
-
-
- ${this.selectedEmail.html ? - html`
` : - html`
${this.selectedEmail.body}
` - } -
- -
-
- this.replyToEmail(this.selectedEmail!)}> - - Reply - - this.replyAllToEmail(this.selectedEmail!)}> - - Reply All - - this.forwardEmail(this.selectedEmail!)}> - - Forward - - this.deleteEmail(this.selectedEmail!.id)} type="danger"> - - Delete - +
+ ${this.selectedEmail.status === 'failed' ? html` + this.resendEmail(this.selectedEmail!.id)} type="highlighted"> + + Resend + + ` : ''} + this.selectedEmail = null}> + + Close + +
`; } - private async openComposeModal(replyTo?: IEmail, replyAll = false, forward = false) { + private renderIncidentDetail() { + if (!this.selectedIncident) return ''; + + const incident = this.selectedIncident; + + return html` + Security Incident Details +
+ this.selectedIncident = null} type="secondary"> + + Back to List + +
+
+
+
+
${incident.message}
+
+ ${new Date(incident.timestamp).toLocaleString()} +
+
+ + ${incident.level.toUpperCase()} + +
+ +
+
+
Type
+
${incident.type}
+
+ ${incident.ipAddress ? html` +
+
IP Address
+
${incident.ipAddress}
+
+ ` : ''} + ${incident.domain ? html` +
+
Domain
+
${incident.domain}
+
+ ` : ''} + ${incident.emailId ? html` +
+
Email ID
+
${incident.emailId}
+
+ ` : ''} + ${incident.userId ? html` +
+
User ID
+
${incident.userId}
+
+ ` : ''} + ${incident.action ? html` +
+
Action
+
${incident.action}
+
+ ` : ''} + ${incident.result ? html` +
+
Result
+
${incident.result}
+
+ ` : ''} + ${incident.success !== undefined ? html` +
+
Success
+
${incident.success ? 'Yes' : 'No'}
+
+ ` : ''} +
+ + ${incident.details ? html` +
+
Details
+
+${JSON.stringify(incident.details, null, 2)}
+            
+
+ ` : ''} +
+ `; + } + + private renderEmptyState(message: string) { + return html` +
+ +
${message}
+
+ `; + } + + private async openComposeModal() { const { DeesModal } = await import('@design.estate/dees-catalog'); - + // Ensure domains are loaded before opening modal if (this.emailDomains.length === 0) { await this.loadEmailDomains(); } - + await DeesModal.createAndShow({ - heading: forward ? 'Forward Email' : replyTo ? 'Reply to Email' : 'New Email', + heading: 'New Email', width: 'large', content: html`
{ await this.sendEmail(e.detail); - // Close modal after sending const modals = document.querySelectorAll('dees-modal'); modals.forEach(m => (m as any).destroy?.()); }}> @@ -469,7 +657,7 @@ export class OpsViewEmails extends DeesElement { 0 + .options=${this.emailDomains.length > 0 ? this.emailDomains.map(domain => ({ key: domain, value: domain })) : [{ key: 'dcrouter.local', value: 'dcrouter.local' }]} .selectedKey=${this.emailDomains[0] || 'dcrouter.local'} @@ -477,55 +665,39 @@ export class OpsViewEmails extends DeesElement { style="flex: 1;" >
- - a.indexOf(v) === i) : [replyTo.from]) : []} required > - - - - - - - -


On ${new Date(replyTo.date).toLocaleString()}, ${replyTo.from} wrote:

${replyTo.html || `

${replyTo.body}

`}
` : replyTo && forward ? (replyTo.html || `

${replyTo.body}

`) : ''} >
- -
`, menuOptions: [ { name: 'Send', - iconName: 'paperPlane', + iconName: 'lucide:send', action: async (modalArg) => { const form = modalArg.shadowRoot?.querySelector('dees-form') as any; form?.submit(); @@ -533,35 +705,32 @@ export class OpsViewEmails extends DeesElement { }, { name: 'Cancel', - iconName: 'xmark', + iconName: 'lucide:x', action: async (modalArg) => await modalArg.destroy() } ] }); } - private getFilteredEmails(): IEmail[] { - let emails = this.emails.filter(e => e.folder === this.selectedFolder); - - if (this.searchTerm) { - const search = this.searchTerm.toLowerCase(); - emails = emails.filter(e => - e.subject.toLowerCase().includes(search) || - e.from.toLowerCase().includes(search) || - e.body.toLowerCase().includes(search) - ); + private filterEmails(emails: interfaces.requests.IEmailQueueItem[]): interfaces.requests.IEmailQueueItem[] { + if (!this.searchTerm) { + return emails; } - - return emails.sort((a, b) => b.date - a.date); + + const search = this.searchTerm.toLowerCase(); + return emails.filter(e => + (e.subject?.toLowerCase().includes(search)) || + (e.from?.toLowerCase().includes(search)) || + (e.to?.some(t => t.toLowerCase().includes(search))) + ); } - private getEmailCount(folder: string): number { - return this.emails.filter(e => e.folder === folder && !e.read).length; - } - - private selectFolder(folder: 'inbox' | 'sent' | 'draft' | 'trash') { - this.selectedFolder = folder; + private selectFolder(folder: TEmailFolder) { + // Use router for navigation to update URL + appRouter.navigateToEmailFolder(folder); + // Clear selections this.selectedEmail = null; + this.selectedIncident = null; } private formatDate(timestamp: number): string { @@ -569,167 +738,81 @@ export class OpsViewEmails extends DeesElement { const now = new Date(); const diff = now.getTime() - date.getTime(); const hours = diff / (1000 * 60 * 60); - + if (hours < 24) { return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } else if (hours < 168) { // 7 days - return date.toLocaleDateString([], { weekday: 'short' }); + return date.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' }); } else { return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); } } - private async loadEmails() { - // TODO: Load real emails from server - // For now, generate mock data - this.generateMockEmails(); + private async loadData() { + this.isLoading = true; + await this.loadFolderData(this.selectedFolder); + this.isLoading = false; + } + + private async loadFolderData(folder: TEmailFolder) { + switch (folder) { + case 'queued': + await appstate.emailOpsStatePart.dispatchAction(appstate.fetchQueuedEmailsAction, null); + break; + case 'sent': + await appstate.emailOpsStatePart.dispatchAction(appstate.fetchSentEmailsAction, null); + break; + case 'failed': + await appstate.emailOpsStatePart.dispatchAction(appstate.fetchFailedEmailsAction, null); + break; + case 'security': + await appstate.emailOpsStatePart.dispatchAction(appstate.fetchSecurityIncidentsAction, null); + break; + } } private async loadEmailDomains() { try { - // Fetch configuration from the server await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null); const config = appstate.configStatePart.getState().config; - + if (config?.email?.domains && Array.isArray(config.email.domains) && config.email.domains.length > 0) { this.emailDomains = config.email.domains; } else { - // Fallback to default domains if none configured this.emailDomains = ['dcrouter.local']; } } catch (error) { console.error('Failed to load email domains:', error); - // Fallback to default domain on error this.emailDomains = ['dcrouter.local']; } } - private async refreshEmails() { + private async refreshData() { this.isLoading = true; - await this.loadEmails(); + await this.loadFolderData(this.selectedFolder); this.isLoading = false; } private async sendEmail(formData: any) { try { - // TODO: Implement actual email sending via API console.log('Sending email:', formData); - - // Add to sent folder (mock) - // Combine username and domain + // TODO: Implement actual email sending via API + // For now, just log the data const fromEmail = `${formData.fromUsername || 'admin'}@${formData.fromDomain || this.emailDomains[0] || 'dcrouter.local'}`; - - const newEmail: IEmail = { - id: `email-${Date.now()}`, - from: fromEmail, - to: formData.to || [], - cc: formData.cc || [], - bcc: formData.bcc || [], - subject: formData.subject, - body: formData.body.replace(/<[^>]*>/g, ''), // Strip HTML for plain text version - html: formData.body, // Store the HTML version - date: Date.now(), - read: true, - folder: 'sent', - }; - - this.emails = [...this.emails, newEmail]; - - // Show success notification - console.log('Email sent successfully'); - // TODO: Show toast notification when interface is available + console.log('From:', fromEmail); + console.log('To:', formData.to); + console.log('Subject:', formData.subject); } catch (error: any) { console.error('Failed to send email', error); - // TODO: Show error toast notification when interface is available } } - private async markAsRead(emailId: string) { - const email = this.emails.find(e => e.id === emailId); - if (email) { - email.read = true; - this.emails = [...this.emails]; + private async resendEmail(emailId: string) { + try { + await appstate.emailOpsStatePart.dispatchAction(appstate.resendEmailAction, emailId); + this.selectedEmail = null; + } catch (error) { + console.error('Failed to resend email:', error); } } - - private async markAllAsRead() { - this.emails = this.emails.map(e => - e.folder === this.selectedFolder ? { ...e, read: true } : e - ); - } - - private async deleteEmail(emailId: string) { - const email = this.emails.find(e => e.id === emailId); - if (email) { - if (email.folder === 'trash') { - // Permanently delete - this.emails = this.emails.filter(e => e.id !== emailId); - } else { - // Move to trash - email.folder = 'trash'; - this.emails = [...this.emails]; - } - - if (this.selectedEmail?.id === emailId) { - this.selectedEmail = null; - } - } - } - - private async replyToEmail(email: IEmail) { - this.openComposeModal(email, false, false); - } - - private async replyAllToEmail(email: IEmail) { - this.openComposeModal(email, true, false); - } - - private async forwardEmail(email: IEmail) { - this.openComposeModal(email, false, true); - } - - private generateMockEmails() { - const subjects = [ - 'Server Alert: High CPU Usage', - 'Daily Report - Network Activity', - 'Security Update Required', - 'New User Registration', - 'Backup Completed Successfully', - 'DNS Query Spike Detected', - 'SSL Certificate Renewal Notice', - 'Monthly Usage Summary', - ]; - - const senders = [ - 'monitoring@dcrouter.local', - 'alerts@system.local', - 'admin@company.com', - 'noreply@service.com', - 'support@vendor.com', - ]; - - const bodies = [ - 'This is an automated alert regarding your server status.', - 'Please review the attached report for detailed information.', - 'Action required: Update your security settings.', - 'Your daily summary is ready for review.', - 'All systems are operating normally.', - ]; - - this.emails = Array.from({ length: 50 }, (_, i) => ({ - id: `email-${i}`, - from: senders[Math.floor(Math.random() * senders.length)], - to: ['admin@dcrouter.local'], - subject: subjects[Math.floor(Math.random() * subjects.length)], - body: bodies[Math.floor(Math.random() * bodies.length)], - date: Date.now() - (i * 3600000), // 1 hour apart - read: Math.random() > 0.3, - folder: i < 40 ? 'inbox' : i < 45 ? 'sent' : 'trash', - attachments: Math.random() > 0.8 ? [{ - filename: 'report.pdf', - size: 1024 * 1024, - contentType: 'application/pdf', - }] : undefined, - })); - } -} \ No newline at end of file +} diff --git a/ts_web/index.ts b/ts_web/index.ts index 5ed3ea6..130a5fe 100644 --- a/ts_web/index.ts +++ b/ts_web/index.ts @@ -3,6 +3,10 @@ import * as plugins from './plugins.js'; import { html } from '@design.estate/dees-element'; import './elements/index.js'; +import { appRouter } from './router.js'; + +// Initialize router before rendering +appRouter.init(); plugins.deesElement.render(html` diff --git a/ts_web/router.ts b/ts_web/router.ts new file mode 100644 index 0000000..9c7a729 --- /dev/null +++ b/ts_web/router.ts @@ -0,0 +1,181 @@ +import * as plugins from './plugins.js'; +import * as appstate from './appstate.js'; + +const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter; + +export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security'] as const; +export const validEmailFolders = ['queued', 'sent', 'failed', 'security'] as const; + +export type TValidView = typeof validViews[number]; +export type TValidEmailFolder = typeof validEmailFolders[number]; + +class AppRouter { + private router: InstanceType; + private initialized = false; + private suppressStateUpdate = false; + + constructor() { + this.router = new SmartRouter({ debug: false }); + } + + public init(): void { + if (this.initialized) return; + this.setupRoutes(); + this.setupStateSync(); + this.handleInitialRoute(); + this.initialized = true; + } + + private setupRoutes(): void { + // Main views + for (const view of validViews) { + if (view === 'emails') { + // Email root - default to queued + this.router.on('/emails', async () => { + this.updateViewState('emails'); + this.updateEmailFolder('queued'); + }); + + // Email with folder parameter + this.router.on('/emails/:folder', async (routeInfo) => { + const folder = routeInfo.params.folder as string; + if (validEmailFolders.includes(folder as TValidEmailFolder)) { + this.updateViewState('emails'); + this.updateEmailFolder(folder as TValidEmailFolder); + } else { + // Invalid folder, redirect to queued + this.navigateTo('/emails/queued'); + } + }); + } else { + this.router.on(`/${view}`, async () => { + this.updateViewState(view); + }); + } + } + + // Root redirect + this.router.on('/', async () => { + this.navigateTo('/overview'); + }); + } + + private setupStateSync(): void { + // Sync URL when state changes programmatically (not from router) + appstate.uiStatePart.state.subscribe((uiState) => { + if (this.suppressStateUpdate) return; + + const currentPath = window.location.pathname; + const expectedPath = this.getExpectedPath(uiState.activeView); + + // Only update URL if it doesn't match current state + if (!currentPath.startsWith(expectedPath)) { + this.suppressStateUpdate = true; + if (uiState.activeView === 'emails') { + const emailState = appstate.emailOpsStatePart.getState(); + this.router.pushUrl(`/emails/${emailState.currentView}`); + } else { + this.router.pushUrl(`/${uiState.activeView}`); + } + this.suppressStateUpdate = false; + } + }); + } + + private getExpectedPath(view: string): string { + if (view === 'emails') { + return '/emails'; + } + return `/${view}`; + } + + private handleInitialRoute(): void { + const path = window.location.pathname; + + if (!path || path === '/') { + // Redirect root to overview + this.router.pushUrl('/overview'); + } else { + // Parse current path and update state + const segments = path.split('/').filter(Boolean); + const view = segments[0]; + + if (validViews.includes(view as TValidView)) { + this.updateViewState(view as TValidView); + + if (view === 'emails' && segments[1]) { + const folder = segments[1]; + if (validEmailFolders.includes(folder as TValidEmailFolder)) { + this.updateEmailFolder(folder as TValidEmailFolder); + } else { + this.updateEmailFolder('queued'); + } + } else if (view === 'emails') { + this.updateEmailFolder('queued'); + } + } else { + // Invalid view, redirect to overview + this.router.pushUrl('/overview'); + } + } + } + + private updateViewState(view: string): void { + this.suppressStateUpdate = true; + const currentState = appstate.uiStatePart.getState(); + if (currentState.activeView !== view) { + appstate.uiStatePart.setState({ + ...currentState, + activeView: view, + }); + } + this.suppressStateUpdate = false; + } + + private updateEmailFolder(folder: TValidEmailFolder): void { + this.suppressStateUpdate = true; + const currentState = appstate.emailOpsStatePart.getState(); + if (currentState.currentView !== folder) { + appstate.emailOpsStatePart.setState({ + ...currentState, + currentView: folder as appstate.IEmailOpsState['currentView'], + }); + } + this.suppressStateUpdate = false; + } + + public navigateTo(path: string): void { + this.router.pushUrl(path); + } + + public navigateToView(view: string): void { + if (validViews.includes(view as TValidView)) { + this.navigateTo(`/${view}`); + } else { + this.navigateTo('/overview'); + } + } + + public navigateToEmailFolder(folder: string): void { + if (validEmailFolders.includes(folder as TValidEmailFolder)) { + this.navigateTo(`/emails/${folder}`); + } else { + this.navigateTo('/emails/queued'); + } + } + + public getCurrentView(): string { + return appstate.uiStatePart.getState().activeView; + } + + public getCurrentEmailFolder(): string { + return appstate.emailOpsStatePart.getState().currentView; + } + + public destroy(): void { + this.router.destroy(); + this.initialized = false; + } +} + +export const appRouter = new AppRouter();