diff --git a/.playwright-mcp/dcrouter-scrollbar-issue.png b/.playwright-mcp/dcrouter-scrollbar-issue.png new file mode 100644 index 0000000..923213c Binary files /dev/null and b/.playwright-mcp/dcrouter-scrollbar-issue.png differ diff --git a/.playwright-mcp/page-2026-02-01T23-10-23-737Z.png b/.playwright-mcp/page-2026-02-01T23-10-23-737Z.png new file mode 100644 index 0000000..6f4f4e8 Binary files /dev/null and b/.playwright-mcp/page-2026-02-01T23-10-23-737Z.png differ 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 0000000..6f4f4e8 Binary files /dev/null and b/.playwright-mcp/page-2026-02-01T23-11-19-449Z.png differ 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 0000000..923213c Binary files /dev/null and b/.playwright-mcp/page-2026-02-01T23-12-03-126Z.png differ diff --git a/.playwright-mcp/page-2026-02-01T23-12-15-576Z.png b/.playwright-mcp/page-2026-02-01T23-12-15-576Z.png new file mode 100644 index 0000000..923213c Binary files /dev/null and b/.playwright-mcp/page-2026-02-01T23-12-15-576Z.png differ diff --git a/changelog.md b/changelog.md index 6a13d7f..f0a653f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,10 @@ # Changelog +## 2026-02-01 - 3.0.0 - BREAKING CHANGE(deps) +upgrade major dependencies, migrate action.target to action.targets (array), adapt to SmartRequest API changes, and add RADIUS server support + +- Bumped many major dependencies: @api.global/typedserver 3.x → 8.3.0, @api.global/typedsocket 3.x → 4.1.0, @apiclient.xyz/cloudflare 6.x → 7.1.0, @design.estate/dees-catalog 1.x → 3.41.4, @push.rocks/smartpath 5.x → 6.x, @push.rocks/smartproxy 19.x → 22.x, @push.rocks/smartrequest 2.x → 5.x, uuid 11.x → 13.x, @types/node 25.1.0 → 25.2.0 + ## 2026-02-01 - 2.13.0 - feat(radius) add RADIUS server with MAC authentication (MAB), VLAN assignment, accounting and OpsServer API handlers diff --git a/package.json b/package.json index 6ecce80..cbe90ba 100644 --- a/package.json +++ b/package.json @@ -22,16 +22,16 @@ "@git.zone/tsrun": "^2.0.1", "@git.zone/tstest": "^3.1.8", "@git.zone/tswatch": "^3.0.1", - "@types/node": "^25.1.0", + "@types/node": "^25.2.0", "node-forge": "^1.3.3" }, "dependencies": { "@api.global/typedrequest": "^3.0.19", "@api.global/typedrequest-interfaces": "^3.0.19", - "@api.global/typedserver": "^3.0.74", - "@api.global/typedsocket": "^3.0.0", - "@apiclient.xyz/cloudflare": "^6.4.1", - "@design.estate/dees-catalog": "^1.10.10", + "@api.global/typedserver": "^8.3.0", + "@api.global/typedsocket": "^4.1.0", + "@apiclient.xyz/cloudflare": "^7.1.0", + "@design.estate/dees-catalog": "^3.41.5", "@design.estate/dees-element": "^2.0.45", "@push.rocks/projectinfo": "^5.0.1", "@push.rocks/qenv": "^6.1.3", @@ -45,11 +45,11 @@ "@push.rocks/smartmail": "^2.2.0", "@push.rocks/smartmetrics": "^2.0.10", "@push.rocks/smartnetwork": "^4.4.0", - "@push.rocks/smartpath": "^5.0.5", + "@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpromise": "^4.0.3", - "@push.rocks/smartproxy": "^19.6.15", + "@push.rocks/smartproxy": "^22.4.2", "@push.rocks/smartradius": "^1.0.3", - "@push.rocks/smartrequest": "^2.1.0", + "@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrule": "^2.0.1", "@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartstate": "^2.0.27", @@ -61,7 +61,7 @@ "lru-cache": "^11.2.5", "mailauth": "^4.12.0", "mailparser": "^3.9.3", - "uuid": "^11.1.0" + "uuid": "^13.0.0" }, "keywords": [ "mail service", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ece24f9..19660ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,17 +15,17 @@ importers: specifier: ^3.0.19 version: 3.0.19 '@api.global/typedserver': - specifier: ^3.0.74 - version: 3.0.80(@push.rocks/smartserve@2.0.1) + specifier: ^8.3.0 + version: 8.3.0(@tiptap/pm@2.27.2) '@api.global/typedsocket': - specifier: ^3.0.0 - version: 3.1.1(@push.rocks/smartserve@2.0.1) + specifier: ^4.1.0 + version: 4.1.0(@push.rocks/smartserve@2.0.1) '@apiclient.xyz/cloudflare': - specifier: ^6.4.1 - version: 6.4.3 + specifier: ^7.1.0 + version: 7.1.0 '@design.estate/dees-catalog': - specifier: ^1.10.10 - version: 1.12.4(@tiptap/pm@2.27.2) + specifier: ^3.41.5 + version: 3.41.5(@tiptap/pm@2.27.2) '@design.estate/dees-element': specifier: ^2.0.45 version: 2.1.6 @@ -37,7 +37,7 @@ importers: version: 6.1.3 '@push.rocks/smartacme': specifier: ^8.0.0 - version: 8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7) + version: 8.0.0(socks@2.8.7) '@push.rocks/smartdata': specifier: ^5.15.1 version: 5.16.7(socks@2.8.7) @@ -66,20 +66,20 @@ importers: specifier: ^4.4.0 version: 4.4.0 '@push.rocks/smartpath': - specifier: ^5.0.5 - version: 5.1.0 + specifier: ^6.0.0 + version: 6.0.0 '@push.rocks/smartpromise': specifier: ^4.0.3 version: 4.2.3 '@push.rocks/smartproxy': - specifier: ^19.6.15 - version: 19.6.17(@push.rocks/smartserve@2.0.1)(socks@2.8.7) + specifier: ^22.4.2 + version: 22.4.2(socks@2.8.7) '@push.rocks/smartradius': specifier: ^1.0.3 version: 1.1.0 '@push.rocks/smartrequest': - specifier: ^2.1.0 - version: 2.1.0 + specifier: ^5.0.1 + version: 5.0.1 '@push.rocks/smartrule': specifier: ^2.0.1 version: 2.0.1 @@ -114,8 +114,8 @@ importers: specifier: ^3.9.3 version: 3.9.3 uuid: - specifier: ^11.1.0 - version: 11.1.0 + specifier: ^13.0.0 + version: 13.0.0 devDependencies: '@git.zone/tsbuild': specifier: ^4.1.2 @@ -133,8 +133,8 @@ importers: specifier: ^3.0.1 version: 3.0.1(@tiptap/pm@2.27.2) '@types/node': - specifier: ^25.1.0 - version: 25.1.0 + specifier: ^25.2.0 + version: 25.2.0 node-forge: specifier: ^1.3.3 version: 1.3.3 @@ -172,6 +172,9 @@ packages: '@apiclient.xyz/cloudflare@6.4.3': resolution: {integrity: sha512-ztegUdUO3Zd4mUoTSylKlCEKPBMHEcggrLelR+7CiblM4beHMwopMVlryBmiCY7bOVbUSPoK0xsVTF7VIy3p/A==} + '@apiclient.xyz/cloudflare@7.1.0': + resolution: {integrity: sha512-qb+PWcE5OjOCPO0+4rexOgtEf9Q1VOIHfrGmav/gXAtkdNL5omifSxPbUseyFKsZrxnRv4rLzvjckUCj0hkvFw==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.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();