17 Commits 53f6ed394f ... dc781b28c4

Autor SHA1 Mensaje Fecha
  MHSanaei dc781b28c4 chore(deps): bump telego to v1.10.0 hace 1 semana
  MHSanaei 5b8504c756 chore(deps): bump frontend deps and override js-yaml to patch DoS advisory hace 1 semana
  MHSanaei c1fdcd98d2 fix(nodes): route 'load inbounds' through the connection outbound hace 1 semana
  Sentiago eec030f86f feat(notifications): event bus architecture with Telegram and SMTP subscribers (#5326) hace 1 semana
  MHSanaei 7fe082a7f1 fix(nodes): stop multi-attached client traffic inflating across node inbounds hace 1 semana
  MHSanaei f7ffe89813 fix(outbound): preserve non-ASCII characters in imported subscription tags (#5354) hace 1 semana
  MHSanaei c1fbfd0510 fix(outbound): parse xmux from imported share links (#5353) hace 1 semana
  MHSanaei cbb21b7575 fix(nodes): propagate single-client deletion to remote nodes (#5352) hace 1 semana
  MHSanaei cf5f37e409 fix(iplimit): ban UDP as well as TCP in fail2ban action (#5350) hace 1 semana
  MHSanaei 0d87bb8b4b fix(inbounds): flag conflicts with the reserved Xray API port (#5304) hace 1 semana
  MHSanaei f00512d12e fix(frontend): TProxy schema, VLESS+XHTTP flow links, clearable Jalali date picker (#5339, #5322, #5313) hace 1 semana
  nima1024m cdaf5f80db fix(inbound): strip XHTTP client-only fields from xray config, keep for subscriptions (#5349) hace 1 semana
  n0ctal ac8cb505d1 fix(subscriptions): avoid shared mutable state during generation (#5270) hace 1 semana
  n0ctal 71616b7cf2 feat(web): cap request body size on state-changing routes (#5271) hace 1 semana
  Rouzbeh† 628406117e fix(nodes): sync "start after first connect" expiry so un-activated nodes do not reset it (#5319) hace 1 semana
  Sanaei 7605902324 Test-quality audit: fix 2 prod bugs, strengthen weak tests, add mutation/fuzz/CI tooling (#5345) hace 1 semana
  tonymoses10 b5872af279 Frontend operation button size optimization (#5343) hace 1 semana
Se han modificado 100 ficheros con 5588 adiciones y 689 borrados
  1. 34 1
      .github/workflows/ci.yml
  2. 62 0
      .github/workflows/mutation.yml
  3. 43 0
      CONTRIBUTING.md
  4. 6 5
      DockerEntrypoint.sh
  5. 1 0
      frontend/.gitignore
  6. 315 134
      frontend/package-lock.json
  7. 12 8
      frontend/package.json
  8. 181 10
      frontend/public/openapi.json
  9. 36 0
      frontend/src/components/form/DateTimePicker.css
  10. 33 2
      frontend/src/components/form/DateTimePicker.tsx
  11. 147 0
      frontend/src/components/ui/EventBusCheckboxes.tsx
  12. 1 0
      frontend/src/components/ui/index.ts
  13. 21 2
      frontend/src/generated/examples.ts
  14. 112 10
      frontend/src/generated/schemas.ts
  15. 21 2
      frontend/src/generated/types.ts
  16. 21 2
      frontend/src/generated/zod.ts
  17. 2 0
      frontend/src/layouts/AppSidebar.tsx
  18. 14 3
      frontend/src/lib/xray/inbound-link.ts
  19. 11 2
      frontend/src/lib/xray/outbound-link-parser.ts
  20. 11 2
      frontend/src/models/setting.ts
  21. 12 0
      frontend/src/pages/api-docs/endpoints.ts
  22. 5 5
      frontend/src/pages/clients/ClientsPage.tsx
  23. 2 2
      frontend/src/pages/groups/GroupsPage.tsx
  24. 2 2
      frontend/src/pages/inbounds/list/RowActions.tsx
  25. 4 4
      frontend/src/pages/nodes/NodeList.tsx
  26. 137 0
      frontend/src/pages/settings/EmailTab.tsx
  27. 3 1
      frontend/src/pages/settings/SettingsPage.tsx
  28. 45 12
      frontend/src/pages/settings/TelegramTab.tsx
  29. 1 1
      frontend/src/pages/xray/balancers/BalancersTab.tsx
  30. 13 4
      frontend/src/schemas/protocols/security/index.ts
  31. 1 1
      frontend/src/schemas/setting.ts
  32. 0 161
      frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap
  33. 0 141
      frontend/src/test/__snapshots__/outbound-form-modal.test.tsx.snap
  34. 12 2
      frontend/src/test/inbound-form-adapter.test.ts
  35. 19 4
      frontend/src/test/inbound-form-modal.test.tsx
  36. 91 0
      frontend/src/test/inbound-link.test.ts
  37. 21 3
      frontend/src/test/outbound-form-modal.test.tsx
  38. 17 0
      frontend/src/test/outbound-link-parser.test.ts
  39. 32 0
      frontend/src/test/protocols.test.ts
  40. 1 1
      frontend/src/test/stream-wire-normalize.test.ts
  41. 2 1
      go.mod
  42. 4 2
      go.sum
  43. 75 0
      internal/config/config_mutation_test.go
  44. 48 1
      internal/database/model/model.go
  45. 82 0
      internal/database/model/model_test.go
  46. 123 0
      internal/eventbus/bus.go
  47. 199 0
      internal/eventbus/bus_test.go
  48. 64 0
      internal/eventbus/events.go
  49. 33 0
      internal/eventbus/filter.go
  50. 22 5
      internal/logger/logger.go
  51. 30 0
      internal/logger/logger_test.go
  52. 99 0
      internal/mtproto/manager_mutation_test.go
  53. 21 0
      internal/sub/build_urls_test.go
  54. 15 16
      internal/sub/clash_service.go
  55. 76 4
      internal/sub/clash_service_test.go
  56. 4 3
      internal/sub/controller.go
  57. 51 0
      internal/sub/external_only_sub_test.go
  58. 10 12
      internal/sub/json_service.go
  59. 484 0
      internal/sub/mutation_audit_test.go
  60. 33 16
      internal/sub/service.go
  61. 32 0
      internal/sub/service_dedup_test.go
  62. 88 0
      internal/sub/service_property_test.go
  63. 89 0
      internal/sub/service_sharelink_test.go
  64. 60 0
      internal/util/common/format_mutation_test.go
  65. 4 2
      internal/util/link/outbound.go
  66. 28 0
      internal/util/link/outbound_fuzz_test.go
  67. 201 0
      internal/util/link/outbound_helpers_test.go
  68. 7 0
      internal/util/link/outbound_test.go
  69. 32 6
      internal/util/netproxy/netproxy_test.go
  70. 102 0
      internal/util/netsafe/netsafe_mutation_test.go
  71. 61 2
      internal/web/controller/setting.go
  72. 22 10
      internal/web/entity/entity.go
  73. 12 16
      internal/web/job/check_cpu_usage.go
  74. 4 0
      internal/web/job/check_xray_running_job.go
  75. 38 0
      internal/web/job/node_heartbeat_job.go
  76. 43 0
      internal/web/middleware/bodylimit.go
  77. 80 0
      internal/web/middleware/bodylimit_test.go
  78. 30 0
      internal/web/middleware/security_test.go
  79. 99 0
      internal/web/middleware/validate_mutation_test.go
  80. 4 4
      internal/web/runtime/remote.go
  81. 117 0
      internal/web/runtime/remote_test.go
  82. 73 0
      internal/web/runtime/tls_client_property_test.go
  83. 61 2
      internal/web/runtime/tls_client_test.go
  84. 24 10
      internal/web/service/client_inbound_apply.go
  85. 297 0
      internal/web/service/email/email.go
  86. 52 0
      internal/web/service/email/ratelimiter_test.go
  87. 182 0
      internal/web/service/email/subscriber.go
  88. 1 1
      internal/web/service/inbound_migration.go
  89. 39 0
      internal/web/service/inbound_migration_test.go
  90. 61 15
      internal/web/service/inbound_node.go
  91. 59 25
      internal/web/service/node.go
  92. 141 0
      internal/web/service/node_client_expiry_sync_test.go
  93. 43 0
      internal/web/service/node_client_traffic_sum_test.go
  94. 42 0
      internal/web/service/node_dirty_test.go
  95. 49 2
      internal/web/service/port_conflict.go
  96. 62 0
      internal/web/service/port_conflict_test.go
  97. 107 5
      internal/web/service/setting.go
  98. 11 2
      internal/web/service/setting_security_test.go
  99. 4 0
      internal/web/service/tgbot/tgbot.go
  100. 150 0
      internal/web/service/tgbot/tgbot_event.go

+ 34 - 1
.github/workflows/ci.yml

@@ -35,7 +35,7 @@ jobs:
       - name: Test
         run: |
           go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
-          go test $(cat /tmp/go-packages.txt)
+          go test -shuffle=on -count=1 $(cat /tmp/go-packages.txt)
 
   codegen:
     runs-on: ubuntu-latest
@@ -69,6 +69,39 @@ jobs:
       - name: Run govulncheck
         run: govulncheck ./...
 
+  # Race + shuffle hygiene gate: data races and order-dependent tests fail the build.
+  race:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v6
+      - uses: actions/setup-go@v6
+        with:
+          go-version-file: go.mod
+          cache: true
+      - name: Stub internal/web/dist for go:embed
+        run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
+      - name: Race + shuffle
+        run: |
+          go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
+          go test -race -shuffle=on -count=1 $(cat /tmp/go-packages.txt)
+
+  # Brief native-fuzz smoke on the security-/parser-critical decoders. Each runs the
+  # generated corpus plus 30s of exploration; a crash here is a real input-handling bug.
+  fuzz-smoke:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v6
+      - uses: actions/setup-go@v6
+        with:
+          go-version-file: go.mod
+          cache: true
+      - name: Stub internal/web/dist for go:embed
+        run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
+      - name: Fuzz critical parsers (smoke)
+        run: |
+          go test -run '^$' -fuzz 'FuzzParseLink$' -fuzztime=30s ./internal/util/link/
+          go test -run '^$' -fuzz 'FuzzDecodeCertPin$' -fuzztime=30s ./internal/web/runtime/
+
   frontend:
     runs-on: ubuntu-latest
     steps:

+ 62 - 0
.github/workflows/mutation.yml

@@ -0,0 +1,62 @@
+name: Mutation testing
+
+# Mutation testing (gremlins) is the objective check for "fake" tests: it mutates the
+# source and a surviving (LIVED) mutant means no test caught the change. It is SLOW, so it
+# runs nightly / on demand and scoped per package — never per-commit. It is informational:
+# no thresholds are set, so it reports survivors as artifacts without failing the build.
+on:
+  schedule:
+    - cron: "0 3 * * *" # 03:00 UTC daily
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+jobs:
+  gremlins:
+    runs-on: ubuntu-latest
+    timeout-minutes: 120
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - name: sub
+            path: ./internal/sub/
+            exclude: ""
+          - name: runtime
+            path: ./internal/web/runtime/
+            exclude: ""
+          - name: link
+            path: ./internal/util/link/
+            exclude: ""
+          - name: database
+            path: ./internal/database/
+            exclude: 'dump_sqlite\.go'
+          - name: service
+            path: ./internal/web/service/
+            exclude: 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go'
+    steps:
+      - uses: actions/checkout@v6
+      - uses: actions/setup-go@v6
+        with:
+          go-version-file: go.mod
+          cache: true
+      - name: Stub internal/web/dist for go:embed
+        run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
+      - name: Install gremlins
+        run: go install github.com/go-gremlins/gremlins/cmd/[email protected]
+      - name: Run gremlins on ${{ matrix.name }}
+        run: |
+          OUT="mutation-${{ matrix.name }}.json"
+          if [ -n "${{ matrix.exclude }}" ]; then
+            gremlins unleash -E '${{ matrix.exclude }}' -o "$OUT" ${{ matrix.path }}
+          else
+            gremlins unleash -o "$OUT" ${{ matrix.path }}
+          fi
+      - name: Upload mutation report
+        if: always()
+        uses: actions/upload-artifact@v4
+        with:
+          name: mutation-${{ matrix.name }}
+          path: mutation-${{ matrix.name }}.json
+          if-no-files-found: ignore

+ 43 - 0
CONTRIBUTING.md

@@ -236,6 +236,49 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
 | `internal/config/` | Environment-variable helpers, paths, defaults |
 | `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
 
+## Testing
+
+Tests live next to the code (`foo.go` ↔ `foo_test.go`); frontend specs and golden fixtures live in `frontend/src/test/`.
+
+### Go conventions
+
+- **Stdlib `testing` only** — no testify. Table-driven with `t.Run` subtests and `t.Helper()` on helpers.
+- **Assert the contract, not internals.** Pin the exact value / typed error / emitted string — not `err != nil` or `len > 0`. A test that still passes when the behavior is broken is worse than no test.
+- **Real dependencies over mocks.** Get a throwaway DB with `database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` + `t.Cleanup(func() { _ = database.CloseDB() })` (Windows-safe), and use `httptest` servers for HTTP. The `internal/sub` suite's `initSubDB(t)` is the template.
+
+### Running
+
+| Goal | Command |
+|------|---------|
+| Standard run | `go test ./...` |
+| Hygiene — data races + order-dependence | `go test -race -shuffle=on -count=1 ./...` (`-race` needs the C compiler from Prerequisites) |
+| Coverage gaps | `go test -coverprofile=cov.out ./<pkg>/... && go tool cover -func=cov.out` |
+| Fuzz a parser briefly | `go test -run '^$' -fuzz 'FuzzName$' -fuzztime=30s ./<pkg>/...` |
+
+Frontend: `cd frontend && npm run test` (vitest), or `npm run test -- --coverage`.
+
+### Property and fuzz tests
+
+Input-heavy or pure logic (link builders, parsers, decoders) is also covered by **property tests** (`pgregory.net/rapid`) and **native fuzz targets** (`go test -fuzz`). A fuzz target's **seed corpus** (its inline `f.Add` cases plus any `testdata/fuzz` entries) runs as ordinary subtests under a plain `go test` — no `-fuzz` flag needed — so CI's normal test job exercises the seeds; the time-boxed *fuzzing* exploration (`-fuzz=...`) runs separately as the `fuzz-smoke` job.
+
+### Mutation testing (optional, manual)
+
+[gremlins](https://github.com/go-gremlins/gremlins) checks whether tests actually fail when the code is mutated — a surviving (`LIVED`) mutant means a weak test. It is **slow**, so run it **scoped per package**, never repo-wide or per-commit:
+
+```bash
+go install github.com/go-gremlins/gremlins/cmd/gremlins@latest
+gremlins unleash ./internal/sub/
+gremlins unleash -E 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go' ./internal/web/service/
+```
+
+Treat each survivor as one of: a weak test (strengthen it), dead code (remove it), or an equivalent mutant (unkillable — leave it). Don't write a test purely to kill a mutant if it doesn't reflect real behavior.
+
+CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml` — scoped per package, results uploaded as artifacts. It is **informational**, not a gate (no thresholds), so check the reports when hardening a suite rather than waiting for a red build.
+
+### CI
+
+`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`test`/`build`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green.
+
 ## Sending a pull request
 
 1. Branch off `main` (e.g. `feat/short-description`).

+ 6 - 5
DockerEntrypoint.sh

@@ -44,23 +44,24 @@ before = iptables-allports.conf
 [Definition]
 actionstart = <iptables> -N f2b-<name>
               <iptables> -A f2b-<name> -j <returntype>
-              <iptables> -I <chain> -p <protocol> -j f2b-<name>
+              <iptables> -I <chain> -j f2b-<name>
 
-actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
+actionstop = <iptables> -D <chain> -j f2b-<name>
              <actionflush>
              <iptables> -X f2b-<name>
 
 actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
 
-actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
+actionban = <iptables> -I f2b-<name> 1 -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
+            <iptables> -I f2b-<name> 1 -s <ip> -p udp -m multiport ! --dports <exemptports> -j <blocktype>
             echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S")   BAN   [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
 
-actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
+actionunban = <iptables> -D f2b-<name> -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
+              <iptables> -D f2b-<name> -s <ip> -p udp -m multiport ! --dports <exemptports> -j <blocktype>
               echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S")   UNBAN   [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
 
 [Init]
 name = default
-protocol = tcp
 chain = INPUT
 exemptports = $EXEMPT_PORTS
 EOF

+ 1 - 0
frontend/.gitignore

@@ -2,3 +2,4 @@ node_modules/
 .vite/
 *.log
 *.tsbuildinfo
+coverage/

+ 315 - 134
frontend/package-lock.json

@@ -13,8 +13,8 @@
         "@codemirror/theme-one-dark": "^6.1.3",
         "@tanstack/react-query": "^5.101.0",
         "@tanstack/react-query-devtools": "^5.101.0",
-        "antd": "^6.4.3",
-        "axios": "^1.17.0",
+        "antd": "^6.4.4",
+        "axios": "^1.18.0",
         "codemirror": "^6.0.2",
         "dayjs": "^1.11.21",
         "i18next": "^26.3.1",
@@ -24,7 +24,7 @@
         "react": "^19.2.7",
         "react-dom": "^19.2.7",
         "react-i18next": "^17.0.8",
-        "react-router-dom": "^7.16.0",
+        "react-router-dom": "^7.17.0",
         "recharts": "^3.8.1",
         "swagger-ui-react": "^5.32.6",
         "zod": "^4.4.3"
@@ -33,18 +33,19 @@
         "@eslint/js": "^10.0.1",
         "@testing-library/dom": "^10.4.1",
         "@testing-library/react": "^16.3.2",
-        "@types/react": "^19.2.16",
+        "@types/react": "^19.2.17",
         "@types/react-dom": "^19.2.3",
         "@types/swagger-ui-react": "^5.18.0",
         "@vitejs/plugin-react": "^6.0.2",
-        "eslint": "^10.4.1",
+        "@vitest/coverage-v8": "^4.1.9",
+        "eslint": "^10.5.0",
         "eslint-plugin-react-hooks": "^7.1.1",
         "globals": "^17.6.0",
         "jsdom": "^29.1.1",
         "typescript": "^6.0.3",
-        "typescript-eslint": "^8.60.1",
+        "typescript-eslint": "^8.61.1",
         "vite": "8.0.16",
-        "vitest": "^4.1.8"
+        "vitest": "^4.1.9"
       },
       "engines": {
         "node": ">=22.0.0",
@@ -456,6 +457,16 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@bcoe/v8-coverage": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+      "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@bramus/specificity": {
       "version": "2.4.2",
       "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -617,9 +628,9 @@
       }
     },
     "node_modules/@csstools/css-color-parser": {
-      "version": "4.1.3",
-      "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.3.tgz",
-      "integrity": "sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==",
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.7.tgz",
+      "integrity": "sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==",
       "dev": true,
       "funding": [
         {
@@ -1236,9 +1247,9 @@
       }
     },
     "node_modules/@rc-component/form": {
-      "version": "1.8.3",
-      "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.3.tgz",
-      "integrity": "sha512-jNkat3uxZ246ELudKwnjQhnDI8+rSxgLxjztvQU3Mrb0G+LwDyOrPu9RNfekOjqU5GQ5QJepi225x+9LhCizJw==",
+      "version": "1.8.4",
+      "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.4.tgz",
+      "integrity": "sha512-I2FHNMWoiGQNjC+hQFhAj/rQeScAIBc+AkZqvu4Zyaxe4I3WOVpQte2E5lyZhruswyT8aULYHu1clPaPwE9L2A==",
       "license": "MIT",
       "dependencies": {
         "@rc-component/async-validator": "^6.0.0",
@@ -3096,17 +3107,17 @@
       "license": "MIT"
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "8.61.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
-      "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==",
+      "version": "8.61.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz",
+      "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@eslint-community/regexpp": "^4.12.2",
-        "@typescript-eslint/scope-manager": "8.61.0",
-        "@typescript-eslint/type-utils": "8.61.0",
-        "@typescript-eslint/utils": "8.61.0",
-        "@typescript-eslint/visitor-keys": "8.61.0",
+        "@typescript-eslint/scope-manager": "8.61.1",
+        "@typescript-eslint/type-utils": "8.61.1",
+        "@typescript-eslint/utils": "8.61.1",
+        "@typescript-eslint/visitor-keys": "8.61.1",
         "ignore": "^7.0.5",
         "natural-compare": "^1.4.0",
         "ts-api-utils": "^2.5.0"
@@ -3119,7 +3130,7 @@
         "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependencies": {
-        "@typescript-eslint/parser": "^8.61.0",
+        "@typescript-eslint/parser": "^8.61.1",
         "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
         "typescript": ">=4.8.4 <6.1.0"
       }
@@ -3135,16 +3146,16 @@
       }
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "8.61.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
-      "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
+      "version": "8.61.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz",
+      "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/scope-manager": "8.61.0",
-        "@typescript-eslint/types": "8.61.0",
-        "@typescript-eslint/typescript-estree": "8.61.0",
-        "@typescript-eslint/visitor-keys": "8.61.0",
+        "@typescript-eslint/scope-manager": "8.61.1",
+        "@typescript-eslint/types": "8.61.1",
+        "@typescript-eslint/typescript-estree": "8.61.1",
+        "@typescript-eslint/visitor-keys": "8.61.1",
         "debug": "^4.4.3"
       },
       "engines": {
@@ -3160,14 +3171,14 @@
       }
     },
     "node_modules/@typescript-eslint/project-service": {
-      "version": "8.61.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
-      "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
+      "version": "8.61.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
+      "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/tsconfig-utils": "^8.61.0",
-        "@typescript-eslint/types": "^8.61.0",
+        "@typescript-eslint/tsconfig-utils": "^8.61.1",
+        "@typescript-eslint/types": "^8.61.1",
         "debug": "^4.4.3"
       },
       "engines": {
@@ -3182,14 +3193,14 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "8.61.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
-      "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==",
+      "version": "8.61.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
+      "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/types": "8.61.0",
-        "@typescript-eslint/visitor-keys": "8.61.0"
+        "@typescript-eslint/types": "8.61.1",
+        "@typescript-eslint/visitor-keys": "8.61.1"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3200,9 +3211,9 @@
       }
     },
     "node_modules/@typescript-eslint/tsconfig-utils": {
-      "version": "8.61.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
-      "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
+      "version": "8.61.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
+      "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -3217,15 +3228,15 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "8.61.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz",
-      "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==",
+      "version": "8.61.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz",
+      "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/types": "8.61.0",
-        "@typescript-eslint/typescript-estree": "8.61.0",
-        "@typescript-eslint/utils": "8.61.0",
+        "@typescript-eslint/types": "8.61.1",
+        "@typescript-eslint/typescript-estree": "8.61.1",
+        "@typescript-eslint/utils": "8.61.1",
         "debug": "^4.4.3",
         "ts-api-utils": "^2.5.0"
       },
@@ -3242,9 +3253,9 @@
       }
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "8.61.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
-      "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==",
+      "version": "8.61.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
+      "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -3256,16 +3267,16 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "8.61.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
-      "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
+      "version": "8.61.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
+      "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/project-service": "8.61.0",
-        "@typescript-eslint/tsconfig-utils": "8.61.0",
-        "@typescript-eslint/types": "8.61.0",
-        "@typescript-eslint/visitor-keys": "8.61.0",
+        "@typescript-eslint/project-service": "8.61.1",
+        "@typescript-eslint/tsconfig-utils": "8.61.1",
+        "@typescript-eslint/types": "8.61.1",
+        "@typescript-eslint/visitor-keys": "8.61.1",
         "debug": "^4.4.3",
         "minimatch": "^10.2.2",
         "semver": "^7.7.3",
@@ -3297,16 +3308,16 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "8.61.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz",
-      "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==",
+      "version": "8.61.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz",
+      "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.9.1",
-        "@typescript-eslint/scope-manager": "8.61.0",
-        "@typescript-eslint/types": "8.61.0",
-        "@typescript-eslint/typescript-estree": "8.61.0"
+        "@typescript-eslint/scope-manager": "8.61.1",
+        "@typescript-eslint/types": "8.61.1",
+        "@typescript-eslint/typescript-estree": "8.61.1"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3321,13 +3332,13 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "8.61.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
-      "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==",
+      "version": "8.61.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
+      "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/types": "8.61.0",
+        "@typescript-eslint/types": "8.61.1",
         "eslint-visitor-keys": "^5.0.0"
       },
       "engines": {
@@ -3364,17 +3375,48 @@
         }
       }
     },
+    "node_modules/@vitest/coverage-v8": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
+      "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@bcoe/v8-coverage": "^1.0.2",
+        "@vitest/utils": "4.1.9",
+        "ast-v8-to-istanbul": "^1.0.0",
+        "istanbul-lib-coverage": "^3.2.2",
+        "istanbul-lib-report": "^3.0.1",
+        "istanbul-reports": "^3.2.0",
+        "magicast": "^0.5.2",
+        "obug": "^2.1.1",
+        "std-env": "^4.0.0-rc.1",
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@vitest/browser": "4.1.9",
+        "vitest": "4.1.9"
+      },
+      "peerDependenciesMeta": {
+        "@vitest/browser": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@vitest/expect": {
-      "version": "4.1.8",
-      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
-      "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
+      "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@standard-schema/spec": "^1.1.0",
         "@types/chai": "^5.2.2",
-        "@vitest/spy": "4.1.8",
-        "@vitest/utils": "4.1.8",
+        "@vitest/spy": "4.1.9",
+        "@vitest/utils": "4.1.9",
         "chai": "^6.2.2",
         "tinyrainbow": "^3.1.0"
       },
@@ -3383,13 +3425,13 @@
       }
     },
     "node_modules/@vitest/mocker": {
-      "version": "4.1.8",
-      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
-      "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
+      "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@vitest/spy": "4.1.8",
+        "@vitest/spy": "4.1.9",
         "estree-walker": "^3.0.3",
         "magic-string": "^0.30.21"
       },
@@ -3410,9 +3452,9 @@
       }
     },
     "node_modules/@vitest/pretty-format": {
-      "version": "4.1.8",
-      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
-      "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
+      "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -3423,13 +3465,13 @@
       }
     },
     "node_modules/@vitest/runner": {
-      "version": "4.1.8",
-      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
-      "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
+      "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@vitest/utils": "4.1.8",
+        "@vitest/utils": "4.1.9",
         "pathe": "^2.0.3"
       },
       "funding": {
@@ -3437,14 +3479,14 @@
       }
     },
     "node_modules/@vitest/snapshot": {
-      "version": "4.1.8",
-      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
-      "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
+      "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@vitest/pretty-format": "4.1.8",
-        "@vitest/utils": "4.1.8",
+        "@vitest/pretty-format": "4.1.9",
+        "@vitest/utils": "4.1.9",
         "magic-string": "^0.30.21",
         "pathe": "^2.0.3"
       },
@@ -3453,9 +3495,9 @@
       }
     },
     "node_modules/@vitest/spy": {
-      "version": "4.1.8",
-      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
-      "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
+      "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
       "dev": true,
       "license": "MIT",
       "funding": {
@@ -3463,13 +3505,13 @@
       }
     },
     "node_modules/@vitest/utils": {
-      "version": "4.1.8",
-      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
-      "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
+      "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@vitest/pretty-format": "4.1.8",
+        "@vitest/pretty-format": "4.1.9",
         "convert-source-map": "^2.0.0",
         "tinyrainbow": "^3.1.0"
       },
@@ -3647,6 +3689,25 @@
         "node": ">=12"
       }
     },
+    "node_modules/ast-v8-to-istanbul": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
+      "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/trace-mapping": "^0.3.31",
+        "estree-walker": "^3.0.3",
+        "js-tokens": "^10.0.0"
+      }
+    },
+    "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+      "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/asynckit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -3678,9 +3739,9 @@
       }
     },
     "node_modules/axios": {
-      "version": "1.17.0",
-      "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
-      "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
+      "version": "1.18.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
+      "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
       "license": "MIT",
       "dependencies": {
         "follow-redirects": "^1.16.0",
@@ -4941,6 +5002,16 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/has-property-descriptors": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
@@ -5067,6 +5138,13 @@
         "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
       }
     },
+    "node_modules/html-escaper": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+      "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/html-parse-stringify": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -5320,6 +5398,45 @@
       "dev": true,
       "license": "ISC"
     },
+    "node_modules/istanbul-lib-coverage": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+      "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/istanbul-lib-report": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+      "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "istanbul-lib-coverage": "^3.0.0",
+        "make-dir": "^4.0.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/istanbul-reports": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+      "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "html-escaper": "^2.0.0",
+        "istanbul-lib-report": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/js-file-download": {
       "version": "0.4.12",
       "resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz",
@@ -5333,9 +5450,19 @@
       "license": "MIT"
     },
     "node_modules/js-yaml": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
-      "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
+      "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/puzrin"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/nodeca"
+        }
+      ],
       "license": "MIT",
       "dependencies": {
         "argparse": "^2.0.1"
@@ -5832,6 +5959,47 @@
         "@jridgewell/sourcemap-codec": "^1.5.5"
       }
     },
+    "node_modules/magicast": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
+      "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.3",
+        "@babel/types": "^7.29.0",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/make-dir": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+      "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "semver": "^7.5.3"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/make-dir/node_modules/semver": {
+      "version": "7.8.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
+      "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/math-intrinsics": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7033,6 +7201,19 @@
       "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
       "license": "MIT"
     },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/swagger-client": {
       "version": "3.37.4",
       "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.37.4.tgz",
@@ -7175,22 +7356,22 @@
       }
     },
     "node_modules/tldts": {
-      "version": "7.4.2",
-      "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
-      "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
+      "version": "7.4.3",
+      "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz",
+      "integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "tldts-core": "^7.4.2"
+        "tldts-core": "^7.4.3"
       },
       "bin": {
         "tldts": "bin/cli.js"
       }
     },
     "node_modules/tldts-core": {
-      "version": "7.4.2",
-      "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
-      "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
+      "version": "7.4.3",
+      "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz",
+      "integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==",
       "dev": true,
       "license": "MIT"
     },
@@ -7366,16 +7547,16 @@
       }
     },
     "node_modules/typescript-eslint": {
-      "version": "8.61.0",
-      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz",
-      "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==",
+      "version": "8.61.1",
+      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz",
+      "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/eslint-plugin": "8.61.0",
-        "@typescript-eslint/parser": "8.61.0",
-        "@typescript-eslint/typescript-estree": "8.61.0",
-        "@typescript-eslint/utils": "8.61.0"
+        "@typescript-eslint/eslint-plugin": "8.61.1",
+        "@typescript-eslint/parser": "8.61.1",
+        "@typescript-eslint/typescript-estree": "8.61.1",
+        "@typescript-eslint/utils": "8.61.1"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7390,9 +7571,9 @@
       }
     },
     "node_modules/undici": {
-      "version": "7.27.2",
-      "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
-      "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+      "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -7566,19 +7747,19 @@
       }
     },
     "node_modules/vitest": {
-      "version": "4.1.8",
-      "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
-      "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
+      "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@vitest/expect": "4.1.8",
-        "@vitest/mocker": "4.1.8",
-        "@vitest/pretty-format": "4.1.8",
-        "@vitest/runner": "4.1.8",
-        "@vitest/snapshot": "4.1.8",
-        "@vitest/spy": "4.1.8",
-        "@vitest/utils": "4.1.8",
+        "@vitest/expect": "4.1.9",
+        "@vitest/mocker": "4.1.9",
+        "@vitest/pretty-format": "4.1.9",
+        "@vitest/runner": "4.1.9",
+        "@vitest/snapshot": "4.1.9",
+        "@vitest/spy": "4.1.9",
+        "@vitest/utils": "4.1.9",
         "es-module-lexer": "^2.0.0",
         "expect-type": "^1.3.0",
         "magic-string": "^0.30.21",
@@ -7606,12 +7787,12 @@
         "@edge-runtime/vm": "*",
         "@opentelemetry/api": "^1.9.0",
         "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
-        "@vitest/browser-playwright": "4.1.8",
-        "@vitest/browser-preview": "4.1.8",
-        "@vitest/browser-webdriverio": "4.1.8",
-        "@vitest/coverage-istanbul": "4.1.8",
-        "@vitest/coverage-v8": "4.1.8",
-        "@vitest/ui": "4.1.8",
+        "@vitest/browser-playwright": "4.1.9",
+        "@vitest/browser-preview": "4.1.9",
+        "@vitest/browser-webdriverio": "4.1.9",
+        "@vitest/coverage-istanbul": "4.1.9",
+        "@vitest/coverage-v8": "4.1.9",
+        "@vitest/ui": "4.1.9",
         "happy-dom": "*",
         "jsdom": "*",
         "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

+ 12 - 8
frontend/package.json

@@ -26,8 +26,8 @@
     "@codemirror/theme-one-dark": "^6.1.3",
     "@tanstack/react-query": "^5.101.0",
     "@tanstack/react-query-devtools": "^5.101.0",
-    "antd": "^6.4.3",
-    "axios": "^1.17.0",
+    "antd": "^6.4.4",
+    "axios": "^1.18.0",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.21",
     "i18next": "^26.3.1",
@@ -37,7 +37,7 @@
     "react": "^19.2.7",
     "react-dom": "^19.2.7",
     "react-i18next": "^17.0.8",
-    "react-router-dom": "^7.16.0",
+    "react-router-dom": "^7.17.0",
     "recharts": "^3.8.1",
     "swagger-ui-react": "^5.32.6",
     "zod": "^4.4.3"
@@ -46,24 +46,28 @@
     "@eslint/js": "^10.0.1",
     "@testing-library/dom": "^10.4.1",
     "@testing-library/react": "^16.3.2",
-    "@types/react": "^19.2.16",
+    "@types/react": "^19.2.17",
     "@types/react-dom": "^19.2.3",
     "@types/swagger-ui-react": "^5.18.0",
     "@vitejs/plugin-react": "^6.0.2",
-    "eslint": "^10.4.1",
+    "@vitest/coverage-v8": "^4.1.9",
+    "eslint": "^10.5.0",
     "eslint-plugin-react-hooks": "^7.1.1",
     "globals": "^17.6.0",
     "jsdom": "^29.1.1",
     "typescript": "^6.0.3",
-    "typescript-eslint": "^8.60.1",
+    "typescript-eslint": "^8.61.1",
     "vite": "8.0.16",
-    "vitest": "^4.1.8"
+    "vitest": "^4.1.9"
   },
   "overrides": {
     "react-copy-to-clipboard": "^5.1.1",
     "react-inspector": "^9.0.0",
     "react-debounce-input": {
       "react": "^19.0.0"
+    },
+    "swagger-ui-react": {
+      "js-yaml": "^4.2.0"
     }
   },
   "allowScripts": {
@@ -73,4 +77,4 @@
     "core-js-pure": false,
     "tree-sitter-json": false
   }
-}
+}

+ 181 - 10
frontend/public/openapi.json

@@ -138,6 +138,46 @@
             "minimum": 1,
             "type": "integer"
           },
+          "smtpCpu": {
+            "description": "CPU threshold for email notifications",
+            "maximum": 100,
+            "minimum": 0,
+            "type": "integer"
+          },
+          "smtpEnable": {
+            "description": "Email (SMTP) notification settings\nEnable email notifications",
+            "type": "boolean"
+          },
+          "smtpEnabledEvents": {
+            "description": "Comma-separated event types to send via email",
+            "type": "string"
+          },
+          "smtpEncryptionType": {
+            "description": "SMTP encryption: none, starttls, tls",
+            "type": "string"
+          },
+          "smtpHost": {
+            "description": "SMTP server host",
+            "type": "string"
+          },
+          "smtpPassword": {
+            "description": "SMTP password",
+            "type": "string"
+          },
+          "smtpPort": {
+            "description": "SMTP server port",
+            "maximum": 65535,
+            "minimum": 1,
+            "type": "integer"
+          },
+          "smtpTo": {
+            "description": "Comma-separated recipient emails",
+            "type": "string"
+          },
+          "smtpUsername": {
+            "description": "SMTP username",
+            "type": "string"
+          },
           "subAnnounce": {
             "description": "Subscription announce",
             "type": "string"
@@ -277,10 +317,6 @@
             "description": "Telegram bot settings\nEnable Telegram bot notifications",
             "type": "boolean"
           },
-          "tgBotLoginNotify": {
-            "description": "Send login notifications",
-            "type": "boolean"
-          },
           "tgBotProxy": {
             "description": "Proxy URL for Telegram bot",
             "type": "string"
@@ -295,6 +331,10 @@
             "minimum": 0,
             "type": "integer"
           },
+          "tgEnabledEvents": {
+            "description": "Comma-separated event types to send via Telegram",
+            "type": "string"
+          },
           "tgLang": {
             "description": "Telegram bot language",
             "type": "string"
@@ -387,6 +427,15 @@
           "remarkModel",
           "restartXrayOnClientDisable",
           "sessionMaxAge",
+          "smtpCpu",
+          "smtpEnable",
+          "smtpEnabledEvents",
+          "smtpEncryptionType",
+          "smtpHost",
+          "smtpPassword",
+          "smtpPort",
+          "smtpTo",
+          "smtpUsername",
           "subAnnounce",
           "subCertFile",
           "subClashEnable",
@@ -421,10 +470,10 @@
           "tgBotBackup",
           "tgBotChatId",
           "tgBotEnable",
-          "tgBotLoginNotify",
           "tgBotProxy",
           "tgBotToken",
           "tgCpu",
+          "tgEnabledEvents",
           "tgLang",
           "tgRunTime",
           "timeLocation",
@@ -471,6 +520,9 @@
           "hasNordSecret": {
             "type": "boolean"
           },
+          "hasSmtpPassword": {
+            "type": "boolean"
+          },
           "hasTgBotToken": {
             "type": "boolean"
           },
@@ -572,6 +624,46 @@
             "minimum": 1,
             "type": "integer"
           },
+          "smtpCpu": {
+            "description": "CPU threshold for email notifications",
+            "maximum": 100,
+            "minimum": 0,
+            "type": "integer"
+          },
+          "smtpEnable": {
+            "description": "Email (SMTP) notification settings\nEnable email notifications",
+            "type": "boolean"
+          },
+          "smtpEnabledEvents": {
+            "description": "Comma-separated event types to send via email",
+            "type": "string"
+          },
+          "smtpEncryptionType": {
+            "description": "SMTP encryption: none, starttls, tls",
+            "type": "string"
+          },
+          "smtpHost": {
+            "description": "SMTP server host",
+            "type": "string"
+          },
+          "smtpPassword": {
+            "description": "SMTP password",
+            "type": "string"
+          },
+          "smtpPort": {
+            "description": "SMTP server port",
+            "maximum": 65535,
+            "minimum": 1,
+            "type": "integer"
+          },
+          "smtpTo": {
+            "description": "Comma-separated recipient emails",
+            "type": "string"
+          },
+          "smtpUsername": {
+            "description": "SMTP username",
+            "type": "string"
+          },
           "subAnnounce": {
             "description": "Subscription announce",
             "type": "string"
@@ -711,10 +803,6 @@
             "description": "Telegram bot settings\nEnable Telegram bot notifications",
             "type": "boolean"
           },
-          "tgBotLoginNotify": {
-            "description": "Send login notifications",
-            "type": "boolean"
-          },
           "tgBotProxy": {
             "description": "Proxy URL for Telegram bot",
             "type": "string"
@@ -729,6 +817,10 @@
             "minimum": 0,
             "type": "integer"
           },
+          "tgEnabledEvents": {
+            "description": "Comma-separated event types to send via Telegram",
+            "type": "string"
+          },
           "tgLang": {
             "description": "Telegram bot language",
             "type": "string"
@@ -799,6 +891,7 @@
           "hasApiToken",
           "hasLdapPassword",
           "hasNordSecret",
+          "hasSmtpPassword",
           "hasTgBotToken",
           "hasTwoFactorToken",
           "hasWarpSecret",
@@ -827,6 +920,15 @@
           "remarkModel",
           "restartXrayOnClientDisable",
           "sessionMaxAge",
+          "smtpCpu",
+          "smtpEnable",
+          "smtpEnabledEvents",
+          "smtpEncryptionType",
+          "smtpHost",
+          "smtpPassword",
+          "smtpPort",
+          "smtpTo",
+          "smtpUsername",
           "subAnnounce",
           "subCertFile",
           "subClashEnable",
@@ -861,10 +963,10 @@
           "tgBotBackup",
           "tgBotChatId",
           "tgBotEnable",
-          "tgBotLoginNotify",
           "tgBotProxy",
           "tgBotToken",
           "tgCpu",
+          "tgEnabledEvents",
           "tgLang",
           "tgRunTime",
           "timeLocation",
@@ -7009,6 +7111,75 @@
         }
       }
     },
+    "/panel/api/setting/testSmtp": {
+      "post": {
+        "tags": [
+          "Settings"
+        ],
+        "summary": "Test SMTP connection with stage-by-stage reporting (connect, auth, send). Returns structured result with stage and message.",
+        "operationId": "post_panel_api_setting_testSmtp",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "stage": "send",
+                  "msg": "Test email sent successfully"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/setting/testTgBot": {
+      "post": {
+        "tags": [
+          "Settings"
+        ],
+        "summary": "Test Telegram bot connection by sending a test message to the configured chat.",
+        "operationId": "post_panel_api_setting_testTgBot",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "msg": "Test message sent to Telegram"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/setting/getDefaultJsonConfig": {
       "get": {
         "tags": [

+ 36 - 0
frontend/src/components/form/DateTimePicker.css

@@ -1,5 +1,6 @@
 .jdp-wrap {
   width: 100%;
+  position: relative;
 }
 
 .jdp-wrap > * {
@@ -33,3 +34,38 @@
   pointer-events: none;
   opacity: 0.6;
 }
+
+/* persian-calendar-suite has no allowClear; overlay our own clear button so
+   the Jalali picker matches the Gregorian AntD DatePicker's X affordance. */
+.jdp-wrap .jdp-clear {
+  position: absolute;
+  top: 50%;
+  right: 11px;
+  transform: translateY(-50%);
+  z-index: 1;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: auto;
+  padding: 0;
+  border: none;
+  background: transparent;
+  cursor: pointer;
+  font-size: 12px;
+  line-height: 1;
+  color: rgba(0, 0, 0, 0.25);
+  transition: color 0.2s;
+}
+
+.jdp-wrap .jdp-clear:hover {
+  color: rgba(0, 0, 0, 0.45);
+}
+
+.jdp-dark .jdp-clear {
+  color: rgba(255, 255, 255, 0.30);
+}
+
+.jdp-dark .jdp-clear:hover,
+.jdp-ultra .jdp-clear:hover {
+  color: rgba(255, 255, 255, 0.45);
+}

+ 33 - 2
frontend/src/components/form/DateTimePicker.tsx

@@ -1,4 +1,5 @@
-import { useMemo } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { CloseCircleFilled } from '@ant-design/icons';
 import { DatePicker } from 'antd';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
@@ -54,6 +55,10 @@ export default function DateTimePicker({
 }: DateTimePickerProps) {
   const { datepicker } = useDatepicker();
   const { isDark, isUltra } = useTheme();
+  const jalaliRef = useRef<HTMLDivElement>(null);
+  // Bumped on clear: persian-calendar-suite reads `value` only on mount, so
+  // remounting via key is the only way to reflect an externally cleared value.
+  const [clearNonce, setClearNonce] = useState(0);
 
   const persianTheme = useMemo(() => {
     if (isUltra) return ULTRA_DARK_THEME;
@@ -61,10 +66,21 @@ export default function DateTimePicker({
     return LIGHT_THEME;
   }, [isDark, isUltra]);
 
+  // The library hardcodes a Persian placeholder and exposes no working prop to
+  // override it, so clear it (or apply the caller's) on the input directly so
+  // the empty field shows no leftover Persian text. No dep array: re-apply
+  // after every render (incl. clear-remounts).
+  useEffect(() => {
+    if (datepicker !== 'jalalian') return;
+    const input = jalaliRef.current?.querySelector('input');
+    if (input) input.placeholder = placeholder;
+  });
+
   if (datepicker === 'jalalian') {
     return (
-      <div className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
+      <div ref={jalaliRef} className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
         <PersianDateTimePicker
+          key={clearNonce}
           value={value ? value.valueOf() : null}
           onChange={(next: number | string | null) => {
             if (next == null || next === '') {
@@ -80,6 +96,21 @@ export default function DateTimePicker({
           rtlCalendar
           theme={persianTheme}
         />
+        {value && !disabled && (
+          <button
+            type="button"
+            className="jdp-clear"
+            aria-label="clear"
+            onMouseDown={(e) => e.preventDefault()}
+            onClick={(e) => {
+              e.stopPropagation();
+              onChange(null);
+              setClearNonce((n) => n + 1);
+            }}
+          >
+            <CloseCircleFilled />
+          </button>
+        )}
       </div>
     );
   }

+ 147 - 0
frontend/src/components/ui/EventBusCheckboxes.tsx

@@ -0,0 +1,147 @@
+import { Checkbox, Collapse, InputNumber, Space } from 'antd';
+import { DownOutlined, RightOutlined } from '@ant-design/icons';
+import { useTranslation } from 'react-i18next';
+
+interface EventGroup {
+  key: string;
+  labelKey: string;
+  events: { value: string; labelKey: string }[];
+}
+
+const EVENT_GROUPS: EventGroup[] = [
+  {
+    key: 'outbound',
+    labelKey: 'pages.settings.eventGroupOutbound',
+    events: [
+      { value: 'outbound.down', labelKey: 'pages.settings.eventOutboundDown' },
+      { value: 'outbound.up', labelKey: 'pages.settings.eventOutboundUp' },
+    ],
+  },
+  {
+    key: 'xray',
+    labelKey: 'pages.settings.eventGroupXray',
+    events: [
+      { value: 'xray.crash', labelKey: 'pages.settings.eventXrayCrash' },
+    ],
+  },
+  {
+    key: 'node',
+    labelKey: 'pages.settings.eventGroupNode',
+    events: [
+      { value: 'node.down', labelKey: 'pages.settings.eventNodeDown' },
+      { value: 'node.up', labelKey: 'pages.settings.eventNodeUp' },
+    ],
+  },
+  {
+    key: 'system',
+    labelKey: 'pages.settings.eventGroupSystem',
+    events: [
+      { value: 'cpu.high', labelKey: 'pages.settings.eventCPUHigh' },
+    ],
+  },
+  {
+    key: 'security',
+    labelKey: 'pages.settings.eventGroupSecurity',
+    events: [
+      { value: 'login.attempt', labelKey: 'pages.settings.eventLoginAttempt' },
+    ],
+  },
+];
+
+interface EventBusCheckboxesProps {
+  value: string;
+  onChange: (v: string) => void;
+  /** Maps event value → { key: setting field name, value: current value } for inline inputs */
+  extra?: Record<string, { key: string; value: number }>;
+  /** Callback when extra input changes: (settingKey, newValue) => void */
+  onExtraChange?: (key: string, v: number | null) => void;
+}
+
+export function EventBusCheckboxes({ value, onChange, extra, onExtraChange }: EventBusCheckboxesProps) {
+  const { t } = useTranslation();
+  const selected = value ? value.split(',').map((s) => s.trim()).filter(Boolean) : [];
+
+  function toggle(eventType: string) {
+    const next = selected.includes(eventType)
+      ? selected.filter((e) => e !== eventType)
+      : [...selected, eventType];
+    onChange(next.join(','));
+  }
+
+  function toggleGroup(group: EventGroup) {
+    const groupValues = group.events.map((e) => e.value);
+    const allSelected = groupValues.every((v) => selected.includes(v));
+    let next: string[];
+    if (allSelected) {
+      next = selected.filter((v) => !groupValues.includes(v));
+    } else {
+      next = [...new Set([...selected, ...groupValues])];
+    }
+    onChange(next.join(','));
+  }
+
+  const items = EVENT_GROUPS.map((group) => {
+    const count = group.events.filter((e) => selected.includes(e.value)).length;
+    const total = group.events.length;
+    const allSelected = count === total;
+
+    return {
+      key: group.key,
+      label: (
+        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+          <span style={{ fontWeight: 500 }}>{t(group.labelKey)}</span>
+          <span style={{ color: '#999', fontSize: 12 }}>
+            {count}/{total}
+          </span>
+          <Checkbox
+            checked={allSelected}
+            indeterminate={count > 0 && count < total}
+            onClick={(e) => e.stopPropagation()}
+            onChange={() => toggleGroup(group)}
+          />
+        </div>
+      ),
+      children: (
+        <Checkbox.Group value={selected} style={{ width: '100%' }}>
+          <Space wrap size={[16, 4]}>
+            {group.events.map((et) => {
+              const checked = selected.includes(et.value);
+              const extraConf = extra?.[et.value];
+              return (
+                <span key={et.value} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
+                  <Checkbox value={et.value} onChange={() => toggle(et.value)}>
+                    {t(et.labelKey)}
+                  </Checkbox>
+                  {extraConf && onExtraChange && (
+                    <InputNumber
+                      size="small"
+                      min={0}
+                      max={100}
+                      value={extraConf.value}
+                      disabled={!checked}
+                      onChange={(v) => onExtraChange(extraConf.key, v)}
+                      style={{ width: 60 }}
+                    />
+                  )}
+                </span>
+              );
+            })}
+          </Space>
+        </Checkbox.Group>
+      ),
+    };
+  });
+
+  const defaultActiveKeys = EVENT_GROUPS
+    .filter((g) => g.events.some((e) => selected.includes(e.value)))
+    .map((g) => g.key);
+
+  return (
+    <Collapse
+      items={items}
+      defaultActiveKey={defaultActiveKeys.length > 0 ? defaultActiveKeys : ['outbound']}
+      expandIcon={({ isActive }) => isActive ? <DownOutlined /> : <RightOutlined />}
+      size="small"
+    />
+  );
+}

+ 1 - 0
frontend/src/components/ui/index.ts

@@ -1,3 +1,4 @@
 export { default as InputAddon } from './InputAddon';
 export { default as InfinityIcon } from './InfinityIcon';
 export { default as SettingListItem } from './SettingListItem';
+export { EventBusCheckboxes } from './EventBusCheckboxes';

+ 21 - 2
frontend/src/generated/examples.ts

@@ -30,6 +30,15 @@ export const EXAMPLES: Record<string, unknown> = {
     "remarkModel": "",
     "restartXrayOnClientDisable": false,
     "sessionMaxAge": 1,
+    "smtpCpu": 0,
+    "smtpEnable": false,
+    "smtpEnabledEvents": "",
+    "smtpEncryptionType": "",
+    "smtpHost": "",
+    "smtpPassword": "",
+    "smtpPort": 1,
+    "smtpTo": "",
+    "smtpUsername": "",
     "subAnnounce": "",
     "subCertFile": "",
     "subClashEnable": false,
@@ -64,10 +73,10 @@ export const EXAMPLES: Record<string, unknown> = {
     "tgBotBackup": false,
     "tgBotChatId": "",
     "tgBotEnable": false,
-    "tgBotLoginNotify": false,
     "tgBotProxy": "",
     "tgBotToken": "",
     "tgCpu": 0,
+    "tgEnabledEvents": "",
     "tgLang": "",
     "tgRunTime": "",
     "timeLocation": "",
@@ -91,6 +100,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "hasApiToken": false,
     "hasLdapPassword": false,
     "hasNordSecret": false,
+    "hasSmtpPassword": false,
     "hasTgBotToken": false,
     "hasTwoFactorToken": false,
     "hasWarpSecret": false,
@@ -119,6 +129,15 @@ export const EXAMPLES: Record<string, unknown> = {
     "remarkModel": "",
     "restartXrayOnClientDisable": false,
     "sessionMaxAge": 1,
+    "smtpCpu": 0,
+    "smtpEnable": false,
+    "smtpEnabledEvents": "",
+    "smtpEncryptionType": "",
+    "smtpHost": "",
+    "smtpPassword": "",
+    "smtpPort": 1,
+    "smtpTo": "",
+    "smtpUsername": "",
     "subAnnounce": "",
     "subCertFile": "",
     "subClashEnable": false,
@@ -153,10 +172,10 @@ export const EXAMPLES: Record<string, unknown> = {
     "tgBotBackup": false,
     "tgBotChatId": "",
     "tgBotEnable": false,
-    "tgBotLoginNotify": false,
     "tgBotProxy": "",
     "tgBotToken": "",
     "tgCpu": 0,
+    "tgEnabledEvents": "",
     "tgLang": "",
     "tgRunTime": "",
     "timeLocation": "",

+ 112 - 10
frontend/src/generated/schemas.ts

@@ -112,6 +112,46 @@ export const SCHEMAS: Record<string, unknown> = {
         "minimum": 1,
         "type": "integer"
       },
+      "smtpCpu": {
+        "description": "CPU threshold for email notifications",
+        "maximum": 100,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "smtpEnable": {
+        "description": "Email (SMTP) notification settings\nEnable email notifications",
+        "type": "boolean"
+      },
+      "smtpEnabledEvents": {
+        "description": "Comma-separated event types to send via email",
+        "type": "string"
+      },
+      "smtpEncryptionType": {
+        "description": "SMTP encryption: none, starttls, tls",
+        "type": "string"
+      },
+      "smtpHost": {
+        "description": "SMTP server host",
+        "type": "string"
+      },
+      "smtpPassword": {
+        "description": "SMTP password",
+        "type": "string"
+      },
+      "smtpPort": {
+        "description": "SMTP server port",
+        "maximum": 65535,
+        "minimum": 1,
+        "type": "integer"
+      },
+      "smtpTo": {
+        "description": "Comma-separated recipient emails",
+        "type": "string"
+      },
+      "smtpUsername": {
+        "description": "SMTP username",
+        "type": "string"
+      },
       "subAnnounce": {
         "description": "Subscription announce",
         "type": "string"
@@ -251,10 +291,6 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Telegram bot settings\nEnable Telegram bot notifications",
         "type": "boolean"
       },
-      "tgBotLoginNotify": {
-        "description": "Send login notifications",
-        "type": "boolean"
-      },
       "tgBotProxy": {
         "description": "Proxy URL for Telegram bot",
         "type": "string"
@@ -269,6 +305,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "minimum": 0,
         "type": "integer"
       },
+      "tgEnabledEvents": {
+        "description": "Comma-separated event types to send via Telegram",
+        "type": "string"
+      },
       "tgLang": {
         "description": "Telegram bot language",
         "type": "string"
@@ -361,6 +401,15 @@ export const SCHEMAS: Record<string, unknown> = {
       "remarkModel",
       "restartXrayOnClientDisable",
       "sessionMaxAge",
+      "smtpCpu",
+      "smtpEnable",
+      "smtpEnabledEvents",
+      "smtpEncryptionType",
+      "smtpHost",
+      "smtpPassword",
+      "smtpPort",
+      "smtpTo",
+      "smtpUsername",
       "subAnnounce",
       "subCertFile",
       "subClashEnable",
@@ -395,10 +444,10 @@ export const SCHEMAS: Record<string, unknown> = {
       "tgBotBackup",
       "tgBotChatId",
       "tgBotEnable",
-      "tgBotLoginNotify",
       "tgBotProxy",
       "tgBotToken",
       "tgCpu",
+      "tgEnabledEvents",
       "tgLang",
       "tgRunTime",
       "timeLocation",
@@ -445,6 +494,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "hasNordSecret": {
         "type": "boolean"
       },
+      "hasSmtpPassword": {
+        "type": "boolean"
+      },
       "hasTgBotToken": {
         "type": "boolean"
       },
@@ -546,6 +598,46 @@ export const SCHEMAS: Record<string, unknown> = {
         "minimum": 1,
         "type": "integer"
       },
+      "smtpCpu": {
+        "description": "CPU threshold for email notifications",
+        "maximum": 100,
+        "minimum": 0,
+        "type": "integer"
+      },
+      "smtpEnable": {
+        "description": "Email (SMTP) notification settings\nEnable email notifications",
+        "type": "boolean"
+      },
+      "smtpEnabledEvents": {
+        "description": "Comma-separated event types to send via email",
+        "type": "string"
+      },
+      "smtpEncryptionType": {
+        "description": "SMTP encryption: none, starttls, tls",
+        "type": "string"
+      },
+      "smtpHost": {
+        "description": "SMTP server host",
+        "type": "string"
+      },
+      "smtpPassword": {
+        "description": "SMTP password",
+        "type": "string"
+      },
+      "smtpPort": {
+        "description": "SMTP server port",
+        "maximum": 65535,
+        "minimum": 1,
+        "type": "integer"
+      },
+      "smtpTo": {
+        "description": "Comma-separated recipient emails",
+        "type": "string"
+      },
+      "smtpUsername": {
+        "description": "SMTP username",
+        "type": "string"
+      },
       "subAnnounce": {
         "description": "Subscription announce",
         "type": "string"
@@ -685,10 +777,6 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Telegram bot settings\nEnable Telegram bot notifications",
         "type": "boolean"
       },
-      "tgBotLoginNotify": {
-        "description": "Send login notifications",
-        "type": "boolean"
-      },
       "tgBotProxy": {
         "description": "Proxy URL for Telegram bot",
         "type": "string"
@@ -703,6 +791,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "minimum": 0,
         "type": "integer"
       },
+      "tgEnabledEvents": {
+        "description": "Comma-separated event types to send via Telegram",
+        "type": "string"
+      },
       "tgLang": {
         "description": "Telegram bot language",
         "type": "string"
@@ -773,6 +865,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "hasApiToken",
       "hasLdapPassword",
       "hasNordSecret",
+      "hasSmtpPassword",
       "hasTgBotToken",
       "hasTwoFactorToken",
       "hasWarpSecret",
@@ -801,6 +894,15 @@ export const SCHEMAS: Record<string, unknown> = {
       "remarkModel",
       "restartXrayOnClientDisable",
       "sessionMaxAge",
+      "smtpCpu",
+      "smtpEnable",
+      "smtpEnabledEvents",
+      "smtpEncryptionType",
+      "smtpHost",
+      "smtpPassword",
+      "smtpPort",
+      "smtpTo",
+      "smtpUsername",
       "subAnnounce",
       "subCertFile",
       "subClashEnable",
@@ -835,10 +937,10 @@ export const SCHEMAS: Record<string, unknown> = {
       "tgBotBackup",
       "tgBotChatId",
       "tgBotEnable",
-      "tgBotLoginNotify",
       "tgBotProxy",
       "tgBotToken",
       "tgCpu",
+      "tgEnabledEvents",
       "tgLang",
       "tgRunTime",
       "timeLocation",

+ 21 - 2
frontend/src/generated/types.ts

@@ -35,6 +35,15 @@ export interface AllSetting {
   remarkModel: string;
   restartXrayOnClientDisable: boolean;
   sessionMaxAge: number;
+  smtpCpu: number;
+  smtpEnable: boolean;
+  smtpEnabledEvents: string;
+  smtpEncryptionType: string;
+  smtpHost: string;
+  smtpPassword: string;
+  smtpPort: number;
+  smtpTo: string;
+  smtpUsername: string;
   subAnnounce: string;
   subCertFile: string;
   subClashEnable: boolean;
@@ -69,10 +78,10 @@ export interface AllSetting {
   tgBotBackup: boolean;
   tgBotChatId: string;
   tgBotEnable: boolean;
-  tgBotLoginNotify: boolean;
   tgBotProxy: string;
   tgBotToken: string;
   tgCpu: number;
+  tgEnabledEvents: string;
   tgLang: string;
   tgRunTime: string;
   timeLocation: string;
@@ -97,6 +106,7 @@ export interface AllSettingView {
   hasApiToken: boolean;
   hasLdapPassword: boolean;
   hasNordSecret: boolean;
+  hasSmtpPassword: boolean;
   hasTgBotToken: boolean;
   hasTwoFactorToken: boolean;
   hasWarpSecret: boolean;
@@ -125,6 +135,15 @@ export interface AllSettingView {
   remarkModel: string;
   restartXrayOnClientDisable: boolean;
   sessionMaxAge: number;
+  smtpCpu: number;
+  smtpEnable: boolean;
+  smtpEnabledEvents: string;
+  smtpEncryptionType: string;
+  smtpHost: string;
+  smtpPassword: string;
+  smtpPort: number;
+  smtpTo: string;
+  smtpUsername: string;
   subAnnounce: string;
   subCertFile: string;
   subClashEnable: boolean;
@@ -159,10 +178,10 @@ export interface AllSettingView {
   tgBotBackup: boolean;
   tgBotChatId: string;
   tgBotEnable: boolean;
-  tgBotLoginNotify: boolean;
   tgBotProxy: string;
   tgBotToken: string;
   tgCpu: number;
+  tgEnabledEvents: string;
   tgLang: string;
   tgRunTime: string;
   timeLocation: string;

+ 21 - 2
frontend/src/generated/zod.ts

@@ -45,6 +45,15 @@ export const AllSettingSchema = z.object({
   remarkModel: z.string(),
   restartXrayOnClientDisable: z.boolean(),
   sessionMaxAge: z.number().int().min(1).max(525600),
+  smtpCpu: z.number().int().min(0).max(100),
+  smtpEnable: z.boolean(),
+  smtpEnabledEvents: z.string(),
+  smtpEncryptionType: z.string(),
+  smtpHost: z.string(),
+  smtpPassword: z.string(),
+  smtpPort: z.number().int().min(1).max(65535),
+  smtpTo: z.string(),
+  smtpUsername: z.string(),
   subAnnounce: z.string(),
   subCertFile: z.string(),
   subClashEnable: z.boolean(),
@@ -79,10 +88,10 @@ export const AllSettingSchema = z.object({
   tgBotBackup: z.boolean(),
   tgBotChatId: z.string(),
   tgBotEnable: z.boolean(),
-  tgBotLoginNotify: z.boolean(),
   tgBotProxy: z.string(),
   tgBotToken: z.string(),
   tgCpu: z.number().int().min(0).max(100),
+  tgEnabledEvents: z.string(),
   tgLang: z.string(),
   tgRunTime: z.string(),
   timeLocation: z.string(),
@@ -108,6 +117,7 @@ export const AllSettingViewSchema = z.object({
   hasApiToken: z.boolean(),
   hasLdapPassword: z.boolean(),
   hasNordSecret: z.boolean(),
+  hasSmtpPassword: z.boolean(),
   hasTgBotToken: z.boolean(),
   hasTwoFactorToken: z.boolean(),
   hasWarpSecret: z.boolean(),
@@ -136,6 +146,15 @@ export const AllSettingViewSchema = z.object({
   remarkModel: z.string(),
   restartXrayOnClientDisable: z.boolean(),
   sessionMaxAge: z.number().int().min(1).max(525600),
+  smtpCpu: z.number().int().min(0).max(100),
+  smtpEnable: z.boolean(),
+  smtpEnabledEvents: z.string(),
+  smtpEncryptionType: z.string(),
+  smtpHost: z.string(),
+  smtpPassword: z.string(),
+  smtpPort: z.number().int().min(1).max(65535),
+  smtpTo: z.string(),
+  smtpUsername: z.string(),
   subAnnounce: z.string(),
   subCertFile: z.string(),
   subClashEnable: z.boolean(),
@@ -170,10 +189,10 @@ export const AllSettingViewSchema = z.object({
   tgBotBackup: z.boolean(),
   tgBotChatId: z.string(),
   tgBotEnable: z.boolean(),
-  tgBotLoginNotify: z.boolean(),
   tgBotProxy: z.string(),
   tgBotToken: z.string(),
   tgCpu: z.number().int().min(0).max(100),
+  tgEnabledEvents: z.string(),
   tgLang: z.string(),
   tgRunTime: z.string(),
   timeLocation: z.string(),

+ 2 - 0
frontend/src/layouts/AppSidebar.tsx

@@ -16,6 +16,7 @@ import {
   HeartOutlined,
   ImportOutlined,
   LogoutOutlined,
+  MailOutlined,
   MenuOutlined,
   MessageOutlined,
   MoonFilled,
@@ -153,6 +154,7 @@ export default function AppSidebar() {
       { key: '/settings#general', icon: <SettingOutlined />, label: t('pages.settings.panelSettings') },
       { key: '/settings#security', icon: <SafetyOutlined />, label: t('pages.settings.securitySettings') },
       { key: '/settings#telegram', icon: <MessageOutlined />, label: t('pages.settings.TGBotSettings') },
+      { key: '/settings#email', icon: <MailOutlined />, label: t('pages.settings.emailSettings') },
       { key: '/settings#subscription', icon: <CloudServerOutlined />, label: t('pages.settings.subSettings') },
     ];
     if (showSubFormats) {

+ 14 - 3
frontend/src/lib/xray/inbound-link.ts

@@ -12,6 +12,7 @@ import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalma
 import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
 
 import { getHeaderValue } from './headers';
+import { canEnableTlsFlow } from './protocol-capabilities';
 
 // Share-link generators. Each per-protocol fn takes a typed inbound plus
 // client overrides and returns a URL (or '' when the protocol doesn't
@@ -186,7 +187,7 @@ export function genVmessLink(input: GenVmessLinkInput): string {
   const stream = inbound.streamSettings;
   if (!stream) return '';
 
-  const tls = forceTls === 'same' ? stream.security : forceTls;
+  const tls = forceTls === 'same' ? (stream.security ?? 'none') : forceTls;
   const obj: Record<string, unknown> = {
     v: '2',
     ps: remark,
@@ -382,7 +383,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
       if (tls.settings.pinnedPeerCertSha256.length > 0) {
         params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
       }
-      if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
     }
     applyExternalProxyTLSParams(externalProxy, params, security);
   } else if (security === 'reality') {
@@ -402,12 +402,23 @@ export function genVlessLink(input: GenVlessLinkInput): string {
       if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
       if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
       if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
-      if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
     }
   } else {
     params.set('security', 'none');
   }
 
+  // XTLS Vision flow: TCP over tls/reality (classic) or XHTTP+vlessenc (the
+  // VLESS-level encryption stands in for transport TLS). Mirrors the backend's
+  // vlessFlowAllowed and the form's flow-field gating so panel link, share
+  // link and subscription agree.
+  if (flow.length > 0 && canEnableTlsFlow({
+    protocol: inbound.protocol,
+    settings: inbound.settings,
+    streamSettings: stream,
+  })) {
+    params.set('flow', flow);
+  }
+
   const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`);
   for (const [key, value] of params) url.searchParams.set(key, value);
   url.hash = encodeURIComponent(remark);

+ 11 - 2
frontend/src/lib/xray/outbound-link-parser.ts

@@ -9,8 +9,9 @@ import { Base64 } from '@/utils';
 // fields the common vmess:// / vless:// links carry as query params.
 // XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes,
 // scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when
-// present in either the JSON or URL params. xmux, reality shortIds,
-// padding obfs key/header/placement, hysteria udphop are still left
+// present in either the JSON or URL params. xmux and downloadSettings
+// round-trip through the `extra` JSON blob. reality shortIds, padding
+// obfs key/header/placement, hysteria udphop are still left
 // to the user to fill in after import — the legacy Outbound.fromLink
 // was ~250 lines of dense edge-case handling we don't need to
 // replicate verbatim for the common phone-to-panel workflow.
@@ -33,6 +34,10 @@ const XHTTP_NUMBER_KEYS = [
 const XHTTP_BOOL_KEYS = [
   'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader',
 ] as const;
+// Nested objects the inbound link bundles into the `extra` JSON blob
+// (and vmess JSON carries inline). The outbound form adapter expands
+// xmux into the XMUX sub-form (enableXmux) on load.
+const XHTTP_OBJECT_KEYS = ['xmux', 'downloadSettings'] as const;
 
 function asBool(s: string | null): boolean | undefined {
   if (s === null) return undefined;
@@ -88,6 +93,10 @@ function applyXhttpStringFromJson(xhttp: Raw, json: Record<string, unknown>): vo
   for (const k of XHTTP_BOOL_KEYS) {
     if (typeof json[k] === 'boolean') xhttp[k] = json[k];
   }
+  for (const k of XHTTP_OBJECT_KEYS) {
+    const v = json[k];
+    if (v && typeof v === 'object' && !Array.isArray(v)) xhttp[k] = v;
+  }
 }
 
 function buildStream(network: string, security: string): Raw {

+ 11 - 2
frontend/src/models/setting.ts

@@ -17,12 +17,10 @@ export class AllSetting {
   datepicker: 'gregorian' | 'jalalian' = 'gregorian';
   tgBotEnable = false;
   tgBotToken = '';
-  tgBotProxy = '';
   tgBotAPIServer = '';
   tgBotChatId = '';
   tgRunTime = '@daily';
   tgBotBackup = false;
-  tgBotLoginNotify = true;
   tgCpu = 80;
   tgLang = 'en-US';
   twoFactorEnable = false;
@@ -84,12 +82,23 @@ export class AllSetting {
   ldapDefaultTotalGB = 0;
   ldapDefaultExpiryDays = 0;
   ldapDefaultLimitIP = 0;
+  tgEnabledEvents = '';
+  smtpEnable = false;
+  smtpHost = '';
+  smtpPort = 587;
+  smtpUsername = '';
+  smtpPassword = '';
+  smtpTo = '';
+  smtpEncryptionType = 'starttls';
+  smtpEnabledEvents = '';
+  smtpCpu = 80;
   hasTgBotToken = false;
   hasTwoFactorToken = false;
   hasLdapPassword = false;
   hasApiToken = false;
   hasWarpSecret = false;
   hasNordSecret = false;
+  hasSmtpPassword = false;
 
   constructor(data?: unknown) {
     if (data != null) {

+ 12 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -942,6 +942,18 @@ export const sections: readonly Section[] = [
         path: '/panel/api/setting/restartPanel',
         summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/setting/testSmtp',
+        summary: 'Test SMTP connection with stage-by-stage reporting (connect, auth, send). Returns structured result with stage and message.',
+        response: '{\n  "success": true,\n  "stage": "send",\n  "msg": "Test email sent successfully"\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/setting/testTgBot',
+        summary: 'Test Telegram bot connection by sending a test message to the configured chat.',
+        response: '{\n  "success": true,\n  "msg": "Test message sent to Telegram"\n}',
+      },
       {
         method: 'GET',
         path: '/panel/api/setting/getDefaultJsonConfig',

+ 5 - 5
frontend/src/pages/clients/ClientsPage.tsx

@@ -619,19 +619,19 @@ export default function ClientsPage() {
       render: (_v, record) => (
         <Space size={4}>
           <Tooltip title={t('pages.clients.qrCode')}>
-            <Button size="small" type="text" style={{ fontSize: 18 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
+            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
           </Tooltip>
           <Tooltip title={t('pages.clients.clientInfo')}>
-            <Button size="small" type="text" style={{ fontSize: 18 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
+            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
           </Tooltip>
           <Tooltip title={t('pages.inbounds.resetTraffic')}>
-            <Button size="small" type="text" style={{ fontSize: 18 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
+            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
           </Tooltip>
           <Tooltip title={t('edit')}>
-            <Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
+            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
           </Tooltip>
           <Tooltip title={t('delete')}>
-            <Button size="small" type="text" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
+            <Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
           </Tooltip>
         </Space>
       ),

+ 2 - 2
frontend/src/pages/groups/GroupsPage.tsx

@@ -407,10 +407,10 @@ export default function GroupsPage() {
       render: (_v, row) => (
         <Space size={4}>
           <Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
-            <Button size="small" type="text" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
+            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
           </Dropdown>
           <Tooltip title={t('pages.groups.rename')}>
-            <Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
+            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
           </Tooltip>
         </Space>
       ),

+ 2 - 2
frontend/src/pages/inbounds/list/RowActions.tsx

@@ -69,7 +69,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
   const { t } = useTranslation();
   return (
     <div className="action-buttons">
-      <Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
+      <Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
       <Dropdown
         trigger={['click']}
         menu={{
@@ -77,7 +77,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
           onClick: ({ key }) => onClick(key as RowAction),
         }}
       >
-        <Button type="text" size="small" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
+        <Button type="text" size="small" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
       </Dropdown>
     </div>
   );

+ 4 - 4
frontend/src/pages/nodes/NodeList.tsx

@@ -241,18 +241,18 @@ export default function NodeList({
       ) : (
         <Space>
           <Tooltip title={t('pages.nodes.probe')}>
-            <Button type="text" size="small" style={{ fontSize: 18 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
+            <Button type="text" size="small" style={{ fontSize: 16 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
           </Tooltip>
           {isUpdateEligible(record) && (
             <Tooltip title={t('pages.nodes.updatePanel')}>
-              <Button type="text" size="small" style={{ fontSize: 18 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
+              <Button type="text" size="small" style={{ fontSize: 16 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
             </Tooltip>
           )}
           <Tooltip title={t('edit')}>
-            <Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
+            <Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
           </Tooltip>
           <Tooltip title={t('delete')}>
-            <Button type="text" size="small" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
+            <Button type="text" size="small" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
           </Tooltip>
         </Space>
       ),

+ 137 - 0
frontend/src/pages/settings/EmailTab.tsx

@@ -0,0 +1,137 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
+import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
+import { HttpUtil } from '@/utils';
+import type { AllSetting } from '@/models/setting';
+import { SettingListItem, EventBusCheckboxes } from '@/components/ui';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { catTabLabel } from './catTabLabel';
+
+interface EmailTabProps {
+  allSetting: AllSetting;
+  updateSetting: (patch: Partial<AllSetting>) => void;
+}
+
+interface SmtpTestResult {
+  success: boolean;
+  stage?: string;
+  msg: string;
+}
+
+export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
+  const { t } = useTranslation();
+  const { isMobile } = useMediaQuery();
+  const [testLoading, setTestLoading] = useState(false);
+  const [testResult, setTestResult] = useState<SmtpTestResult | null>(null);
+
+  const stageLabel: Record<string, string> = {
+    connect: t('pages.settings.smtpStageConnect'),
+    auth: t('pages.settings.smtpStageAuth'),
+    send: t('pages.settings.smtpStageSend'),
+  };
+
+  async function handleTestSmtp() {
+    setTestLoading(true);
+    setTestResult(null);
+    try {
+      const res = await HttpUtil.post('/panel/api/setting/testSmtp') as SmtpTestResult;
+      setTestResult(res);
+    } catch (e: unknown) {
+      setTestResult({ success: false, msg: e instanceof Error ? e.message : t('pages.settings.requestFailed') });
+    } finally {
+      setTestLoading(false);
+    }
+  }
+
+  return (
+    <Tabs defaultActiveKey="1" items={[
+      {
+        key: '1',
+        label: catTabLabel(<SettingOutlined />, t('pages.settings.smtpSettings'), isMobile),
+        children: (
+          <>
+            <SettingListItem paddings="small" title={t('pages.settings.smtpEnable')} description={t('pages.settings.smtpEnableDesc')}>
+              <Switch checked={allSetting.smtpEnable} onChange={(v) => updateSetting({ smtpEnable: v })} />
+            </SettingListItem>
+
+            <SettingListItem paddings="small" title={t('pages.settings.smtpHost')} description={t('pages.settings.smtpHostDesc')}>
+              <Input value={allSetting.smtpHost} placeholder="smtp.gmail.com"
+                onChange={(e) => updateSetting({ smtpHost: e.target.value })} />
+            </SettingListItem>
+
+            <SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
+              <InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
+                onChange={(v) => updateSetting({ smtpPort: Number(v) || 587 })} />
+            </SettingListItem>
+
+            <SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>
+              <Input value={allSetting.smtpUsername} placeholder="[email protected]"
+                onChange={(e) => updateSetting({ smtpUsername: e.target.value })} />
+            </SettingListItem>
+
+            <SettingListItem paddings="small" title={t('pages.settings.smtpPassword')}
+              description={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
+              <Input.Password value={allSetting.smtpPassword}
+                placeholder={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordPlaceholder') : ''}
+                onChange={(e) => updateSetting({ smtpPassword: e.target.value })} />
+            </SettingListItem>
+
+            <SettingListItem paddings="small" title={t('pages.settings.smtpTo')} description={t('pages.settings.smtpToDesc')}>
+              <Input value={allSetting.smtpTo} placeholder="[email protected], [email protected]"
+                onChange={(e) => updateSetting({ smtpTo: e.target.value })} />
+            </SettingListItem>
+
+            <SettingListItem paddings="small" title={t('pages.settings.smtpEncryption')} description={t('pages.settings.smtpEncryptionDesc')}>
+              <Select
+                value={allSetting.smtpEncryptionType}
+                onChange={(v) => updateSetting({ smtpEncryptionType: v })}
+                options={[
+                  { value: 'none', label: t('pages.settings.smtpEncryptionNone') },
+                  { value: 'starttls', label: t('pages.settings.smtpEncryptionStartTLS') },
+                  { value: 'tls', label: t('pages.settings.smtpEncryptionTLS') },
+                ]}
+                style={{ width: '100%' }}
+              />
+            </SettingListItem>
+
+            <Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
+              <Button type="primary" icon={<SendOutlined />} loading={testLoading} onClick={handleTestSmtp}>
+                {t('pages.settings.testSmtp')}
+              </Button>
+              {testResult && (
+                <Alert
+                  type={testResult.success ? 'success' : 'error'}
+                  message={
+                    testResult.success
+                      ? t('pages.settings.' + testResult.msg)
+                      : <span><b>{stageLabel[testResult.stage || ''] || testResult.stage}:</b> {t('pages.settings.' + testResult.msg)}</span>
+                  }
+                  showIcon
+                  closable
+                  onClose={() => setTestResult(null)}
+                />
+              )}
+            </Space>
+          </>
+        ),
+      },
+      {
+        key: '2',
+        label: catTabLabel(<MailOutlined />, t('pages.settings.emailNotifications'), isMobile),
+        children: (
+          <>
+            <SettingListItem paddings="small" title={t('pages.settings.smtpEventBusNotify')} description={t('pages.settings.smtpEventBusNotifyDesc')}>
+              <EventBusCheckboxes
+                value={allSetting.smtpEnabledEvents}
+                onChange={(v) => updateSetting({ smtpEnabledEvents: v })}
+                extra={{ 'cpu.high': { key: 'smtpCpu', value: allSetting.smtpCpu } }}
+                onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })}
+              />
+            </SettingListItem>
+          </>
+        ),
+      },
+    ]} />
+  );
+}

+ 3 - 1
frontend/src/pages/settings/SettingsPage.tsx

@@ -26,6 +26,7 @@ import AppSidebar from '@/layouts/AppSidebar';
 import GeneralTab from './GeneralTab';
 import SecurityTab from './SecurityTab';
 import TelegramTab from './TelegramTab';
+import EmailTab from './EmailTab';
 import SubscriptionGeneralTab from './SubscriptionGeneralTab';
 import SubscriptionFormatsTab from './SubscriptionFormatsTab';
 import './SettingsPage.css';
@@ -34,7 +35,7 @@ interface ApiMsg {
   success?: boolean;
 }
 
-const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
+const tabSlugs = ['general', 'security', 'telegram', 'email', 'subscription', 'subscription-formats'];
 
 function isIp(h: string): boolean {
   if (typeof h !== 'string') return false;
@@ -197,6 +198,7 @@ export default function SettingsPage() {
     switch (activeSlug) {
       case 'security': return <SecurityTab allSetting={allSetting} updateSetting={updateSetting} />;
       case 'telegram': return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
+      case 'email': return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
       case 'subscription': return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
       case 'subscription-formats': return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
       default: return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;

+ 45 - 12
frontend/src/pages/settings/TelegramTab.tsx

@@ -1,10 +1,11 @@
 import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
-import { BellOutlined, SettingOutlined } from '@ant-design/icons';
+import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
+import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
 import { LanguageManager } from '@/utils';
+import { HttpUtil } from '@/utils';
 import type { AllSetting } from '@/models/setting';
-import { SettingListItem } from '@/components/ui';
+import { SettingListItem, EventBusCheckboxes } from '@/components/ui';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 
@@ -107,7 +108,7 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
   ];
 
   return (
-    <Space direction="vertical" size="small" style={{ width: '100%' }}>
+    <Space orientation="vertical" size="small" style={{ width: '100%' }}>
       <Select<Mode>
         style={{ width: '100%' }}
         value={state.mode}
@@ -144,6 +145,21 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
 export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();
+  const [testLoading, setTestLoading] = useState(false);
+  const [testResult, setTestResult] = useState<{ success: boolean; msg: string } | null>(null);
+
+  async function handleTestTgBot() {
+    setTestLoading(true);
+    setTestResult(null);
+    try {
+      const res = await HttpUtil.post('/panel/api/setting/testTgBot') as { success?: boolean; msg?: string };
+      setTestResult({ success: !!res.success, msg: res.msg || '' });
+    } catch (e: unknown) {
+      setTestResult({ success: false, msg: e instanceof Error ? e.message : t('pages.settings.requestFailed') });
+    } finally {
+      setTestLoading(false);
+    }
+  }
 
   const langOptions = useMemo(
     () => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
@@ -172,11 +188,11 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
             <SettingListItem
               paddings="small"
               title={t('pages.settings.telegramToken')}
-              description={allSetting.hasTgBotToken ? 'Configured; leave blank to keep current token.' : t('pages.settings.telegramTokenDesc')}
+              description={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')}
             >
               <Input.Password
                 value={allSetting.tgBotToken}
-                placeholder={allSetting.hasTgBotToken ? 'Configured - enter a new token to replace' : ''}
+                placeholder={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenPlaceholder') : ''}
                 onChange={(e) => updateSetting({ tgBotToken: e.target.value })}
               />
             </SettingListItem>
@@ -198,6 +214,21 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
               <Input value={allSetting.tgBotAPIServer} placeholder="https://api.example.com"
                 onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })} />
             </SettingListItem>
+
+            <Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
+              <Button type="primary" icon={<SendOutlined />} loading={testLoading} onClick={handleTestTgBot}>
+                {t('pages.settings.testTgBot')}
+              </Button>
+              {testResult && (
+                <Alert
+                  type={testResult.success ? 'success' : 'error'}
+                  message={testResult.msg}
+                  showIcon
+                  closable
+                  onClose={() => setTestResult(null)}
+                />
+              )}
+            </Space>
           </>
         ),
       },
@@ -212,12 +243,14 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
             <SettingListItem paddings="small" title={t('pages.settings.tgNotifyBackup')} description={t('pages.settings.tgNotifyBackupDesc')}>
               <Switch checked={allSetting.tgBotBackup} onChange={(v) => updateSetting({ tgBotBackup: v })} />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.tgNotifyLogin')} description={t('pages.settings.tgNotifyLoginDesc')}>
-              <Switch checked={allSetting.tgBotLoginNotify} onChange={(v) => updateSetting({ tgBotLoginNotify: v })} />
-            </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.tgNotifyCpu')} description={t('pages.settings.tgNotifyCpuDesc')}>
-              <InputNumber value={allSetting.tgCpu} min={0} max={100} style={{ width: '100%' }}
-                onChange={(v) => updateSetting({ tgCpu: Number(v) || 0 })} />
+
+            <SettingListItem paddings="small" title={t('pages.settings.tgEventBusNotify')} description={t('pages.settings.tgEventBusNotifyDesc')}>
+              <EventBusCheckboxes
+                value={allSetting.tgEnabledEvents}
+                onChange={(v) => updateSetting({ tgEnabledEvents: v })}
+                extra={{ 'cpu.high': { key: 'tgCpu', value: allSetting.tgCpu } }}
+                onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })}
+              />
             </SettingListItem>
           </>
         ),

+ 1 - 1
frontend/src/pages/xray/balancers/BalancersTab.tsx

@@ -265,7 +265,7 @@ export default function BalancersTab({
       align: 'center',
       render: (_v, record) =>
         (record.selector || []).map((sel) => (
-          <Tag key={sel} className="info-large-tag">
+          <Tag key={sel} className="info-large-tag" style={{ margin: 0, marginRight: 4 }}>
             {sel}
           </Tag>
         )),

+ 13 - 4
frontend/src/schemas/protocols/security/index.ts

@@ -15,9 +15,18 @@ export type Security = z.infer<typeof SecuritySchema>;
 // 'none' neither key appears. The Xray panel's StreamSettings class emits
 // `undefined` for the inactive branch which strips the key during JSON
 // serialization, so this DU faithfully describes what's on disk.
-export const SecuritySettingsSchema = z.discriminatedUnion('security', [
-  z.object({ security: z.literal('none') }),
-  z.object({ security: z.literal('tls'),     tlsSettings:     TlsStreamSettingsSchema }),
-  z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
+//
+// Tunnel (dokodemo-door / TProxy) is transportless and may carry only
+// `sockopt` — its streamSettings has no `security` key at all. The
+// transportless branch accepts that shape, mirroring NetworkSettingsSchema's
+// `network: never().optional()` handling. A present-but-invalid security
+// still fails both branches so a typo can't slip through.
+export const SecuritySettingsSchema = z.union([
+  z.discriminatedUnion('security', [
+    z.object({ security: z.literal('none') }),
+    z.object({ security: z.literal('tls'),     tlsSettings:     TlsStreamSettingsSchema }),
+    z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
+  ]),
+  z.object({ security: z.never().optional() }),
 ]);
 export type SecuritySettings = z.infer<typeof SecuritySettingsSchema>;

+ 1 - 1
frontend/src/schemas/setting.ts

@@ -26,7 +26,6 @@ export const AllSettingSchema = z.object({
   tgBotChatId: z.string().optional(),
   tgRunTime: z.string().optional(),
   tgBotBackup: z.boolean().optional(),
-  tgBotLoginNotify: z.boolean().optional(),
   tgCpu: z.number().int().min(0).max(100).optional(),
   tgLang: z.string().optional(),
   twoFactorEnable: z.boolean().optional(),
@@ -91,6 +90,7 @@ export const AllSettingSchema = z.object({
   hasApiToken: z.boolean().optional(),
   hasWarpSecret: z.boolean().optional(),
   hasNordSecret: z.boolean().optional(),
+  hasSmtpPassword: z.boolean().optional(),
 }).loose();
 
 export type AllSettingInput = z.infer<typeof AllSettingSchema>;

+ 0 - 161
frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap

@@ -1,161 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`InboundFormModal > field structure is stable for every protocol > http 1`] = `
-[
-  "Enabled",
-  "Remark",
-  "Protocol",
-  "Address",
-  "Share address strategy",
-  "Subscription sort order",
-  "Port",
-  "Total Flow",
-  "Traffic Reset",
-  "Duration",
-  "Enabled",
-]
-`;
-
-exports[`InboundFormModal > field structure is stable for every protocol > hysteria 1`] = `
-[
-  "Enabled",
-  "Remark",
-  "Protocol",
-  "Address",
-  "Share address strategy",
-  "Subscription sort order",
-  "Port",
-  "Total Flow",
-  "Traffic Reset",
-  "Duration",
-  "Enabled",
-]
-`;
-
-exports[`InboundFormModal > field structure is stable for every protocol > mixed 1`] = `
-[
-  "Enabled",
-  "Remark",
-  "Protocol",
-  "Address",
-  "Share address strategy",
-  "Subscription sort order",
-  "Port",
-  "Total Flow",
-  "Traffic Reset",
-  "Duration",
-  "Enabled",
-]
-`;
-
-exports[`InboundFormModal > field structure is stable for every protocol > shadowsocks 1`] = `
-[
-  "Enabled",
-  "Remark",
-  "Protocol",
-  "Address",
-  "Share address strategy",
-  "Subscription sort order",
-  "Port",
-  "Total Flow",
-  "Traffic Reset",
-  "Duration",
-  "Enabled",
-]
-`;
-
-exports[`InboundFormModal > field structure is stable for every protocol > trojan 1`] = `
-[
-  "Enabled",
-  "Remark",
-  "Protocol",
-  "Address",
-  "Share address strategy",
-  "Subscription sort order",
-  "Port",
-  "Total Flow",
-  "Traffic Reset",
-  "Duration",
-  "Enabled",
-]
-`;
-
-exports[`InboundFormModal > field structure is stable for every protocol > tun 1`] = `
-[
-  "Enabled",
-  "Remark",
-  "Protocol",
-  "Address",
-  "Share address strategy",
-  "Subscription sort order",
-  "Port",
-  "Total Flow",
-  "Traffic Reset",
-  "Duration",
-  "Enabled",
-]
-`;
-
-exports[`InboundFormModal > field structure is stable for every protocol > tunnel 1`] = `
-[
-  "Enabled",
-  "Remark",
-  "Protocol",
-  "Address",
-  "Share address strategy",
-  "Subscription sort order",
-  "Port",
-  "Total Flow",
-  "Traffic Reset",
-  "Duration",
-  "Enabled",
-]
-`;
-
-exports[`InboundFormModal > field structure is stable for every protocol > vless 1`] = `
-[
-  "Enabled",
-  "Remark",
-  "Protocol",
-  "Address",
-  "Share address strategy",
-  "Subscription sort order",
-  "Port",
-  "Total Flow",
-  "Traffic Reset",
-  "Duration",
-  "Enabled",
-]
-`;
-
-exports[`InboundFormModal > field structure is stable for every protocol > vmess 1`] = `
-[
-  "Enabled",
-  "Remark",
-  "Protocol",
-  "Address",
-  "Share address strategy",
-  "Subscription sort order",
-  "Port",
-  "Total Flow",
-  "Traffic Reset",
-  "Duration",
-  "Enabled",
-]
-`;
-
-exports[`InboundFormModal > field structure is stable for every protocol > wireguard 1`] = `
-[
-  "Enabled",
-  "Remark",
-  "Protocol",
-  "Address",
-  "Share address strategy",
-  "Subscription sort order",
-  "Port",
-  "Total Flow",
-  "Traffic Reset",
-  "Duration",
-  "Enabled",
-]
-`;

+ 0 - 141
frontend/src/test/__snapshots__/outbound-form-modal.test.tsx.snap

@@ -1,141 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`OutboundFormModal > field structure is stable for every protocol > blackhole 1`] = `
-[
-  "Protocol",
-  "Tag",
-  "Send Through",
-  "Address",
-  "Port",
-  "ID",
-  "Encryption",
-  "Reverse tag",
-  "Mux",
-]
-`;
-
-exports[`OutboundFormModal > field structure is stable for every protocol > dns 1`] = `
-[
-  "Protocol",
-  "Tag",
-  "Send Through",
-  "Address",
-  "Port",
-  "ID",
-  "Encryption",
-  "Reverse tag",
-  "Mux",
-]
-`;
-
-exports[`OutboundFormModal > field structure is stable for every protocol > freedom 1`] = `
-[
-  "Protocol",
-  "Tag",
-  "Send Through",
-  "Address",
-  "Port",
-  "ID",
-  "Encryption",
-  "Reverse tag",
-  "Mux",
-]
-`;
-
-exports[`OutboundFormModal > field structure is stable for every protocol > hysteria 1`] = `
-[
-  "Protocol",
-  "Tag",
-  "Send Through",
-  "Address",
-  "Port",
-  "ID",
-  "Encryption",
-  "Reverse tag",
-  "Mux",
-]
-`;
-
-exports[`OutboundFormModal > field structure is stable for every protocol > shadowsocks 1`] = `
-[
-  "Protocol",
-  "Tag",
-  "Send Through",
-  "Address",
-  "Port",
-  "ID",
-  "Encryption",
-  "Reverse tag",
-  "Mux",
-]
-`;
-
-exports[`OutboundFormModal > field structure is stable for every protocol > socks 1`] = `
-[
-  "Protocol",
-  "Tag",
-  "Send Through",
-  "Address",
-  "Port",
-  "ID",
-  "Encryption",
-  "Reverse tag",
-  "Mux",
-]
-`;
-
-exports[`OutboundFormModal > field structure is stable for every protocol > trojan 1`] = `
-[
-  "Protocol",
-  "Tag",
-  "Send Through",
-  "Address",
-  "Port",
-  "ID",
-  "Encryption",
-  "Reverse tag",
-  "Mux",
-]
-`;
-
-exports[`OutboundFormModal > field structure is stable for every protocol > vless 1`] = `
-[
-  "Protocol",
-  "Tag",
-  "Send Through",
-  "Address",
-  "Port",
-  "ID",
-  "Encryption",
-  "Reverse tag",
-  "Mux",
-]
-`;
-
-exports[`OutboundFormModal > field structure is stable for every protocol > vmess 1`] = `
-[
-  "Protocol",
-  "Tag",
-  "Send Through",
-  "Address",
-  "Port",
-  "ID",
-  "Encryption",
-  "Reverse tag",
-  "Mux",
-]
-`;
-
-exports[`OutboundFormModal > field structure is stable for every protocol > wireguard 1`] = `
-[
-  "Protocol",
-  "Tag",
-  "Send Through",
-  "Address",
-  "Port",
-  "ID",
-  "Encryption",
-  "Reverse tag",
-  "Mux",
-]
-`;

+ 12 - 2
frontend/src/test/inbound-form-adapter.test.ts

@@ -289,8 +289,18 @@ describe('subSortIndex', () => {
   });
 
   it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => {
-    expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 }).success).toBe(false);
-    expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }).success).toBe(false);
+    // Reject for the RIGHT reason: the issue must be about subSortIndex, not some
+    // unrelated field — otherwise a schema that rejects everything would pass.
+    const nonInt = InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 });
+    expect(nonInt.success).toBe(false);
+    if (!nonInt.success) expect(nonInt.error.issues[0]?.path).toContain('subSortIndex');
+
+    const belowMin = InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 });
+    expect(belowMin.success).toBe(false);
+    if (!belowMin.success) expect(belowMin.error.issues[0]?.path).toContain('subSortIndex');
+
+    // A valid integer >= 1 must pass (guards against a mutant rejecting all values).
+    expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 5 }).success).toBe(true);
     expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
   });
 });

+ 19 - 4
frontend/src/test/inbound-form-modal.test.tsx

@@ -1,5 +1,5 @@
 import { describe, it, expect } from 'vitest';
-import { screen } from '@testing-library/react';
+import { screen, act } from '@testing-library/react';
 
 import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
 import { DBInbound } from '@/models/dbinbound';
@@ -31,15 +31,30 @@ describe('InboundFormModal', () => {
     expect(fieldLabels().length).toBeGreaterThan(0);
   });
 
-  it('field structure is stable for every protocol', () => {
+  it('field structure differs per protocol (not a vacuous snapshot loop)', async () => {
     renderModal();
     const protocols = listSelectOptions('protocol');
     expect(protocols.length).toBeGreaterThan(3);
+
+    const labelsByProto: Record<string, string[]> = {};
     for (const proto of protocols) {
       chooseSelectOption('protocol', proto);
-      expect(fieldLabels()).toMatchSnapshot(proto);
+      // Flush antd Form.useWatch('protocol') before reading — without it every iteration
+      // sees the same pre-update DOM and the loop asserts nothing (the original bug here).
+      await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
+      labelsByProto[proto] = fieldLabels();
     }
-  });
+
+    // The loop must actually exercise protocol-specific rendering: distinct protocols
+    // must yield distinct field sets (a vacuous loop makes them all identical).
+    const distinctShapes = new Set(Object.values(labelsByProto).map((l) => l.join('|')));
+    expect(distinctShapes.size).toBeGreaterThan(1);
+
+    // Spot-check a protocol-distinguishing field that must appear after the switch.
+    if (labelsByProto.shadowsocks) {
+      expect(labelsByProto.shadowsocks).toContain('Encryption method');
+    }
+  }, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
 
   it('preserves custom share address strategy when editing a local inbound', async () => {
     renderWithProviders(

+ 91 - 0
frontend/src/test/inbound-link.test.ts

@@ -567,3 +567,94 @@ describe('external proxy pinned cert (pcs)', () => {
     expect(new URL(link).searchParams.has('pcs')).toBe(false);
   });
 });
+
+// #5322: the panel copy-link must carry XTLS Vision `flow` for VLESS+XHTTP
+// when VLESS encryption (vlessenc) is on, matching the form's flow display
+// and the backend subscription. Gating is via canEnableTlsFlow.
+describe('genVlessLink flow gating (#5322)', () => {
+  function vlessXhttp(encryption: string) {
+    return InboundSchema.parse({
+      id: 1,
+      up: 0,
+      down: 0,
+      total: 0,
+      remark: 'vlessenc',
+      enable: true,
+      expiryTime: 0,
+      listen: '',
+      port: 443,
+      tag: 'inbound-vless-xhttp',
+      sniffing: {
+        enabled: false,
+        destOverride: [],
+        metadataOnly: false,
+        routeOnly: false,
+        ipsExcluded: [],
+        domainsExcluded: [],
+      },
+      protocol: 'vless',
+      settings: {
+        clients: [
+          {
+            id: '11111111-2222-3333-4444-555555555555',
+            email: '[email protected]',
+            flow: 'xtls-rprx-vision',
+            limitIp: 0,
+            totalGB: 0,
+            expiryTime: 0,
+            enable: true,
+            tgId: 0,
+            subId: 's1',
+            comment: '',
+            reset: 0,
+          },
+        ],
+        decryption: 'none',
+        encryption,
+        fallbacks: [],
+      },
+      streamSettings: {
+        network: 'xhttp',
+        xhttpSettings: {},
+        security: 'none',
+      },
+    });
+  }
+
+  const clientId = '11111111-2222-3333-4444-555555555555';
+
+  it('emits flow for VLESS+XHTTP when vless encryption is enabled', () => {
+    const link = genVlessLink({
+      inbound: vlessXhttp('mlkem768x25519plus.native.0rtt.SGVsbG8'),
+      address: 'example.test',
+      port: 443,
+      clientId,
+      flow: 'xtls-rprx-vision',
+    });
+    expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
+  });
+
+  it('omits flow for VLESS+XHTTP without vless encryption', () => {
+    const link = genVlessLink({
+      inbound: vlessXhttp('none'),
+      address: 'example.test',
+      port: 443,
+      clientId,
+      flow: 'xtls-rprx-vision',
+    });
+    expect(new URL(link).searchParams.has('flow')).toBe(false);
+  });
+
+  it('still emits flow for classic TCP+REALITY Vision', () => {
+    const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-tcp-reality')!;
+    const typed = InboundSchema.parse(raw);
+    const link = genVlessLink({
+      inbound: typed,
+      address: 'example.test',
+      port: 443,
+      clientId: (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id,
+      flow: 'xtls-rprx-vision',
+    });
+    expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
+  });
+});

+ 21 - 3
frontend/src/test/outbound-form-modal.test.tsx

@@ -1,4 +1,5 @@
 import { describe, it, expect } from 'vitest';
+import { act } from '@testing-library/react';
 
 import OutboundFormModal from '@/pages/xray/outbounds/OutboundFormModal';
 import {
@@ -27,13 +28,30 @@ describe('OutboundFormModal', () => {
     expect(fieldLabels().length).toBeGreaterThan(0);
   });
 
-  it('field structure is stable for every protocol', () => {
+  it('field structure differs per protocol (not a vacuous snapshot loop)', async () => {
     renderModal(null);
     const protocols = listSelectOptions('protocol');
     expect(protocols.length).toBeGreaterThan(3);
+
+    const labelsByProto: Record<string, string[]> = {};
     for (const proto of protocols) {
       chooseSelectOption('protocol', proto);
-      expect(fieldLabels()).toMatchSnapshot(proto);
+      // Flush antd Form.useWatch('protocol') so protocol-specific fields render before
+      // reading; otherwise every iteration sees the same default (vless) DOM.
+      await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
+      labelsByProto[proto] = fieldLabels();
     }
-  });
+
+    // Distinct protocols must yield distinct field sets (a vacuous loop is all-identical).
+    const distinctShapes = new Set(Object.values(labelsByProto).map((l) => l.join('|')));
+    expect(distinctShapes.size).toBeGreaterThan(1);
+
+    // vless carries an Encryption field; wireguard does not — proves real protocol switching.
+    if (labelsByProto.vless) {
+      expect(labelsByProto.vless).toContain('Encryption');
+    }
+    if (labelsByProto.wireguard) {
+      expect(labelsByProto.wireguard).not.toContain('Encryption');
+    }
+  }, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
 });

+ 17 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -392,6 +392,23 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
     expect(xhttp.xPaddingBytes).toBe('900-9000');
   });
 
+  it('extracts the nested xmux object from the extra JSON blob', () => {
+    // The inbound link bundles xmux into `extra` as a nested object
+    // (sub/service.go). It must survive import so the outbound form's
+    // XMUX sub-form populates rather than silently dropping it (#5353).
+    const extra = encodeURIComponent(JSON.stringify({
+      xmux: { maxConcurrency: '8-16', hMaxRequestTimes: '700-1000' },
+    }));
+    const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto'
+      + '&extra=' + extra + '#t';
+    const parsed = parseVlessLink(link);
+    const xhttp = (parsed!.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
+    const xmux = xhttp.xmux as Record<string, unknown>;
+    expect(xmux).toBeDefined();
+    expect(xmux.maxConcurrency).toBe('8-16');
+    expect(xmux.hMaxRequestTimes).toBe('700-1000');
+  });
+
   it('ignores malformed extra JSON without breaking the rest of the link', () => {
     const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto'
       + '&extra=not-json&fp=chrome#t';

+ 32 - 0
frontend/src/test/protocols.test.ts

@@ -27,3 +27,35 @@ describe('InboundSettingsSchema fixtures', () => {
     });
   }
 });
+
+// The fixture tests above pin coerced values only via regenerable snapshots. These
+// assert the load-bearing transforms directly, so a broken coercion fails independently
+// of the snapshot baseline.
+describe('InboundSettingsSchema coercions', () => {
+  it('vmess: defaults alterId to 0 and coerces a string tgId to a number', () => {
+    const parsed = InboundSettingsSchema.parse({
+      protocol: 'vmess',
+      settings: { clients: [{ id: 'u1', email: '[email protected]', tgId: '12345' }] },
+    });
+    if (parsed.protocol !== 'vmess') throw new Error('discriminator narrowed to the wrong protocol');
+    const client = parsed.settings.clients[0];
+    expect(client.alterId).toBe(0); // .default(0) injected for omitted field
+    expect(client.tgId).toBe(12345); // string -> number transform
+  });
+
+  it('vmess: a non-numeric tgId coerces to 0', () => {
+    const parsed = InboundSettingsSchema.parse({
+      protocol: 'vmess',
+      settings: { clients: [{ id: 'u1', email: '[email protected]', tgId: 'not-a-number' }] },
+    });
+    if (parsed.protocol !== 'vmess') throw new Error('wrong protocol');
+    expect(parsed.settings.clients[0].tgId).toBe(0); // Number(v) || 0
+  });
+
+  it('vless: defaults decryption and encryption to "none"', () => {
+    const parsed = InboundSettingsSchema.parse({ protocol: 'vless', settings: { clients: [] } });
+    if (parsed.protocol !== 'vless') throw new Error('wrong protocol');
+    expect(parsed.settings.decryption).toBe('none');
+    expect(parsed.settings.encryption).toBe('none');
+  });
+});

+ 1 - 1
frontend/src/test/stream-wire-normalize.test.ts

@@ -66,7 +66,7 @@ describe('normalizeXhttpForWire stream-one', () => {
     expect(out).not.toHaveProperty('scMaxEachPostBytes');
   });
 
-  it('keeps inbound xmux when enableXmux is on (for the share-link extra)', () => {
+  it('keeps inbound xmux when enableXmux is on (stored for subscription extra; stripped from xray config on Go side)', () => {
     const out = normalizeXhttpForWire({
       path: '/app',
       mode: 'auto',

+ 2 - 1
go.mod

@@ -13,7 +13,7 @@ require (
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
 	github.com/joho/godotenv v1.5.1
-	github.com/mymmrac/telego v1.9.0
+	github.com/mymmrac/telego v1.10.0
 	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
 	github.com/robfig/cron/v3 v3.0.1
@@ -31,6 +31,7 @@ require (
 	gorm.io/driver/postgres v1.6.0
 	gorm.io/driver/sqlite v1.6.0
 	gorm.io/gorm v1.31.1
+	pgregory.net/rapid v1.3.0
 )
 
 require (

+ 4 - 2
go.sum

@@ -138,8 +138,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/mymmrac/telego v1.9.0 h1:ZUJxZaPx/1IgRvVb5lXnUB8FgW5rNYfRe6Q2EJ4OJ+Y=
-github.com/mymmrac/telego v1.9.0/go.mod h1:tVEB7OqiOPx8elRk9+ETkwiDQrUhWSB2XmAKIY9KmWY=
+github.com/mymmrac/telego v1.10.0 h1:Upe0TqYyiK+yE5RFXXuQWVHGfLZnqvUfj4KZVjTcgWE=
+github.com/mymmrac/telego v1.10.0/go.mod h1:LsQKDA6EwssPP9XkORPXwwOFUGIRf/Wf2Wb8y3YyJdE=
 github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
 github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
@@ -305,3 +305,5 @@ gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TI
 gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
+pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
+pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=

+ 75 - 0
internal/config/config_mutation_test.go

@@ -0,0 +1,75 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+// copyFile is the workhorse invoked by init()'s Windows-only DB migration
+// (config.go:214), the branch guarded by the platform check on config.go:196.
+// The init() guard itself cannot be re-driven from an in-process test (init runs
+// once at package load, the OS check is a compile-time constant, and the old-DB
+// source path is hardcoded to a system location), so these tests pin down the
+// migration payload's contract instead.
+
+func TestCopyFileCopiesContents(t *testing.T) {
+	dir := t.TempDir()
+	src := filepath.Join(dir, "src.db")
+	dst := filepath.Join(dir, "dst.db")
+
+	want := []byte("3x-ui sqlite payload\x00\x01\x02")
+	if err := os.WriteFile(src, want, 0o600); err != nil {
+		t.Fatalf("write src: %v", err)
+	}
+
+	if err := copyFile(src, dst); err != nil {
+		t.Fatalf("copyFile returned error: %v", err)
+	}
+
+	got, err := os.ReadFile(dst)
+	if err != nil {
+		t.Fatalf("read dst: %v", err)
+	}
+	if string(got) != string(want) {
+		t.Errorf("dst contents = %q, want %q", got, want)
+	}
+}
+
+func TestCopyFileMissingSourceReturnsError(t *testing.T) {
+	dir := t.TempDir()
+	src := filepath.Join(dir, "does-not-exist.db")
+	dst := filepath.Join(dir, "dst.db")
+
+	if err := copyFile(src, dst); err == nil {
+		t.Fatal("copyFile with missing source returned nil error, want error")
+	}
+	if _, err := os.Stat(dst); !os.IsNotExist(err) {
+		t.Errorf("dst should not be created when source is missing, stat err = %v", err)
+	}
+}
+
+func TestCopyFileOverwritesDestination(t *testing.T) {
+	dir := t.TempDir()
+	src := filepath.Join(dir, "src.db")
+	dst := filepath.Join(dir, "dst.db")
+
+	if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
+		t.Fatalf("write src: %v", err)
+	}
+	if err := os.WriteFile(dst, []byte("stale-and-longer"), 0o600); err != nil {
+		t.Fatalf("write dst: %v", err)
+	}
+
+	if err := copyFile(src, dst); err != nil {
+		t.Fatalf("copyFile returned error: %v", err)
+	}
+
+	got, err := os.ReadFile(dst)
+	if err != nil {
+		t.Fatalf("read dst: %v", err)
+	}
+	if string(got) != "new" {
+		t.Errorf("dst contents = %q, want %q (truncated overwrite)", got, "new")
+	}
+}

+ 48 - 1
internal/database/model/model.go

@@ -225,6 +225,49 @@ func jsonStringFieldFromRaw(r json.RawMessage) string {
 	return string(trimmed)
 }
 
+// StripInboundXhttpClientFields removes xHTTP knobs that belong on the
+// client dialer and subscription share-link extras only. xray-core's XHTTP
+// inbound listener does not consume them; the panel still stores them on
+// the inbound row so buildXhttpExtra can push defaults to clients.
+func StripInboundXhttpClientFields(streamSettings string) (string, bool) {
+	if streamSettings == "" {
+		return streamSettings, false
+	}
+	var stream map[string]any
+	if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
+		return streamSettings, false
+	}
+	if stream["network"] != "xhttp" {
+		return streamSettings, false
+	}
+	xhttp, ok := stream["xhttpSettings"].(map[string]any)
+	if !ok || len(xhttp) == 0 {
+		return streamSettings, false
+	}
+	clientOnly := []string{
+		"xmux",
+		"downloadSettings",
+		"scMinPostsIntervalMs",
+		"uplinkChunkSize",
+		"noGRPCHeader",
+	}
+	changed := false
+	for _, key := range clientOnly {
+		if _, has := xhttp[key]; has {
+			delete(xhttp, key)
+			changed = true
+		}
+	}
+	if !changed {
+		return streamSettings, false
+	}
+	out, err := json.MarshalIndent(stream, "", "  ")
+	if err != nil {
+		return streamSettings, false
+	}
+	return string(out), true
+}
+
 // GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
 func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
 	listen := i.Listen
@@ -248,12 +291,16 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
 			settings = stripped
 		}
 	}
+	streamSettings := i.StreamSettings
+	if stripped, ok := StripInboundXhttpClientFields(streamSettings); ok {
+		streamSettings = stripped
+	}
 	return &xray.InboundConfig{
 		Listen:         json_util.RawMessage(listen),
 		Port:           i.Port,
 		Protocol:       protocol,
 		Settings:       json_util.RawMessage(settings),
-		StreamSettings: json_util.RawMessage(i.StreamSettings),
+		StreamSettings: json_util.RawMessage(streamSettings),
 		Tag:            i.Tag,
 		Sniffing:       json_util.RawMessage(i.Sniffing),
 	}

+ 82 - 0
internal/database/model/model_test.go

@@ -188,3 +188,85 @@ func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) {
 		})
 	}
 }
+
+func TestStripInboundXhttpClientFields_RemovesClientOnlyKnobs(t *testing.T) {
+	stream := `{
+		"network": "xhttp",
+		"security": "reality",
+		"xhttpSettings": {
+			"path": "/app",
+			"host": "example.com",
+			"mode": "stream-one",
+			"xmux": { "maxConcurrency": "16-32" },
+			"downloadSettings": { "network": "xhttp" },
+			"scMinPostsIntervalMs": "20-40",
+			"uplinkChunkSize": 4096,
+			"noGRPCHeader": true
+		}
+	}`
+	out, changed := StripInboundXhttpClientFields(stream)
+	if !changed {
+		t.Fatal("expected client-only xhttp fields to be stripped")
+	}
+	if strings.Contains(out, `"xmux"`) {
+		t.Fatalf("xmux should be removed from xray config stream: %s", out)
+	}
+	for _, key := range []string{"downloadSettings", "scMinPostsIntervalMs", "uplinkChunkSize", "noGRPCHeader"} {
+		if strings.Contains(out, `"`+key+`"`) {
+			t.Fatalf("%s should be removed from xray config stream: %s", key, out)
+		}
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+		t.Fatalf("invalid JSON: %v", err)
+	}
+	xhttp := parsed["xhttpSettings"].(map[string]any)
+	if xhttp["path"] != "/app" || xhttp["host"] != "example.com" {
+		t.Fatalf("server fields must survive: %#v", xhttp)
+	}
+}
+
+func TestStripInboundXhttpClientFields_UnchangedWithoutClientFields(t *testing.T) {
+	stream := `{"network":"xhttp","xhttpSettings":{"path":"/app","mode":"stream-one"}}`
+	out, changed := StripInboundXhttpClientFields(stream)
+	if changed {
+		t.Fatalf("expected no change, got: %s", out)
+	}
+	if out != stream {
+		t.Fatalf("unchanged stream must be returned verbatim")
+	}
+}
+
+func TestStripInboundXhttpClientFields_NonXhttpPassthrough(t *testing.T) {
+	stream := `{"network":"ws","wsSettings":{"path":"/"}}`
+	out, changed := StripInboundXhttpClientFields(stream)
+	if changed || out != stream {
+		t.Fatalf("non-xhttp stream must pass through unchanged, got changed=%v out=%s", changed, out)
+	}
+}
+
+func TestGenXrayInboundConfig_OmitsInboundXmuxButDbRowUnchanged(t *testing.T) {
+	stream := `{
+		"network": "xhttp",
+		"xhttpSettings": {
+			"path": "/app",
+			"mode": "stream-one",
+			"xmux": { "maxConcurrency": "16-32", "hMaxRequestTimes": "600-900" }
+		}
+	}`
+	in := Inbound{
+		Protocol:       VLESS,
+		Port:           443,
+		Listen:         "0.0.0.0",
+		Tag:            "in-xhttp",
+		Settings:       `{"clients":[],"decryption":"none"}`,
+		StreamSettings: stream,
+	}
+	cfg := in.GenXrayInboundConfig()
+	if strings.Contains(string(cfg.StreamSettings), `"xmux"`) {
+		t.Fatalf("GenXrayInboundConfig must not emit xmux: %s", cfg.StreamSettings)
+	}
+	if strings.Contains(in.StreamSettings, `"xmux"`) == false {
+		t.Fatal("inbound row streamSettings must still carry xmux for subscriptions")
+	}
+}

+ 123 - 0
internal/eventbus/bus.go

@@ -0,0 +1,123 @@
+package eventbus
+
+import (
+	"sync"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// DefaultBufferSize is the number of events the bus can hold before Publish starts dropping.
+const DefaultBufferSize = 256
+
+// subscriber pairs an ID with its event handler.
+type subscriber struct {
+	id      string
+	handler func(Event)
+}
+
+// Bus is a minimal in-process pub/sub event bus backed by a buffered channel.
+// Producers call Publish (non-blocking) and every event is fanned out to all
+// subscribers; per-event filtering is the subscriber's responsibility.
+type Bus struct {
+	ch   chan Event
+	subs []subscriber
+	mu   sync.RWMutex
+	done chan struct{}
+	wg   sync.WaitGroup
+}
+
+// New creates a Bus with the given buffer size. Use 0 for DefaultBufferSize.
+func New(bufSize int) *Bus {
+	if bufSize <= 0 {
+		bufSize = DefaultBufferSize
+	}
+	b := &Bus{
+		ch:   make(chan Event, bufSize),
+		done: make(chan struct{}),
+	}
+	b.wg.Add(1)
+	go b.dispatch()
+	return b
+}
+
+// Subscribe registers a handler that receives every published event.
+// The id is used for Unsubscribe; it must be unique across active subscribers.
+// Subscribing with an already-registered id replaces the previous handler.
+func (b *Bus) Subscribe(id string, handler func(Event)) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	for i, s := range b.subs {
+		if s.id == id {
+			b.subs[i].handler = handler
+			return
+		}
+	}
+	b.subs = append(b.subs, subscriber{id: id, handler: handler})
+}
+
+// Unsubscribe removes a subscriber by id. Safe to call with unknown id.
+func (b *Bus) Unsubscribe(id string) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	for i, s := range b.subs {
+		if s.id == id {
+			b.subs = append(b.subs[:i], b.subs[i+1:]...)
+			return
+		}
+	}
+}
+
+// Publish sends an event to all subscribers. Non-blocking — if the buffer is
+// full the event is dropped and a warning is logged.
+func (b *Bus) Publish(e Event) {
+	if e.Timestamp.IsZero() {
+		e.Timestamp = time.Now()
+	}
+	select {
+	case b.ch <- e:
+	default:
+		logger.Warning("eventbus: buffer full, dropping event ", e.Type)
+	}
+}
+
+// dispatch is the fan-out loop. It reads events from the channel and calls
+// every subscriber's handler sequentially. Handlers run on the dispatch
+// goroutine — they must not block.
+func (b *Bus) dispatch() {
+	defer b.wg.Done()
+	for {
+		select {
+		case e, ok := <-b.ch:
+			if !ok {
+				return
+			}
+			b.mu.RLock()
+			subs := make([]subscriber, len(b.subs))
+			copy(subs, b.subs)
+			b.mu.RUnlock()
+			for _, s := range subs {
+				safeCall(s.handler, e)
+			}
+		case <-b.done:
+			return
+		}
+	}
+}
+
+// safeCall invokes handler with panic recovery.
+func safeCall(fn func(Event), e Event) {
+	defer func() {
+		if r := recover(); r != nil {
+			logger.Errorf("eventbus: subscriber panicked on %s: %v", e.Type, r)
+		}
+	}()
+	fn(e)
+}
+
+// Stop shuts down the bus: the dispatch goroutine exits, in-flight handlers
+// finish, and any events still buffered may be dropped. Safe to call once.
+func (b *Bus) Stop() {
+	close(b.done)
+	b.wg.Wait()
+}

+ 199 - 0
internal/eventbus/bus_test.go

@@ -0,0 +1,199 @@
+package eventbus
+
+import (
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/op/go-logging"
+)
+
+func TestMain(m *testing.M) {
+	logger.InitLogger(logging.ERROR)
+	m.Run()
+}
+
+func TestBusPublishSubscribe(t *testing.T) {
+	b := New(16)
+	defer b.Stop()
+
+	var received Event
+	var wg sync.WaitGroup
+	wg.Add(1)
+
+	b.Subscribe("test", func(e Event) {
+		received = e
+		wg.Done()
+	})
+
+	b.Publish(Event{Type: EventOutboundDown, Source: "my-proxy"})
+
+	select {
+	case <-waitDone(&wg):
+	case <-time.After(time.Second):
+		t.Fatal("subscriber did not receive event")
+	}
+
+	if received.Type != EventOutboundDown {
+		t.Errorf("got type %q, want %q", received.Type, EventOutboundDown)
+	}
+	if received.Source != "my-proxy" {
+		t.Errorf("got source %q, want %q", received.Source, "my-proxy")
+	}
+	if received.Timestamp.IsZero() {
+		t.Error("timestamp not set")
+	}
+}
+
+func TestBusMultipleSubscribers(t *testing.T) {
+	b := New(16)
+	defer b.Stop()
+
+	var count atomic.Int32
+	var wg sync.WaitGroup
+	wg.Add(2)
+
+	b.Subscribe("a", func(e Event) {
+		count.Add(1)
+		wg.Done()
+	})
+	b.Subscribe("b", func(e Event) {
+		count.Add(1)
+		wg.Done()
+	})
+
+	b.Publish(Event{Type: EventXrayCrash})
+
+	select {
+	case <-waitDone(&wg):
+	case <-time.After(time.Second):
+		t.Fatal("subscribers did not receive event")
+	}
+
+	if count.Load() != 2 {
+		t.Errorf("got %d calls, want 2", count.Load())
+	}
+}
+
+func TestBusUnsubscribe(t *testing.T) {
+	b := New(16)
+	defer b.Stop()
+
+	var count atomic.Int32
+
+	b.Subscribe("test", func(e Event) {
+		count.Add(1)
+	})
+	b.Unsubscribe("test")
+
+	b.Publish(Event{Type: EventOutboundUp})
+	time.Sleep(50 * time.Millisecond)
+
+	if count.Load() != 0 {
+		t.Errorf("got %d calls after unsubscribe, want 0", count.Load())
+	}
+}
+
+func TestBusReplaceSubscriber(t *testing.T) {
+	b := New(16)
+	defer b.Stop()
+
+	var last string
+	var wg sync.WaitGroup
+	wg.Add(1)
+
+	b.Subscribe("test", func(e Event) {
+		last = "old"
+	})
+	b.Subscribe("test", func(e Event) {
+		last = "new"
+		wg.Done()
+	})
+
+	b.Publish(Event{Type: EventOutboundDown})
+
+	select {
+	case <-waitDone(&wg):
+	case <-time.After(time.Second):
+		t.Fatal("subscriber did not receive event")
+	}
+
+	if last != "new" {
+		t.Errorf("got %q, want %q", last, "new")
+	}
+}
+
+func TestBusPanicRecovery(t *testing.T) {
+	b := New(16)
+	defer b.Stop()
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+
+	b.Subscribe("panicker", func(e Event) {
+		panic("oops")
+	})
+	b.Subscribe("after", func(e Event) {
+		wg.Done()
+	})
+
+	b.Publish(Event{Type: EventOutboundDown})
+
+	select {
+	case <-waitDone(&wg):
+	case <-time.After(time.Second):
+		t.Fatal("subscriber after panicker did not receive event")
+	}
+}
+
+func TestBusBufferFull(t *testing.T) {
+	b := New(2)
+	defer b.Stop()
+
+	b.Subscribe("slow", func(e Event) {
+		time.Sleep(100 * time.Millisecond)
+	})
+
+	b.Publish(Event{Type: EventOutboundDown})
+	b.Publish(Event{Type: EventOutboundUp})
+	b.Publish(Event{Type: EventXrayCrash})
+
+	time.Sleep(50 * time.Millisecond)
+}
+
+func TestBusZeroTimestamp(t *testing.T) {
+	b := New(16)
+	defer b.Stop()
+
+	var received Event
+	var wg sync.WaitGroup
+	wg.Add(1)
+
+	b.Subscribe("test", func(e Event) {
+		received = e
+		wg.Done()
+	})
+
+	b.Publish(Event{Type: EventOutboundDown})
+
+	select {
+	case <-waitDone(&wg):
+	case <-time.After(time.Second):
+		t.Fatal("subscriber did not receive event")
+	}
+
+	if received.Timestamp.IsZero() {
+		t.Error("timestamp should be set automatically")
+	}
+}
+
+func waitDone(wg *sync.WaitGroup) <-chan struct{} {
+	ch := make(chan struct{})
+	go func() {
+		wg.Wait()
+		close(ch)
+	}()
+	return ch
+}

+ 64 - 0
internal/eventbus/events.go

@@ -0,0 +1,64 @@
+package eventbus
+
+import "time"
+
+// EventType identifies the kind of event flowing through the bus.
+type EventType string
+
+const (
+	// Outbound health (observatory-driven)
+	EventOutboundDown EventType = "outbound.down"
+	EventOutboundUp   EventType = "outbound.up"
+
+	// Xray core (local)
+	EventXrayCrash EventType = "xray.crash"
+
+	// Node health (heartbeat-driven)
+	EventNodeDown EventType = "node.down"
+	EventNodeUp   EventType = "node.up"
+
+	// System health
+	EventCPUHigh EventType = "cpu.high"
+
+	// Security
+	EventLoginAttempt EventType = "login.attempt"
+)
+
+// Event is the unit of information flowing through the bus.
+type Event struct {
+	Type      EventType
+	Source    string    // outbound tag, node name, client email, IP, etc.
+	Data      any       // event-specific payload, may be nil
+	Timestamp time.Time // when the event was detected
+}
+
+// OutboundHealthData carries observatory details for outbound events.
+type OutboundHealthData struct {
+	Delay int64  // last measured delay in ms, 0 if unknown
+	Error string // last error if probe failed, empty if up
+}
+
+// NodeHealthData carries heartbeat details for node events.
+type NodeHealthData struct {
+	NodeId    int
+	LatencyMs int
+	CpuPct    float64
+	MemPct    float64
+	XrayState string // "running", "stopped", etc.
+	XrayError string
+}
+
+// LoginEventData carries login attempt details.
+type LoginEventData struct {
+	Username string
+	IP       string
+	Time     string
+	Status   string // "success" or "fail"
+	Reason   string
+}
+
+// SystemMetricData carries raw system metric values for threshold-based events.
+type SystemMetricData struct {
+	Percent   float64 // current usage percentage
+	Threshold int     // configured threshold
+}

+ 33 - 0
internal/eventbus/filter.go

@@ -0,0 +1,33 @@
+package eventbus
+
+import (
+	"sync"
+	"time"
+)
+
+// RateLimiter prevents notification spam from flapping events.
+type RateLimiter struct {
+	mu       sync.Mutex
+	lastSent map[string]time.Time
+	cooldown time.Duration
+}
+
+// NewRateLimiter creates a rate limiter with the given cooldown period.
+func NewRateLimiter(cooldown time.Duration) *RateLimiter {
+	return &RateLimiter{
+		lastSent: make(map[string]time.Time),
+		cooldown: cooldown,
+	}
+}
+
+// Allow returns true if the event should be sent (cooldown has elapsed).
+func (r *RateLimiter) Allow(eventType EventType, source string) bool {
+	key := string(eventType) + ":" + source
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if time.Since(r.lastSent[key]) < r.cooldown {
+		return false
+	}
+	r.lastSent[key] = time.Now()
+	return true
+}

+ 22 - 5
internal/logger/logger.go

@@ -7,6 +7,7 @@ import (
 	"os"
 	"path/filepath"
 	"runtime"
+	"sync"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
@@ -31,8 +32,10 @@ var (
 	logger     *logging.Logger
 	fileRotate *lumberjack.Logger // nil when file backend disabled
 
-	// logBuffer maintains recent log entries in memory for web UI retrieval
-	logBuffer []struct {
+	// logBuffer maintains recent log entries in memory for web UI retrieval;
+	// logBufferMu guards it — written from many goroutines, read by the web UI.
+	logBufferMu sync.Mutex
+	logBuffer   []struct {
 		time  string
 		level logging.Level
 		log   string
@@ -193,6 +196,8 @@ func Errorf(format string, args ...any) {
 // addToBuffer adds a log entry to the in-memory ring buffer for web UI retrieval.
 func addToBuffer(level string, newLog string) {
 	t := time.Now()
+	logBufferMu.Lock()
+	defer logBufferMu.Unlock()
 	if len(logBuffer) >= maxLogBufferSize {
 		logBuffer = logBuffer[1:]
 	}
@@ -214,9 +219,21 @@ func GetLogs(c int, level string) []string {
 	var output []string
 	logLevel, _ := logging.LogLevel(level)
 
-	for i := len(logBuffer) - 1; i >= 0 && len(output) <= c; i-- {
-		if logBuffer[i].level <= logLevel {
-			output = append(output, fmt.Sprintf("%s %s - %s", logBuffer[i].time, logBuffer[i].level, logBuffer[i].log))
+	// Snapshot (copy) under the lock, then filter/format unlocked: a UI log fetch
+	// must not block addToBuffer — and thus all logging — for the formatting loop.
+	// A copy (not a reslice) is required, since addToBuffer can append in place.
+	logBufferMu.Lock()
+	snapshot := make([]struct {
+		time  string
+		level logging.Level
+		log   string
+	}, len(logBuffer))
+	copy(snapshot, logBuffer)
+	logBufferMu.Unlock()
+
+	for i := len(snapshot) - 1; i >= 0 && len(output) < c; i-- {
+		if snapshot[i].level <= logLevel {
+			output = append(output, fmt.Sprintf("%s %s - %s", snapshot[i].time, snapshot[i].level, snapshot[i].log))
 		}
 	}
 	return output

+ 30 - 0
internal/logger/logger_test.go

@@ -0,0 +1,30 @@
+package logger
+
+import (
+	"fmt"
+	"testing"
+)
+
+// TestGetLogs_ReturnsAtMostC guards the documented "up to c entries" contract.
+// The loop condition must cap output at c (ERROR entries are queried at "debug"
+// level so the level filter passes all of them, isolating the count).
+func TestGetLogs_ReturnsAtMostC(t *testing.T) {
+	logBufferMu.Lock()
+	logBuffer = nil
+	logBufferMu.Unlock()
+	for i := 0; i < 5; i++ {
+		addToBuffer("ERROR", fmt.Sprintf("m%d", i))
+	}
+
+	cases := []struct{ c, want int }{
+		{0, 0},
+		{2, 2},
+		{5, 5},
+		{10, 5}, // capped at what's available
+	}
+	for _, tc := range cases {
+		if got := GetLogs(tc.c, "debug"); len(got) != tc.want {
+			t.Errorf("GetLogs(%d) returned %d entries, want %d", tc.c, len(got), tc.want)
+		}
+	}
+}

+ 99 - 0
internal/mtproto/manager_mutation_test.go

@@ -0,0 +1,99 @@
+package mtproto
+
+import (
+	"testing"
+)
+
+// TestParseMetricLineBraceBoundary pins the contract of the brace-position
+// guard in parseMetricLine (manager.go:425 -> `if end < brace`).
+//
+// Once a '{' is found at index `brace`, the matching '}' must appear AFTER it.
+// A '}' that precedes the '{', or a '{' with no closing '}' at all
+// (strings.IndexByte returns -1, which is < brace), is a malformed line and
+// must yield an error rather than slicing past the brace.
+func TestParseMetricLineBraceBoundary(t *testing.T) {
+	t.Run("closing brace before opening brace is malformed", func(t *testing.T) {
+		// '}' at index 8 comes before '{' at index 16: end < brace must hold,
+		// so this is rejected. Mutating `<` to `>`/`>=` would accept it.
+		_, _, _, err := parseMetricLine(`mtg_x_a}_b{direction="x"} 5`)
+		if err == nil {
+			t.Fatal("expected error for '}' appearing before '{'")
+		}
+	})
+
+	t.Run("opening brace with no closing brace is malformed", func(t *testing.T) {
+		// No '}' at all -> end == -1, which is < brace. Must error.
+		// If the guard were dropped/inverted the code would slice line[brace+1:-1]
+		// and panic; asserting a clean error keeps that contract.
+		_, _, _, err := parseMetricLine(`mtg_traffic{direction="x" 5`)
+		if err == nil {
+			t.Fatal("expected error for '{' without a closing '}'")
+		}
+	})
+
+	t.Run("well-formed braces are accepted", func(t *testing.T) {
+		// '{' at index 11, '}' at index 25: end > brace, so the guard must NOT
+		// fire and parsing must succeed. Guards against a mutant that always errors.
+		name, labels, val, err := parseMetricLine(`mtg_traffic{direction="up"} 42`)
+		if err != nil {
+			t.Fatalf("well-formed line should parse: %v", err)
+		}
+		if name != "mtg_traffic" {
+			t.Fatalf("name=%q", name)
+		}
+		if labels["direction"] != "up" {
+			t.Fatalf("labels=%v", labels)
+		}
+		if val != 42 {
+			t.Fatalf("val=%v", val)
+		}
+	})
+}
+
+// TestParseMetricLineLabelEqualsBoundary pins the contract of the '=' guard in
+// the per-label loop (manager.go:430 -> `if eq < 0`).
+//
+//   - eq < 0  (no '=' in the segment): the segment is skipped, no label added.
+//   - eq == 0 (segment begins with '='): the key is empty but the pair is STILL
+//     parsed, producing labels[""] = value. The boundary is `< 0`, not `<= 0`.
+func TestParseMetricLineLabelEqualsBoundary(t *testing.T) {
+	t.Run("label segment without '=' is skipped, not fatal", func(t *testing.T) {
+		// "novalue" has no '=' (eq == -1) and must be skipped. A real key=val
+		// segment in the same line must still be parsed. Mutating `< 0` to `> 0`
+		// would take kv[:eq] with eq=-1 and panic; mutating away the skip would
+		// also corrupt parsing.
+		name, labels, val, err := parseMetricLine(`mtg_traffic{novalue,direction="down"} 9`)
+		if err != nil {
+			t.Fatalf("line with a value-less label should still parse: %v", err)
+		}
+		if name != "mtg_traffic" {
+			t.Fatalf("name=%q", name)
+		}
+		if _, present := labels["novalue"]; present {
+			t.Fatalf("value-less segment must not create a label: %v", labels)
+		}
+		if labels["direction"] != "down" {
+			t.Fatalf("real label must still be parsed: %v", labels)
+		}
+		if val != 9 {
+			t.Fatalf("val=%v", val)
+		}
+	})
+
+	t.Run("label segment beginning with '=' is parsed as empty key", func(t *testing.T) {
+		// "=onlyvalue": eq == 0. Since the guard is `< 0`, this is NOT skipped:
+		// it yields labels[""] = "onlyvalue". A mutant changing `< 0` to `<= 0`
+		// would skip it, losing the empty-key entry.
+		_, labels, _, err := parseMetricLine(`mtg_traffic{=onlyvalue} 1`)
+		if err != nil {
+			t.Fatalf("segment with empty key should still parse: %v", err)
+		}
+		v, present := labels[""]
+		if !present {
+			t.Fatalf("eq==0 segment must produce an empty-key label: %v", labels)
+		}
+		if v != "onlyvalue" {
+			t.Fatalf("empty-key label value=%q", v)
+		}
+	})
+}

+ 21 - 0
internal/sub/build_urls_test.go

@@ -70,6 +70,27 @@ func TestBuildURLs_EmptySubId(t *testing.T) {
 	}
 }
 
+func TestForRequestDoesNotMutateSharedService(t *testing.T) {
+	initSubDB(t)
+	base := &SubService{}
+
+	first := base.ForRequest("first.example.com")
+	second := base.ForRequest("second.example.com")
+
+	if base.address != "" || base.nodesByID != nil {
+		t.Fatalf("ForRequest mutated the shared service: address=%q nodes=%v", base.address, base.nodesByID)
+	}
+
+	firstURL, _, _ := first.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
+	secondURL, _, _ := second.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
+	if !strings.Contains(firstURL, "first.example.com") {
+		t.Fatalf("first request URL = %q, want first.example.com", firstURL)
+	}
+	if !strings.Contains(secondURL, "second.example.com") {
+		t.Fatalf("second request URL = %q, want second.example.com", secondURL)
+	}
+}
+
 // A subscriber arriving via a reverse proxy (subURI configured with full
 // HTTPS URL) must see the same scheme+host in the JSON and Clash Copy
 // URLs as in the main subURL — not the raw sub-server port 2096.

+ 15 - 16
internal/sub/clash_service.go

@@ -22,13 +22,12 @@ func NewSubClashService(enableRouting bool, clashRules string, subService *SubSe
 }
 
 func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
-	// Set per-request state so resolveInboundAddress sees the node map.
-	s.SubService.PrepareForRequest(host)
-	inbounds, err := s.SubService.getInboundsBySubId(subId)
+	subReq := s.SubService.ForRequest(host)
+	inbounds, err := subReq.getInboundsBySubId(subId)
 	if err != nil {
 		return "", "", err
 	}
-	externalLinks, err := s.SubService.getClientExternalLinksBySubId(subId)
+	externalLinks, err := subReq.getClientExternalLinksBySubId(subId)
 	if err != nil {
 		return "", "", err
 	}
@@ -40,14 +39,14 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 
 	seenEmails := make(map[string]struct{})
 	for _, inbound := range inbounds {
-		clients := s.SubService.matchingClients(inbound, subId)
+		clients := subReq.matchingClients(inbound, subId)
 		if len(clients) == 0 {
 			continue
 		}
-		s.SubService.projectThroughFallbackMaster(inbound)
+		subReq.projectThroughFallbackMaster(inbound)
 		for _, client := range clients {
 			seenEmails[client.Email] = struct{}{}
-			proxies = append(proxies, s.getProxies(inbound, client, host)...)
+			proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
 		}
 	}
 	for _, ext := range externalLinks {
@@ -73,7 +72,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 	for e := range seenEmails {
 		emails = append(emails, e)
 	}
-	traffic, _ := s.SubService.AggregateTrafficByEmails(emails)
+	traffic, _ := subReq.AggregateTrafficByEmails(emails)
 
 	proxyNames := make([]string, 0, len(proxies)+1)
 	for _, proxy := range proxies {
@@ -140,12 +139,12 @@ func fallbackProxyName(proxy map[string]any, idx int) string {
 	return fmt.Sprintf("proxy-%d", idx+1)
 }
 
-func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any {
+func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []map[string]any {
 	stream := s.streamData(inbound.StreamSettings)
 	// For node-managed inbounds the Clash proxy "server" must be the
 	// node's address, not the request host. resolveInboundAddress handles
 	// the node→subscriber-host fallback chain.
-	defaultDest := s.SubService.resolveInboundAddress(inbound)
+	defaultDest := subReq.resolveInboundAddress(inbound)
 	if defaultDest == "" {
 		defaultDest = host
 	}
@@ -187,7 +186,7 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
 			applyExternalProxyTLSToStream(extPrxy, workingStream, security)
 		}
 
-		proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string))
+		proxy := s.buildProxy(subReq, &workingInbound, client, workingStream, extPrxy["remark"].(string))
 		if len(proxy) > 0 {
 			proxies = append(proxies, proxy)
 		}
@@ -195,15 +194,15 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
 	return proxies
 }
 
-func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
+func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
 	// Hysteria has its own transport + TLS model, applyTransport /
 	// applySecurity don't fit.
 	if inbound.Protocol == model.Hysteria {
-		return s.buildHysteriaProxy(inbound, client, extraRemark)
+		return s.buildHysteriaProxy(subReq, inbound, client, extraRemark)
 	}
 
 	proxy := map[string]any{
-		"name":   s.SubService.genRemark(inbound, client.Email, extraRemark),
+		"name":   subReq.genRemark(inbound, client.Email, extraRemark),
 		"server": inbound.Listen,
 		"port":   inbound.Port,
 		"udp":    true,
@@ -274,7 +273,7 @@ func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client
 // directly instead of going through streamData/tlsData, because those
 // helpers prune fields (like `allowInsecure` / the salamander obfs
 // block) that the hysteria proxy wants preserved.
-func (s *SubClashService) buildHysteriaProxy(inbound *model.Inbound, client model.Client, extraRemark string) map[string]any {
+func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.Inbound, client model.Client, extraRemark string) map[string]any {
 	var inboundSettings map[string]any
 	_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
 
@@ -286,7 +285,7 @@ func (s *SubClashService) buildHysteriaProxy(inbound *model.Inbound, client mode
 	}
 
 	proxy := map[string]any{
-		"name":   s.SubService.genRemark(inbound, client.Email, extraRemark),
+		"name":   subReq.genRemark(inbound, client.Email, extraRemark),
 		"type":   proxyType,
 		"server": inbound.Listen,
 		"port":   inbound.Port,

+ 76 - 4
internal/sub/clash_service_test.go

@@ -41,6 +41,64 @@ func TestEnsureUniqueProxyNames(t *testing.T) {
 	}
 }
 
+// TestBuildProxy_VLESSRealityFieldsForClash locks the reality field mapping in
+// applySecurity (clash_service.go ~488): a regression that drops servername,
+// public-key, short-id, or client-fingerprint would hand mihomo a broken reality
+// proxy. The existing clash tests don't assert any of these.
+func TestBuildProxy_VLESSRealityFieldsForClash(t *testing.T) {
+	svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
+	inbound := &model.Inbound{Listen: "203.0.113.1", Port: 443, Protocol: model.VLESS, Remark: "r", Settings: `{"encryption":"none"}`}
+	client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
+	stream := map[string]any{
+		"network":         "tcp",
+		"security":        "reality",
+		"tcpSettings":     map[string]any{"header": map[string]any{"type": "none"}},
+		"realitySettings": map[string]any{"serverName": "reality.example.com", "publicKey": "PBKvalue", "shortId": "ab12", "fingerprint": "chrome"},
+	}
+
+	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
+	if proxy == nil {
+		t.Fatal("buildProxy returned nil for a valid reality stream")
+	}
+	if proxy["tls"] != true {
+		t.Fatalf("tls = %v, want true", proxy["tls"])
+	}
+	if proxy["servername"] != "reality.example.com" {
+		t.Fatalf("servername = %v, want reality.example.com", proxy["servername"])
+	}
+	if proxy["client-fingerprint"] != "chrome" {
+		t.Fatalf("client-fingerprint = %v, want chrome", proxy["client-fingerprint"])
+	}
+	opts, _ := proxy["reality-opts"].(map[string]any)
+	if opts == nil {
+		t.Fatal("reality-opts missing")
+	}
+	if opts["public-key"] != "PBKvalue" {
+		t.Fatalf("public-key = %v, want PBKvalue", opts["public-key"])
+	}
+	if opts["short-id"] != "ab12" {
+		t.Fatalf("short-id = %v, want ab12", opts["short-id"])
+	}
+}
+
+// TestApplyTransport_TCPHeader pins the tcp-header validation (clash_service.go ~359):
+// plain tcp and a "none" header are representable in clash; a non-none obfs header is
+// not, so applyTransport must reject it (returning false drops it from the YAML).
+func TestApplyTransport_TCPHeader(t *testing.T) {
+	svc := &SubClashService{}
+	if !svc.applyTransport(map[string]any{}, "tcp", map[string]any{}) {
+		t.Fatal("plain tcp must be buildable")
+	}
+	noneStream := map[string]any{"tcpSettings": map[string]any{"header": map[string]any{"type": "none"}}}
+	if !svc.applyTransport(map[string]any{}, "tcp", noneStream) {
+		t.Fatal("tcp + header type none must be buildable")
+	}
+	httpStream := map[string]any{"tcpSettings": map[string]any{"header": map[string]any{"type": "http"}}}
+	if svc.applyTransport(map[string]any{}, "tcp", httpStream) {
+		t.Fatal("tcp + non-none (http) header is not representable in clash and must be rejected")
+	}
+}
+
 func TestApplyTransport_XHTTP(t *testing.T) {
 	svc := &SubClashService{}
 	proxy := map[string]any{}
@@ -141,7 +199,7 @@ func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testi
 		},
 	}
 
-	proxy := svc.buildProxy(inbound, client, stream, "")
+	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
 
 	if proxy["encryption"] != encryption {
 		t.Fatalf("encryption = %v, want %q", proxy["encryption"], encryption)
@@ -173,7 +231,7 @@ func TestBuildProxy_VLESSFlowXhttpRealityVlessenc(t *testing.T) {
 		},
 	}
 
-	proxy := svc.buildProxy(inbound, client, stream, "")
+	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
 
 	if proxy["flow"] != "xtls-rprx-vision" {
 		t.Fatalf("xhttp+reality+vlessenc Clash proxy must carry the vision flow (#5232): %#v", proxy)
@@ -198,7 +256,7 @@ func TestBuildProxy_VLESSFlowDroppedWithoutVisionSupport(t *testing.T) {
 		},
 	}
 
-	proxy := svc.buildProxy(inbound, client, stream, "")
+	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
 
 	if _, ok := proxy["flow"]; ok {
 		t.Fatalf("tcp without tls/reality must not carry a flow: %#v", proxy)
@@ -223,9 +281,23 @@ func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
 		},
 	}
 
-	proxy := svc.buildProxy(inbound, client, stream, "")
+	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
 
 	if _, ok := proxy["encryption"]; ok {
 		t.Fatalf("plain vless encryption should be omitted for mihomo: %#v", proxy)
 	}
+	// The rest of the proxy must still be well-formed — otherwise a mutant that
+	// drops encryption *and* corrupts a core field passes the absence check alone.
+	if proxy["type"] != "vless" {
+		t.Fatalf("type = %v, want vless", proxy["type"])
+	}
+	if proxy["server"] != "203.0.113.1" {
+		t.Fatalf("server = %v, want 203.0.113.1", proxy["server"])
+	}
+	if proxy["port"] != 443 {
+		t.Fatalf("port = %v, want 443", proxy["port"])
+	}
+	if proxy["uuid"] != client.ID {
+		t.Fatalf("uuid = %v, want %v", proxy["uuid"], client.ID)
+	}
 }

+ 4 - 3
internal/sub/controller.go

@@ -137,7 +137,8 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
 func (a *SUBController) subs(c *gin.Context) {
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
-	subs, emails, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
+	subReq := a.subService.ForRequest(host)
+	subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
 	if err != nil || len(subs) == 0 {
 		writeSubError(c, err)
 	} else {
@@ -149,7 +150,7 @@ func (a *SUBController) subs(c *gin.Context) {
 		// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
 		accept := c.GetHeader("Accept")
 		if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
-			subURL, subJsonURL, subClashURL := a.subService.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
+			subURL, subJsonURL, subClashURL := subReq.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
 			if !a.jsonEnabled {
 				subJsonURL = ""
 			}
@@ -161,7 +162,7 @@ func (a *SUBController) subs(c *gin.Context) {
 				basePath = "/"
 			}
 			basePathStr := basePath.(string)
-			page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
+			page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
 			a.serveSubPage(c, basePathStr, page)
 			return
 		}

+ 51 - 0
internal/sub/external_only_sub_test.go

@@ -0,0 +1,51 @@
+package sub
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// A subscription whose only entries are external links — no enabled standard
+// inbound — must still render in the JSON and Clash formats, not just the raw
+// one. Regression guard for the premature len(inbounds)==0 early return that
+// short-circuited GetJson/GetClash before external links were ever fetched.
+func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+
+	rec := &model.ClientRecord{Email: "ext@x", SubID: "ext-only", UUID: "ext-uuid", Enable: true}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	link := "vless://[email protected]:443?type=tcp&security=reality&pbk=abc&sid=12&fp=chrome#orig"
+	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: link, Remark: "DE-Provider", SortIndex: 1}).Error; err != nil {
+		t.Fatalf("seed external link: %v", err)
+	}
+
+	base := NewSubService(false, "-io")
+
+	jsonOut, _, err := NewSubJsonService("", "", "", base).GetJson("ext-only", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetJson err = %v", err)
+	}
+	if jsonOut == "" {
+		t.Fatal("GetJson returned empty for an external-link-only sub")
+	}
+	if !strings.Contains(jsonOut, "DE-Provider") {
+		t.Fatalf("GetJson missing external remark: %s", jsonOut)
+	}
+
+	clashOut, _, err := NewSubClashService(false, "", base).GetClash("ext-only", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetClash err = %v", err)
+	}
+	if clashOut == "" {
+		t.Fatal("GetClash returned empty for an external-link-only sub")
+	}
+	if !strings.Contains(clashOut, "DE-Provider") {
+		t.Fatalf("GetClash missing external proxy: %s", clashOut)
+	}
+}

+ 10 - 12
internal/sub/json_service.go

@@ -58,14 +58,12 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
 
 // GetJson generates a JSON subscription configuration for the given subscription ID and host.
 func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) {
-	// Set per-request state on the shared SubService so any
-	// resolveInboundAddress call inside picks node-aware host values.
-	s.SubService.PrepareForRequest(host)
-	inbounds, err := s.SubService.getInboundsBySubId(subId)
+	subReq := s.SubService.ForRequest(host)
+	inbounds, err := subReq.getInboundsBySubId(subId)
 	if err != nil {
 		return "", "", err
 	}
-	externalLinks, err := s.SubService.getClientExternalLinksBySubId(subId)
+	externalLinks, err := subReq.getClientExternalLinksBySubId(subId)
 	if err != nil {
 		return "", "", err
 	}
@@ -79,15 +77,15 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
 	seenEmails := make(map[string]struct{})
 	// Prepare Inbounds
 	for _, inbound := range inbounds {
-		clients := s.SubService.matchingClients(inbound, subId)
+		clients := subReq.matchingClients(inbound, subId)
 		if len(clients) == 0 {
 			continue
 		}
-		s.SubService.projectThroughFallbackMaster(inbound)
+		subReq.projectThroughFallbackMaster(inbound)
 
 		for _, client := range clients {
 			seenEmails[client.Email] = struct{}{}
-			configArray = append(configArray, s.getConfig(inbound, client, host)...)
+			configArray = append(configArray, s.getConfig(subReq, inbound, client, host)...)
 		}
 	}
 	for _, ext := range externalLinks {
@@ -120,7 +118,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
 	for e := range seenEmails {
 		emails = append(emails, e)
 	}
-	traffic, _ := s.SubService.AggregateTrafficByEmails(emails)
+	traffic, _ := subReq.AggregateTrafficByEmails(emails)
 
 	// Combile outbounds
 	var finalJson []byte
@@ -134,7 +132,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
 	return string(finalJson), header, nil
 }
 
-func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
+func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
 	var newJsonArray []json_util.RawMessage
 	stream := s.streamData(inbound.StreamSettings)
 
@@ -143,7 +141,7 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
 	// For node-managed inbounds we want the node's address — request
 	// host won't reach the right xray. resolveInboundAddress already
 	// implements the node→subscriber-host fallback chain.
-	defaultDest := s.SubService.resolveInboundAddress(inbound)
+	defaultDest := subReq.resolveInboundAddress(inbound)
 	if defaultDest == "" {
 		defaultDest = host
 	}
@@ -204,7 +202,7 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
 		maps.Copy(newConfigJson, s.configJson)
 
 		newConfigJson["outbounds"] = newOutbounds
-		newConfigJson["remarks"] = s.SubService.genRemark(inbound, client.Email, extPrxy["remark"].(string))
+		newConfigJson["remarks"] = subReq.genRemark(inbound, client.Email, extPrxy["remark"].(string))
 
 		newConfig, _ := json.MarshalIndent(newConfigJson, "", "  ")
 		newJsonArray = append(newJsonArray, newConfig)

+ 484 - 0
internal/sub/mutation_audit_test.go

@@ -0,0 +1,484 @@
+package sub
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// initMutDB spins up a real temp SQLite DB for tests that exercise DB-backed
+// query helpers, mirroring the house pattern in service_sharelink/dedup tests.
+func initMutDB(t *testing.T) {
+	t.Helper()
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+}
+
+// --- json_service.go:40 — rules are merged into routing only when non-empty ---
+
+func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
+	rules := `[{"type":"field","domain":["geosite:ads"],"outboundTag":"block"}]`
+	svc := NewSubJsonService("", rules, "", nil)
+
+	routing, ok := svc.configJson["routing"].(map[string]any)
+	if !ok {
+		t.Fatalf("routing missing: %#v", svc.configJson["routing"])
+	}
+	got, _ := routing["rules"].([]any)
+	// default.json ships exactly 1 rule; the custom rule must be prepended.
+	if len(got) != 2 {
+		t.Fatalf("rules len = %d, want 2 (custom prepended to default)", len(got))
+	}
+	first, _ := got[0].(map[string]any)
+	if domains, _ := first["domain"].([]any); len(domains) != 1 || domains[0] != "geosite:ads" {
+		t.Fatalf("custom rule must come first, got %#v", got[0])
+	}
+}
+
+func TestSubJsonService_EmptyRulesLeavesDefault(t *testing.T) {
+	svc := NewSubJsonService("", "", "", nil)
+	routing, _ := svc.configJson["routing"].(map[string]any)
+	got, _ := routing["rules"].([]any)
+	if len(got) != 1 {
+		t.Fatalf("rules len = %d, want 1 (no custom rules → default untouched)", len(got))
+	}
+}
+
+// --- json_service.go:331,356,408 — mux is attached only when configured ---
+
+func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
+	const mux = `{"enabled":true,"concurrency":8}`
+	client := model.Client{ID: "uuid-1", Password: "p4ss"}
+
+	cases := []struct {
+		name     string
+		raw      []byte
+		wantMux  bool
+		protocol model.Protocol
+	}{
+		{"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client), true, model.VMESS},
+		{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client), true, model.VLESS},
+		{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client), true, model.Trojan},
+		{"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client), false, model.VMESS},
+		{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client), false, model.VLESS},
+		{"server no mux", NewSubJsonService("", "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client), false, model.Trojan},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			var ob map[string]any
+			if err := json.Unmarshal(tc.raw, &ob); err != nil {
+				t.Fatalf("unmarshal outbound: %v", err)
+			}
+			m, has := ob["mux"]
+			if tc.wantMux {
+				if !has {
+					t.Fatalf("mux must be set when configured, outbound = %#v", ob)
+				}
+				mm, _ := m.(map[string]any)
+				if mm["enabled"] != true || mm["concurrency"] != float64(8) {
+					t.Fatalf("mux payload wrong: %#v", m)
+				}
+			} else if has {
+				t.Fatalf("mux must be omitted when empty, outbound = %#v", ob)
+			}
+		})
+	}
+}
+
+// --- json_service.go:268 — a non-empty finalMask that merges to nothing must
+// not add the finalmask key (the `len(merged) > 0` guard). ---
+
+func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
+	// finalMask is non-empty (passes the len(fm)==0 early return) but its only
+	// key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
+	// so applyGlobalFinalMask (json_service.go:268) must NOT set finalmask.
+	svc := NewSubJsonService("", "", `{"tcp":[]}`, nil)
+	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
+	if _, ok := stream["finalmask"]; ok {
+		t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"])
+	}
+
+	// Sanity: a finalMask that DOES merge to something still gets set, so the
+	// guard is the only distinguishing factor.
+	svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
+	stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
+	if _, ok := stream2["finalmask"]; !ok {
+		t.Fatal("non-empty finalMask must be set")
+	}
+}
+
+// --- json_service.go:494 — an empty extra tcp slice must not clobber the base ---
+
+func TestMergeFinalMask_EmptyExtraTcpKeepsBase(t *testing.T) {
+	base := map[string]any{"tcp": []any{map[string]any{"type": "keep"}}}
+	extra := map[string]any{"tcp": []any{}} // empty → must be ignored
+	merged := mergeFinalMask(base, extra)
+	tcp, _ := merged["tcp"].([]any)
+	if len(tcp) != 1 {
+		t.Fatalf("tcp len = %d, want 1 (empty extra must not drop or append)", len(tcp))
+	}
+	if first, _ := tcp[0].(map[string]any); first["type"] != "keep" {
+		t.Fatalf("base tcp mask lost: %#v", tcp)
+	}
+	// Sanity: a non-empty extra DOES append, so the guard is the only thing
+	// distinguishing the two paths.
+	extra2 := map[string]any{"tcp": []any{map[string]any{"type": "add"}}}
+	merged2 := mergeFinalMask(base, extra2)
+	if tcp2, _ := merged2["tcp"].([]any); len(tcp2) != 2 {
+		t.Fatalf("non-empty extra must append: len = %d, want 2", len(tcp2))
+	}
+}
+
+// --- service.go:69-77 — configuredPublicHost priority: subDomain > webDomain > "" ---
+
+func TestConfiguredPublicHost_Priority(t *testing.T) {
+	initMutDB(t)
+	db := database.GetDB()
+	set := func(key, val string) {
+		if err := db.Save(&model.Setting{Key: key, Value: val}).Error; err != nil {
+			t.Fatalf("save %s: %v", key, err)
+		}
+	}
+
+	s := &SubService{}
+
+	// Both empty → "".
+	if got := s.configuredPublicHost(); got != "" {
+		t.Fatalf("no domains configured: got %q, want empty", got)
+	}
+
+	// Only webDomain → webDomain wins (exercises the second branch, service.go:73).
+	set("webDomain", "web.example.com")
+	if got := s.configuredPublicHost(); got != "web.example.com" {
+		t.Fatalf("webDomain fallback: got %q, want web.example.com", got)
+	}
+
+	// subDomain set → subDomain takes precedence over webDomain (service.go:70).
+	set("subDomain", "sub.example.com")
+	if got := s.configuredPublicHost(); got != "sub.example.com" {
+		t.Fatalf("subDomain priority: got %q, want sub.example.com", got)
+	}
+}
+
+// --- service.go:248 — AggregateTrafficByEmails tracks the MAX LastOnline ---
+
+func TestAggregateTrafficByEmails_LastOnlineIsMax(t *testing.T) {
+	initMutDB(t)
+	db := database.GetDB()
+
+	rows := []xray.ClientTraffic{
+		{Email: "a@x", Up: 10, Down: 20, LastOnline: 100},
+		{Email: "b@x", Up: 1, Down: 2, LastOnline: 500}, // the max
+		{Email: "c@x", Up: 3, Down: 4, LastOnline: 300},
+	}
+	for i := range rows {
+		if err := db.Create(&rows[i]).Error; err != nil {
+			t.Fatalf("seed traffic: %v", err)
+		}
+	}
+
+	s := &SubService{}
+	agg, lastOnline := s.AggregateTrafficByEmails([]string{"a@x", "b@x", "c@x"})
+	if lastOnline != 500 {
+		t.Fatalf("lastOnline = %d, want 500 (max across rows)", lastOnline)
+	}
+	// Up/Down must still sum so a mutant can't pass by zeroing everything.
+	if agg.Up != 14 || agg.Down != 26 {
+		t.Fatalf("agg up/down = %d/%d, want 14/26", agg.Up, agg.Down)
+	}
+}
+
+// --- service.go:329 — projectThroughFallbackMaster returns false for nil ---
+
+func TestProjectThroughFallbackMaster_Nil(t *testing.T) {
+	s := &SubService{}
+	if s.projectThroughFallbackMaster(nil) {
+		t.Fatal("nil inbound must yield false (no projection, no DB hit)")
+	}
+}
+
+// --- service.go:555 — empty client flow must not emit a flow param even when allowed ---
+
+func TestGenVlessLink_NoFlowWhenClientFlowEmpty(t *testing.T) {
+	// tcp+reality is a flow-allowed combo; with an empty client flow the
+	// len(...)>0 guard (service.go:555) must keep `flow` out of the link.
+	stream := `{
+		"network":"tcp","security":"reality",
+		"tcpSettings":{"header":{"type":"none"}},
+		"realitySettings":{"serverNames":["r.example.com"],"shortIds":["ab"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}
+	}`
+	inbound := &model.Inbound{
+		Listen:         "203.0.113.1",
+		Port:           443,
+		Protocol:       model.VLESS,
+		Remark:         "noflow",
+		Settings:       `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}],"encryption":"none"}`,
+		StreamSettings: stream,
+	}
+	s := &SubService{remarkModel: "-ieo"}
+	if link := s.genVlessLink(inbound, "user"); strings.Contains(link, "flow=") {
+		t.Fatalf("empty client flow must not produce a flow param, got %q", link)
+	}
+}
+
+// --- service.go:906-913 — applyPathAndHostParams host source ---
+
+func TestApplyPathAndHostParams(t *testing.T) {
+	// Direct host wins (service.go:908 true branch).
+	params := map[string]string{}
+	applyPathAndHostParams(map[string]any{"path": "/p", "host": "direct.example.com"}, params)
+	if params["path"] != "/p" {
+		t.Fatalf("path = %q, want /p", params["path"])
+	}
+	if params["host"] != "direct.example.com" {
+		t.Fatalf("direct host = %q, want direct.example.com", params["host"])
+	}
+
+	// No direct host → fall back to headers.Host (service.go:908 false branch).
+	params = map[string]string{}
+	applyPathAndHostParams(map[string]any{
+		"path":    "/p",
+		"headers": map[string]any{"Host": "via-header.example.com"},
+	}, params)
+	if params["host"] != "via-header.example.com" {
+		t.Fatalf("header host fallback = %q, want via-header.example.com", params["host"])
+	}
+
+	// Empty-string host must NOT shadow the header fallback (len(host) > 0 guard).
+	params = map[string]string{}
+	applyPathAndHostParams(map[string]any{
+		"path":    "/p",
+		"host":    "",
+		"headers": map[string]any{"Host": "via-header.example.com"},
+	}, params)
+	if params["host"] != "via-header.example.com" {
+		t.Fatalf("empty host must defer to headers, got %q", params["host"])
+	}
+}
+
+// --- external_config.go:39,42,55,58 — getClientExternalLinksBySubId ---
+
+func TestGetClientExternalLinksBySubId(t *testing.T) {
+	initMutDB(t)
+	db := database.GetDB()
+	s := &SubService{}
+
+	// No client rows for the subId → nil, no error (service.go path :42).
+	out, err := s.getClientExternalLinksBySubId("missing")
+	if err != nil {
+		t.Fatalf("missing subId err = %v, want nil", err)
+	}
+	if out != nil {
+		t.Fatalf("missing subId = %#v, want nil", out)
+	}
+
+	// A client with NO external-link rows → nil (the rows-empty guard :58).
+	bare := &model.ClientRecord{Email: "bare@x", SubID: "sub-bare", UUID: "u", Enable: true}
+	if err := db.Create(bare).Error; err != nil {
+		t.Fatalf("seed bare client: %v", err)
+	}
+	out, err = s.getClientExternalLinksBySubId("sub-bare")
+	if err != nil {
+		t.Fatalf("bare subId err = %v", err)
+	}
+	if out != nil {
+		t.Fatalf("client with no links = %#v, want nil", out)
+	}
+
+	// A client with two link rows: ordering by sort_index and email/enable
+	// attribution from the owning client (the loop copies rec.Email/rec.Enable).
+	rec := &model.ClientRecord{Email: "owner@x", SubID: "sub-ok", UUID: "u2", Enable: true}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://b", Remark: "second", SortIndex: 5}).Error; err != nil {
+		t.Fatalf("seed link b: %v", err)
+	}
+	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://a", Remark: "first", SortIndex: 1}).Error; err != nil {
+		t.Fatalf("seed link a: %v", err)
+	}
+
+	out, err = s.getClientExternalLinksBySubId("sub-ok")
+	if err != nil {
+		t.Fatalf("ok subId err = %v", err)
+	}
+	if len(out) != 2 {
+		t.Fatalf("entries = %d, want 2", len(out))
+	}
+	// sort_index ASC: the SortIndex=1 row comes first.
+	if out[0].Value != "trojan://a" || out[1].Value != "trojan://b" {
+		t.Fatalf("ordering wrong: %#v", out)
+	}
+	// Email + Enable must be copied from the owning client, not the link row
+	// (which carries neither field). The enabled owner → Enable true.
+	if out[0].Email != "owner@x" || out[0].Enable != true {
+		t.Fatalf("attribution wrong: email=%q enable=%v", out[0].Email, out[0].Enable)
+	}
+
+	// A DISABLED client must produce entries with Enable=false, proving the
+	// value is read from the client row (Enable has a gorm default:true, so
+	// flip it with a raw UPDATE that bypasses the default).
+	dis := &model.ClientRecord{Email: "off@x", SubID: "sub-off", UUID: "u3", Enable: true}
+	if err := db.Create(dis).Error; err != nil {
+		t.Fatalf("seed disabled client: %v", err)
+	}
+	if err := db.Model(&model.ClientRecord{}).Where("id = ?", dis.Id).Update("enable", false).Error; err != nil {
+		t.Fatalf("disable client: %v", err)
+	}
+	if err := db.Create(&model.ClientExternalLink{ClientId: dis.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://c", SortIndex: 1}).Error; err != nil {
+		t.Fatalf("seed link c: %v", err)
+	}
+	offOut, err := s.getClientExternalLinksBySubId("sub-off")
+	if err != nil {
+		t.Fatalf("off subId err = %v", err)
+	}
+	if len(offOut) != 1 {
+		t.Fatalf("disabled client entries = %d, want 1", len(offOut))
+	}
+	if offOut[0].Email != "off@x" || offOut[0].Enable != false {
+		t.Fatalf("disabled attribution wrong: email=%q enable=%v", offOut[0].Email, offOut[0].Enable)
+	}
+}
+
+// --- external_config.go:102 — applyRemarkToLink appends a fragment when none exists ---
+
+func TestApplyRemarkToLink_NoFragmentAppends(t *testing.T) {
+	link := "trojan://[email protected]:8443?security=tls"
+	out := applyRemarkToLink(link, "DE-Node")
+	if out != link+"#DE-Node" {
+		t.Fatalf("no-fragment link must get the remark appended, got %q", out)
+	}
+}
+
+// --- external_config.go:111 — applyVmessRemark falls back to RawURLEncoding ---
+
+func TestApplyVmessRemark_RawURLEncodingFallback(t *testing.T) {
+	// The "aa?" ps forces a URL-safe char (_) in the RawURL encoding, so
+	// base64.StdEncoding.DecodeString fails and the RawURLEncoding fallback
+	// path (external_config.go:111) must take over. (ps is overwritten below,
+	// so its value is irrelevant to the assertions.)
+	payload := map[string]any{"v": "2", "ps": "aa?", "add": "1.2.3.4", "port": "443", "id": "uuid"}
+	b, _ := json.Marshal(payload)
+	link := "vmess://" + base64.RawURLEncoding.EncodeToString(b)
+	// Guard the premise: this link must NOT be std-decodable, else the fallback
+	// branch is never reached and the test is meaningless.
+	if _, err := base64.StdEncoding.DecodeString(padBase64Sub(strings.TrimPrefix(link, "vmess://"))); err == nil {
+		t.Fatal("test premise broken: link is std-base64 decodable, fallback not exercised")
+	}
+
+	out := applyRemarkToLink(link, "NL-Node")
+	if out == link {
+		t.Fatalf("raw-url-encoded vmess remark was not applied (fallback decode broken): %q", out)
+	}
+	// The result re-encodes with StdEncoding; decode and verify ps + credentials.
+	raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(out, "vmess://"))
+	if err != nil {
+		t.Fatalf("decode out: %v", err)
+	}
+	var got map[string]any
+	if err := json.Unmarshal(raw, &got); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	if got["ps"] != "NL-Node" {
+		t.Fatalf("ps = %v, want NL-Node", got["ps"])
+	}
+	if got["id"] != "uuid" {
+		t.Fatalf("credentials lost via fallback path: %#v", got)
+	}
+}
+
+// --- external_config.go:130 — padBase64Sub pads to a multiple of 4 ---
+
+func TestPadBase64Sub(t *testing.T) {
+	cases := map[string]string{
+		"":     "",
+		"a":    "a===",
+		"ab":   "ab==",
+		"abc":  "abc=",
+		"abcd": "abcd",
+	}
+	for in, want := range cases {
+		if got := padBase64Sub(in); got != want {
+			t.Fatalf("padBase64Sub(%q) = %q, want %q", in, got, want)
+		}
+		if len(padBase64Sub(in))%4 != 0 {
+			t.Fatalf("padBase64Sub(%q) length not a multiple of 4", in)
+		}
+	}
+}
+
+// --- external_subscription.go:122 — base64 body decode strips embedded whitespace ---
+
+func TestDecodeSubscriptionBody_StripsWhitespaceInBase64(t *testing.T) {
+	plain := "vless://[email protected]:443#one\ntrojan://[email protected]:8443#two\n"
+	encoded := base64.StdEncoding.EncodeToString([]byte(plain))
+	// Inject whitespace into the base64 token; tryDecodeBase64Body must strip it
+	// (external_subscription.go:122) so decoding still succeeds.
+	half := len(encoded) / 2
+	dirty := encoded[:half] + "\n \t" + encoded[half:]
+
+	links := decodeSubscriptionBody([]byte(dirty))
+	if len(links) != 2 || links[0] != "vless://[email protected]:443#one" || links[1] != "trojan://[email protected]:8443#two" {
+		t.Fatalf("whitespace-laden base64 body decoded wrong: %#v", links)
+	}
+}
+
+// --- clash_service.go:123 — duplicate proxy names disambiguate as base-N ---
+
+func TestEnsureUniqueProxyNames_SuffixSequence(t *testing.T) {
+	proxies := []map[string]any{
+		{"name": "node"},
+		{"name": "node"},
+		{"name": "node"},
+	}
+	ensureUniqueProxyNames(proxies)
+	if proxies[0]["name"] != "node" {
+		t.Fatalf("first occurrence must keep base name, got %v", proxies[0]["name"])
+	}
+	if proxies[1]["name"] != "node-2" {
+		t.Fatalf("second duplicate = %v, want node-2", proxies[1]["name"])
+	}
+	if proxies[2]["name"] != "node-3" {
+		t.Fatalf("third duplicate = %v, want node-3", proxies[2]["name"])
+	}
+}
+
+// --- clash_service.go:422,447 — empty transport opts must NOT add the *-opts key ---
+
+func TestApplyTransport_EmptyOptsOmitted(t *testing.T) {
+	svc := &SubClashService{}
+
+	// httpupgrade with no path/host → opts empty → no http-upgrade-opts key (clash:422).
+	huProxy := map[string]any{}
+	if !svc.applyTransport(huProxy, "httpupgrade", map[string]any{"httpupgradeSettings": map[string]any{}}) {
+		t.Fatal("httpupgrade must still be buildable")
+	}
+	if huProxy["network"] != "httpupgrade" {
+		t.Fatalf("network = %v, want httpupgrade", huProxy["network"])
+	}
+	if _, ok := huProxy["http-upgrade-opts"]; ok {
+		t.Fatalf("empty opts must not set http-upgrade-opts: %#v", huProxy["http-upgrade-opts"])
+	}
+
+	// xhttp with no path/host/mode → opts empty → no xhttp-opts key (clash:447).
+	xhProxy := map[string]any{}
+	if !svc.applyTransport(xhProxy, "xhttp", map[string]any{"xhttpSettings": map[string]any{}}) {
+		t.Fatal("xhttp must still be buildable")
+	}
+	if xhProxy["network"] != "xhttp" {
+		t.Fatalf("network = %v, want xhttp", xhProxy["network"])
+	}
+	if _, ok := xhProxy["xhttp-opts"]; ok {
+		t.Fatalf("empty opts must not set xhttp-opts: %#v", xhProxy["xhttp-opts"])
+	}
+}

+ 33 - 16
internal/sub/service.go

@@ -49,11 +49,18 @@ func NewSubService(showInfo bool, remarkModel string) *SubService {
 	}
 }
 
-// PrepareForRequest sets per-request state (host + nodes map) on the
-// shared SubService. Called by every entry point — GetSubs, GetJson,
-// GetClash — so resolveInboundAddress sees the right host and the
-// freshly-loaded node map regardless of which sub flavour the client
-// hit.
+// ForRequest returns a shallow copy with request-scoped state populated.
+// Subscription controllers share one base SubService, so request-specific
+// fields such as address and nodesByID must live on a per-request copy.
+func (s *SubService) ForRequest(host string) *SubService {
+	req := *s
+	req.PrepareForRequest(host)
+	return &req
+}
+
+// PrepareForRequest sets per-request state (host + nodes map) on this
+// SubService instance. HTTP handlers should call ForRequest instead so the
+// controller's shared base service is never mutated by concurrent requests.
 func (s *SubService) PrepareForRequest(host string) {
 	if !isRoutableHost(host) {
 		if d := s.configuredPublicHost(); d != "" {
@@ -64,6 +71,23 @@ func (s *SubService) PrepareForRequest(host string) {
 	}
 	s.address = host
 	s.loadNodes()
+	s.loadRemarkSettings()
+}
+
+// loadRemarkSettings populates the per-request remark formatting state so
+// every subscription format — raw, JSON, Clash — renders remarks the same
+// way. genRemark reads emailInRemark and the date formatter reads datepicker;
+// loading these only in getSubs left JSON/Clash with the zero values.
+func (s *SubService) loadRemarkSettings() {
+	var err error
+	s.datepicker, err = s.settingService.GetDatepicker()
+	if err != nil {
+		s.datepicker = "gregorian"
+	}
+	s.emailInRemark, err = s.settingService.GetSubEmailInRemark()
+	if err != nil {
+		s.emailInRemark = true
+	}
 }
 
 func (s *SubService) configuredPublicHost() string {
@@ -139,7 +163,10 @@ func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []mod
 
 // GetSubs retrieves subscription links for a given subscription ID and host.
 func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
-	s.PrepareForRequest(host)
+	return s.ForRequest(host).getSubs(subId)
+}
+
+func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.ClientTraffic, error) {
 	var result []string
 	var emails []string
 	var traffic xray.ClientTraffic
@@ -157,16 +184,6 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
 		return nil, nil, 0, traffic, nil
 	}
 
-	s.datepicker, err = s.settingService.GetDatepicker()
-	if err != nil {
-		s.datepicker = "gregorian"
-	}
-
-	s.emailInRemark, err = s.settingService.GetSubEmailInRemark()
-	if err != nil {
-		s.emailInRemark = true
-	}
-
 	seenEmails := make(map[string]struct{})
 	for _, inbound := range inbounds {
 		clients := s.matchingClients(inbound, subId)

+ 32 - 0
internal/sub/service_dedup_test.go

@@ -3,6 +3,7 @@ package sub
 import (
 	"fmt"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -62,4 +63,35 @@ func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) {
 	if len(emails) != 1 {
 		t.Fatalf("emails = %d, want 1, got %v", len(emails), emails)
 	}
+	// Identity, not just count: the single surviving link must be for this client.
+	if !strings.Contains(links[0], uuid) {
+		t.Fatalf("surviving link must carry the client uuid %q, got %q", uuid, links[0])
+	}
+}
+
+// TestMatchingClients_DedupsCaseInsensitiveEmail pins the dedup KEY, not just the count:
+// the two entries differ only by email case, so dropping strings.ToLower (or keying on
+// another field) yields two clients. The byte-identical dupes above can't catch that.
+func TestMatchingClients_DedupsCaseInsensitiveEmail(t *testing.T) {
+	const subId = "s1"
+	const uuid = "11111111-2222-4333-8444-555555555555"
+	ib := &model.Inbound{
+		Protocol: model.VLESS,
+		Settings: `{"clients":[
+			{"id":"` + uuid + `","email":"[email protected]","subId":"` + subId + `","enable":true},
+			{"id":"` + uuid + `","email":"[email protected]","subId":"` + subId + `","enable":true}
+		]}`,
+	}
+	s := &SubService{}
+	got := s.matchingClients(ib, subId)
+	if len(got) != 1 {
+		t.Fatalf("case-differing duplicate emails must dedup to 1 client, got %d", len(got))
+	}
+	if got[0].Email != "[email protected]" {
+		t.Fatalf("first occurrence must be kept, got %q", got[0].Email)
+	}
+	// A wrong subId must still be excluded (guards the subId filter at service.go:127).
+	if other := s.matchingClients(ib, "nope"); len(other) != 0 {
+		t.Fatalf("non-matching subId must yield 0 clients, got %d", len(other))
+	}
 }

+ 88 - 0
internal/sub/service_property_test.go

@@ -0,0 +1,88 @@
+package sub
+
+import (
+	"net"
+	"net/url"
+	"strconv"
+	"strings"
+	"testing"
+
+	"pgregory.net/rapid"
+)
+
+// TestProp_JoinHostPort_Bracketing asserts the RFC-3986 authority contract for any
+// host/port: SplitHostPort must recover the (un-bracketed) host and the exact port,
+// and an IPv6 literal is bracketed exactly once regardless of input brackets.
+func TestProp_JoinHostPort_Bracketing(t *testing.T) {
+	hosts := []string{
+		"1.2.3.4", "example.com", "sub.host.test",
+		"2001:db8::1", "[2001:db8::1]", "::1", "[::1]", "fe80::1%eth0",
+	}
+	rapid.Check(t, func(t *rapid.T) {
+		host := rapid.SampledFrom(hosts).Draw(t, "host")
+		port := rapid.IntRange(0, 65535).Draw(t, "port")
+
+		out := joinHostPort(host, port)
+
+		gotHost, gotPort, err := net.SplitHostPort(out)
+		if err != nil {
+			t.Fatalf("SplitHostPort(%q) failed: %v", out, err)
+		}
+		wantHost := strings.Trim(host, "[]")
+		if gotHost != wantHost {
+			t.Fatalf("host round-trip: joinHostPort(%q,%d)=%q -> host %q, want %q", host, port, out, gotHost, wantHost)
+		}
+		if gotPort != strconv.Itoa(port) {
+			t.Fatalf("port round-trip: got %q, want %d (out=%q)", gotPort, port, out)
+		}
+		// An IPv6 literal (contains a colon in the host) must be bracketed once.
+		if strings.Contains(wantHost, ":") {
+			if strings.Count(out, "[") != 1 || strings.Count(out, "]") != 1 {
+				t.Fatalf("IPv6 host not bracketed exactly once: %q", out)
+			}
+		}
+	})
+}
+
+// TestProp_EncodeUserinfo_RoundTrip asserts encodeUserinfo produces a userinfo token
+// that net/url parses back to the original password for ANY input — the contract that
+// trojan/ss links rely on. A field-mapping mutant that mangles escaping breaks this.
+func TestProp_EncodeUserinfo_RoundTrip(t *testing.T) {
+	rapid.Check(t, func(t *rapid.T) {
+		pw := rapid.String().Draw(t, "pw")
+		raw := "trojan://" + encodeUserinfo(pw) + "@example.com:443"
+		u, err := url.Parse(raw)
+		if err != nil {
+			t.Fatalf("url.Parse(%q) failed for pw=%q: %v", raw, pw, err)
+		}
+		if got := u.User.Username(); got != pw {
+			t.Fatalf("userinfo round-trip mismatch: pw=%q got=%q", pw, got)
+		}
+	})
+}
+
+// TestProp_SplitLinkLines_Invariants asserts splitLinkLines never emits empty or
+// untrimmed lines, and that re-splitting its own joined output is a fixed point.
+func TestProp_SplitLinkLines_Invariants(t *testing.T) {
+	rapid.Check(t, func(t *rapid.T) {
+		raw := rapid.String().Draw(t, "raw")
+		out := splitLinkLines(raw)
+		for i, line := range out {
+			if line == "" {
+				t.Fatalf("splitLinkLines emitted an empty line at %d for %q", i, raw)
+			}
+			if line != strings.TrimSpace(line) {
+				t.Fatalf("splitLinkLines emitted an untrimmed line %q", line)
+			}
+		}
+		rejoined := splitLinkLines(strings.Join(out, "\n"))
+		if len(rejoined) != len(out) {
+			t.Fatalf("not a fixed point: %d -> %d lines", len(out), len(rejoined))
+		}
+		for i := range out {
+			if rejoined[i] != out[i] {
+				t.Fatalf("fixed-point mismatch at %d: %q vs %q", i, out[i], rejoined[i])
+			}
+		}
+	})
+}

+ 89 - 0
internal/sub/service_sharelink_test.go

@@ -0,0 +1,89 @@
+package sub
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// shareLinkInbound builds a VLESS inbound with one client and the given stream
+// settings, mirroring flowTestInbound but without forcing a flow.
+func shareLinkInbound(streamSettings string) *model.Inbound {
+	return &model.Inbound{
+		Listen:         "203.0.113.1",
+		Port:           443,
+		Protocol:       model.VLESS,
+		Remark:         "sharelink",
+		Settings:       `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}],"decryption":"none","encryption":"none"}`,
+		StreamSettings: streamSettings,
+	}
+}
+
+// TestGenVlessLink_TLSParamsMapped locks every field that applyShareTLSParams
+// (service.go:1029) writes into a TLS share link. Without these assertions a mutant
+// that drops `sni`, swaps a key, or skips `pcs`/`alpn`/`fp` survives the whole suite —
+// the existing flow tests only check `flow=`.
+func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
+	stream := `{
+		"network":"tcp","security":"tls",
+		"tcpSettings":{"header":{"type":"none"}},
+		"tlsSettings":{
+			"serverName":"sni.example.com",
+			"alpn":["h2","http/1.1"],
+			"settings":{"fingerprint":"chrome","pinnedPeerCertSha256":["YWJj"]}
+		}
+	}`
+	s := &SubService{remarkModel: "-ieo"}
+	link := s.genVlessLink(shareLinkInbound(stream), "user")
+
+	// url.Values.Encode() percent-encodes values: "," -> %2C, "/" -> %2F.
+	wants := []string{
+		"security=tls",
+		"sni=sni.example.com",
+		"fp=chrome",
+		"alpn=h2%2Chttp%2F1.1",
+		"pcs=YWJj",
+	}
+	for _, w := range wants {
+		if !strings.Contains(link, w) {
+			t.Fatalf("TLS link missing %q\n got: %s", w, link)
+		}
+	}
+}
+
+// TestGenVlessLink_RealityParamsMapped locks the reality field mapping
+// (applyShareRealityParams, service.go:1147). serverNames/shortIds are single-element
+// so random.Num is deterministic (index 0); spx is random so it is asserted by prefix.
+// Distinct pbk/sid values catch a pbk<->sid swap mutant.
+func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
+	stream := `{
+		"network":"tcp","security":"reality",
+		"tcpSettings":{"header":{"type":"none"}},
+		"realitySettings":{
+			"serverNames":["reality.example.com"],
+			"shortIds":["ab12cd"],
+			"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
+		}
+	}`
+	s := &SubService{remarkModel: "-ieo"}
+	link := s.genVlessLink(shareLinkInbound(stream), "user")
+
+	wants := []string{
+		"security=reality",
+		"sni=reality.example.com",
+		"pbk=PBKvalue",
+		"sid=ab12cd",
+		"fp=firefox",
+		"spx=%2F", // "/" + random.Seq(15), percent-encoded leading slash
+	}
+	for _, w := range wants {
+		if !strings.Contains(link, w) {
+			t.Fatalf("reality link missing %q\n got: %s", w, link)
+		}
+	}
+	// A pbk<->sid swap must not silently pass: pbk must not carry the shortId.
+	if strings.Contains(link, "pbk=ab12cd") || strings.Contains(link, "sid=PBKvalue") {
+		t.Fatalf("reality pbk/sid mapping crossed: %s", link)
+	}
+}

+ 60 - 0
internal/util/common/format_mutation_test.go

@@ -0,0 +1,60 @@
+package common
+
+import "testing"
+
+// TestFormatTraffic_UnitBoundaries pins the exact switch point in the loop
+// condition `size >= 1024`: a unit must roll over at exactly 1024 (not 1023,
+// not 1025), and a value one byte short must stay in the lower unit. This kills
+// CONDITIONALS_BOUNDARY (>= -> >) and ARITHMETIC_BASE on the 1024 comparison.
+func TestFormatTraffic_UnitBoundaries(t *testing.T) {
+	cases := []struct {
+		name  string
+		bytes int64
+		want  string
+	}{
+		// Just below the first boundary: must NOT roll over to KB.
+		{"one_below_kb", 1023, "1023.00B"},
+		// Exactly at the boundary: must roll over to KB.
+		{"exactly_kb", 1024, "1.00KB"},
+		// Just above: stays in KB.
+		{"one_above_kb", 1025, "1.00KB"},
+		// Just below the MB boundary: stays in KB (proves division divisor 1024).
+		{"one_below_mb", 1024*1024 - 1, "1024.00KB"},
+		// Exactly at the MB boundary: rolls over to MB.
+		{"exactly_mb", 1024 * 1024, "1.00MB"},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			if got := FormatTraffic(c.bytes); got != c.want {
+				t.Fatalf("FormatTraffic(%d) = %q, want %q", c.bytes, got, c.want)
+			}
+		})
+	}
+}
+
+// TestFormatTraffic_ClampsAtPB pins the upper bound guard
+// `unitIndex < len(units)-1`: huge values must clamp at PB instead of indexing
+// past the units slice. A mutated bound (< -> <= via CONDITIONALS_BOUNDARY, or
+// len(units)-1 -> len(units)+1 via INVERT_NEGATIVES/ARITHMETIC_BASE) would run
+// one extra iteration and panic with index-out-of-range, so the assertion that
+// these return a normal "PB" string kills those mutants.
+func TestFormatTraffic_ClampsAtPB(t *testing.T) {
+	const pb = int64(1024 * 1024 * 1024 * 1024 * 1024)
+	cases := []struct {
+		name  string
+		bytes int64
+		want  string
+	}{
+		// Stays at PB even though size is still >= 1024 at the PB level.
+		{"1024_pb", 1024 * pb, "1024.00PB"},
+		// Max int64 must not overflow the units slice.
+		{"max_int64", 9223372036854775807, "8192.00PB"},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			if got := FormatTraffic(c.bytes); got != c.want {
+				t.Fatalf("FormatTraffic(%d) = %q, want %q", c.bytes, got, c.want)
+			}
+		})
+	}
+}

+ 4 - 2
internal/util/link/outbound.go

@@ -781,8 +781,10 @@ func base64DecodeFlexible(s string) (string, error) {
 	return "", fmt.Errorf("base64 decode failed")
 }
 
-// SlugRemark turns a free-form remark into a conservative DNS-ish tag segment.
-var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
+// SlugRemark turns a free-form remark into a tag segment, keeping Unicode
+// letters and digits (so non-ASCII remarks like Cyrillic stay readable) and
+// replacing every other run of characters with a single dash.
+var slugRe = regexp.MustCompile(`[^\p{L}\p{N}]+`)
 
 func SlugRemark(remark string) string {
 	s := strings.ToLower(strings.TrimSpace(remark))

+ 28 - 0
internal/util/link/outbound_fuzz_test.go

@@ -0,0 +1,28 @@
+package link
+
+import "testing"
+
+// FuzzParseLink asserts the parser never panics and upholds its (result, error) contract
+// — exactly one non-nil. It base64-decodes and type-asserts attacker-controllable JSON,
+// the classic panic source.
+func FuzzParseLink(f *testing.F) {
+	seeds := []string{
+		"",
+		"not-a-link",
+		"vmess://eyJ2IjoiMiIsInBzIjoidCIsImFkZCI6ImEuY29tIiwicG9ydCI6IjQ0MyIsImlkIjoiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwibmV0IjoidGNwIn0=",
+		"vless://[email protected]:443?type=tcp&security=none#x",
+		"trojan://[email protected]:443?security=tls#x",
+		"ss://[email protected]:8388#x",
+		"hysteria2://[email protected]:443?sni=a.com#x",
+		"wireguard://[email protected]:51820?publickey=pub#x",
+	}
+	for _, s := range seeds {
+		f.Add(s)
+	}
+	f.Fuzz(func(t *testing.T, s string) {
+		res, err := ParseLink(s)
+		if (res == nil) == (err == nil) {
+			t.Fatalf("ParseLink(%q): exactly one of (result, error) must be non-nil; got res=%v err=%v", s, res, err)
+		}
+	})
+}

+ 201 - 0
internal/util/link/outbound_helpers_test.go

@@ -0,0 +1,201 @@
+package link
+
+import (
+	"encoding/base64"
+	"net/url"
+	"reflect"
+	"testing"
+)
+
+func TestDefaultPort(t *testing.T) {
+	cases := []struct {
+		in   string
+		def  int
+		want int
+	}{
+		{"", 443, 443},
+		{"8080", 443, 8080},
+		{"0", 443, 443},   // non-positive falls back
+		{"-1", 443, 443},  // negative falls back
+		{"abc", 443, 443}, // unparseable falls back
+		{"65535", 443, 65535},
+	}
+	for _, c := range cases {
+		if got := defaultPort(c.in, c.def); got != c.want {
+			t.Errorf("defaultPort(%q,%d) = %d, want %d", c.in, c.def, got, c.want)
+		}
+	}
+}
+
+func TestFirstNonEmptyAndParam(t *testing.T) {
+	if got := firstNonEmpty("a", "b"); got != "a" {
+		t.Errorf("firstNonEmpty(a,b) = %q, want a", got)
+	}
+	if got := firstNonEmpty("", "b"); got != "b" {
+		t.Errorf("firstNonEmpty(,b) = %q, want b", got)
+	}
+	p := url.Values{"x": {""}, "y": {"hit"}, "z": {"z"}}
+	if got := firstParam(p, "x", "y", "z"); got != "hit" {
+		t.Errorf("firstParam = %q, want hit (first non-empty)", got)
+	}
+	if got := firstParam(p, "x"); got != "" {
+		t.Errorf("firstParam(only empty) = %q, want empty", got)
+	}
+}
+
+func TestSplitComma(t *testing.T) {
+	if got := splitComma(""); got != nil {
+		t.Errorf("splitComma(empty) = %v, want nil", got)
+	}
+	if got := splitComma("a, ,b ,, c"); !reflect.DeepEqual(got, []string{"a", "b", "c"}) {
+		t.Errorf("splitComma trim/skip = %v, want [a b c]", got)
+	}
+	if got := splitCommaOrDefault("", []string{"d"}); !reflect.DeepEqual(got, []string{"d"}) {
+		t.Errorf("splitCommaOrDefault(empty) = %v, want [d]", got)
+	}
+	if got := splitCommaOrDefault("x,y", []string{"d"}); !reflect.DeepEqual(got, []string{"x", "y"}) {
+		t.Errorf("splitCommaOrDefault(x,y) = %v, want [x y]", got)
+	}
+}
+
+func TestPadAndBase64DecodeFlexible(t *testing.T) {
+	if got := padBase64("abc"); got != "abc=" {
+		t.Errorf("padBase64(abc) = %q, want abc=", got)
+	}
+	if got := padBase64("abcd"); got != "abcd" {
+		t.Errorf("padBase64(abcd) = %q, want unchanged", got)
+	}
+	std := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secret"))
+	if got, err := base64DecodeFlexible(std); err != nil || got != "aes-256-gcm:secret" {
+		t.Errorf("base64DecodeFlexible(std) = (%q,%v), want (aes-256-gcm:secret,nil)", got, err)
+	}
+	rawURL := base64.RawURLEncoding.EncodeToString([]byte("m:p"))
+	if got, err := base64DecodeFlexible(rawURL); err != nil || got != "m:p" {
+		t.Errorf("base64DecodeFlexible(rawurl) = (%q,%v), want (m:p,nil)", got, err)
+	}
+	if _, err := base64DecodeFlexible("!!!not!!!"); err == nil {
+		t.Error("base64DecodeFlexible(garbage) should error")
+	}
+}
+
+func TestDecodeHash(t *testing.T) {
+	if got := decodeHash(""); got != "" {
+		t.Errorf("decodeHash(empty) = %q, want empty", got)
+	}
+	if got := decodeHash("a%20b"); got != "a b" {
+		t.Errorf("decodeHash(a%%20b) = %q, want 'a b'", got)
+	}
+	if got := decodeHash("plain"); got != "plain" {
+		t.Errorf("decodeHash(plain) = %q, want plain", got)
+	}
+}
+
+func TestCanonicalQuery_SortsKeys(t *testing.T) {
+	// unsorted input must come out key-sorted for a stable identity
+	got := canonicalQuery(url.Values{"c": {"3"}, "a": {"1"}, "b": {"2"}})
+	if got != "a=1&b=2&c=3" {
+		t.Fatalf("canonicalQuery = %q, want a=1&b=2&c=3", got)
+	}
+}
+
+// stream navigates res.Outbound["streamSettings"][key] as a map.
+func streamSub(t *testing.T, res *ParseResult, key string) map[string]any {
+	t.Helper()
+	ss, _ := res.Outbound["streamSettings"].(map[string]any)
+	m, ok := ss[key].(map[string]any)
+	if !ok {
+		t.Fatalf("streamSettings.%s missing/not a map: %#v", key, ss)
+	}
+	return m
+}
+
+func TestParse_RealitySecurityMapped(t *testing.T) {
+	res, err := ParseLink("vless://[email protected]:443?type=tcp&security=reality&pbk=PBK&sid=SID&sni=SNI&fp=firefox&spx=%2Fspx&pqv=PQV")
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	re := streamSub(t, res, "realitySettings")
+	for k, want := range map[string]string{"publicKey": "PBK", "shortId": "SID", "serverName": "SNI", "fingerprint": "firefox", "spiderX": "/spx", "mldsa65Verify": "PQV"} {
+		if re[k] != want {
+			t.Errorf("realitySettings[%q] = %v, want %q", k, re[k], want)
+		}
+	}
+}
+
+func TestParse_TLSSecurityMapped(t *testing.T) {
+	res, err := ParseLink("trojan://[email protected]:443?type=tcp&security=tls&sni=SNI&fp=chrome&alpn=h2,http/1.1&ech=ECH&pcs=PCS")
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	tls := streamSub(t, res, "tlsSettings")
+	if tls["serverName"] != "SNI" || tls["fingerprint"] != "chrome" || tls["echConfigList"] != "ECH" || tls["pinnedPeerCertSha256"] != "PCS" {
+		t.Errorf("tlsSettings fields = %#v", tls)
+	}
+	if alpn, _ := tls["alpn"].([]string); !reflect.DeepEqual(alpn, []string{"h2", "http/1.1"}) {
+		t.Errorf("alpn = %#v, want [h2 http/1.1]", tls["alpn"])
+	}
+}
+
+func TestParse_WSAndGRPCTransport(t *testing.T) {
+	ws, err := ParseLink("vless://[email protected]:443?type=ws&host=H&path=%2Fwspath")
+	if err != nil {
+		t.Fatalf("parse ws: %v", err)
+	}
+	wss := streamSub(t, ws, "wsSettings")
+	if wss["host"] != "H" || wss["path"] != "/wspath" {
+		t.Errorf("wsSettings = %#v, want host=H path=/wspath", wss)
+	}
+
+	grpc, err := ParseLink("vless://[email protected]:443?type=grpc&serviceName=svc&authority=auth&mode=multi")
+	if err != nil {
+		t.Fatalf("parse grpc: %v", err)
+	}
+	gs := streamSub(t, grpc, "grpcSettings")
+	if gs["serviceName"] != "svc" || gs["authority"] != "auth" || gs["multiMode"] != true {
+		t.Errorf("grpcSettings = %#v, want serviceName=svc authority=auth multiMode=true", gs)
+	}
+}
+
+func TestParse_TCPHTTPHeader(t *testing.T) {
+	res, err := ParseLink("vless://[email protected]:443?type=tcp&headerType=http&host=ex.com&path=%2F")
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	tcp := streamSub(t, res, "tcpSettings")
+	header, _ := tcp["header"].(map[string]any)
+	if header["type"] != "http" {
+		t.Errorf("tcp header type = %v, want http", header["type"])
+	}
+}
+
+func TestParseVless_CoreFields(t *testing.T) {
+	res, err := ParseLink("vless://[email protected]:8443?type=tcp&security=none&flow=xtls-rprx-vision#tag1")
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	st, _ := res.Outbound["settings"].(map[string]any)
+	if st["address"] != "9.9.9.9" || st["port"] != 8443 || st["id"] != "the-uuid" || st["flow"] != "xtls-rprx-vision" {
+		t.Errorf("vless settings = %#v", st)
+	}
+}
+
+func TestParseTrojanAndSS_CoreFields(t *testing.T) {
+	tr, err := ParseLink("trojan://[email protected]:443?type=tcp&security=tls#tj")
+	if err != nil {
+		t.Fatalf("parse trojan: %v", err)
+	}
+	srv := tr.Outbound["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
+	if srv["address"] != "t.com" || srv["port"] != 443 || srv["password"] != "secret" {
+		t.Errorf("trojan server = %#v", srv)
+	}
+
+	ssLink := "ss://" + base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:sspass")) + "@s.com:8388#ss1"
+	ss, err := ParseLink(ssLink)
+	if err != nil {
+		t.Fatalf("parse ss: %v", err)
+	}
+	ssrv := ss.Outbound["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
+	if ssrv["address"] != "s.com" || ssrv["port"] != 8388 || ssrv["password"] != "sspass" || ssrv["method"] != "aes-256-gcm" {
+		t.Errorf("ss server = %#v", ssrv)
+	}
+}

+ 7 - 0
internal/util/link/outbound_test.go

@@ -59,4 +59,11 @@ func TestSlugAndSuggest(t *testing.T) {
 	if tag != "hk-sg-01" {
 		t.Errorf("suggest tag got %q", tag)
 	}
+	// Non-ASCII letters/digits are preserved rather than stripped.
+	if got := SlugRemark("Москва 🇷🇺 01"); got != "москва-01" {
+		t.Errorf("unicode slug got %q", got)
+	}
+	if got := SuggestTag("ru-", "Сервер 2", 0); got != "ru-сервер-2" {
+		t.Errorf("unicode suggest tag got %q", got)
+	}
 }

+ 32 - 6
internal/util/netproxy/netproxy_test.go

@@ -2,6 +2,8 @@ package netproxy
 
 import (
 	"net/http"
+	"net/http/httptest"
+	"reflect"
 	"testing"
 	"time"
 )
@@ -22,6 +24,10 @@ func TestNewHTTPClient(t *testing.T) {
 		{name: "unsupported scheme errors", proxyURL: "ftp://127.0.0.1:21", wantErr: true},
 	}
 
+	// baseTransport clones http.DefaultTransport, whose Proxy and DialContext are already
+	// non-nil — so "!= nil" can't prove our proxy/dialer was applied. Check the real values.
+	defaultDialPtr := reflect.ValueOf(http.DefaultTransport.(*http.Transport).DialContext).Pointer()
+
 	for _, tc := range tests {
 		t.Run(tc.name, func(t *testing.T) {
 			client, err := NewHTTPClient(tc.proxyURL, 5*time.Second)
@@ -37,16 +43,36 @@ func TestNewHTTPClient(t *testing.T) {
 			if client.Timeout != 5*time.Second {
 				t.Errorf("timeout = %v, want 5s", client.Timeout)
 			}
+			// Empty proxyURL → a plain direct client with no custom transport.
+			if tc.proxyURL == "" {
+				if client.Transport != nil {
+					t.Errorf("empty proxy must yield a direct client (nil Transport), got %T", client.Transport)
+				}
+				return
+			}
+			transport, ok := client.Transport.(*http.Transport)
+			if !ok {
+				t.Fatalf("transport is %T, want *http.Transport", client.Transport)
+			}
 			if tc.wantProxy {
-				transport, ok := client.Transport.(*http.Transport)
-				if !ok || transport.Proxy == nil {
-					t.Errorf("expected transport with Proxy set for %q", tc.proxyURL)
+				// Prove the CONFIGURED proxy is applied: transport.Proxy(req) must
+				// return our URL, not the cloned default's ProxyFromEnvironment.
+				req := httptest.NewRequest(http.MethodGet, "https://example.com", nil)
+				u, perr := transport.Proxy(req)
+				if perr != nil {
+					t.Fatalf("transport.Proxy returned error: %v", perr)
+				}
+				if u == nil || u.String() != tc.proxyURL {
+					t.Errorf("transport.Proxy(req) = %v, want %q (configured proxy not applied)", u, tc.proxyURL)
 				}
 			}
 			if tc.wantDial {
-				transport, ok := client.Transport.(*http.Transport)
-				if !ok || transport.DialContext == nil {
-					t.Errorf("expected transport with DialContext set for %q", tc.proxyURL)
+				if transport.DialContext == nil {
+					t.Fatal("DialContext is nil")
+				}
+				// Must be the socks5 dialer, not the cloned default DialContext.
+				if reflect.ValueOf(transport.DialContext).Pointer() == defaultDialPtr {
+					t.Error("DialContext is still the default; socks5 dialer was not applied")
 				}
 			}
 		})

+ 102 - 0
internal/util/netsafe/netsafe_mutation_test.go

@@ -0,0 +1,102 @@
+package netsafe
+
+import (
+	"context"
+	"strings"
+	"testing"
+)
+
+// TestSSRFGuardedDialContext_LiteralIPSkipsResolver pins the netsafe.go:37
+// decision (`if ip := net.ParseIP(host); ip != nil`). The string "fe80::1%eth0"
+// is rejected by net.ParseIP (returns nil) but accepted by the resolver, which
+// yields the link-local address fe80::1. With the branch intact, ParseIP returns
+// nil so the host falls through to LookupIPAddr, resolves to fe80::1, and is
+// blocked by IsBlockedIP -> the error mentions the resolved blocked address.
+// If the condition is flipped to `ip == nil`, the nil-IP literal path is taken
+// instead: ips = [{IP: nil}], IsBlockedIP(nil) is false, the guard never fires
+// and the error would never say "blocked private/internal address fe80::1".
+func TestSSRFGuardedDialContext_LiteralIPSkipsResolver(t *testing.T) {
+	_, err := SSRFGuardedDialContext(context.Background(), "tcp", "[fe80::1%eth0]:80")
+	if err == nil {
+		t.Fatal("expected error for link-local host with zone suffix")
+	}
+	if !strings.Contains(err.Error(), "blocked private/internal address fe80::1") {
+		t.Fatalf("expected guard to block resolved link-local fe80::1, got: %v", err)
+	}
+}
+
+// TestSSRFGuardedDialContext_LiteralPrivateIPv6Blocked complements the above by
+// confirming that a valid IP literal (parsed by the line 37 branch) is still run
+// through IsBlockedIP and rejected with the literal in the message.
+func TestSSRFGuardedDialContext_LiteralPrivateIPv6Blocked(t *testing.T) {
+	_, err := SSRFGuardedDialContext(context.Background(), "tcp", "[::1]:80")
+	if err == nil {
+		t.Fatal("expected dial to ::1 to be blocked")
+	}
+	if !strings.Contains(err.Error(), "blocked private/internal address ::1") {
+		t.Fatalf("expected '::1' literal in blocked error, got: %v", err)
+	}
+}
+
+// TestNormalizeHost_LengthBoundary pins the netsafe.go:76 length check
+// (`len(addr) > 253`). A valid-pattern hostname of exactly 253 chars must be
+// accepted (kills `>` -> `>=` / off-by-one mutations of the bound), while the
+// same hostname at 254 chars must be rejected.
+func TestNormalizeHost_LengthBoundary(t *testing.T) {
+	label := strings.Repeat("a", 61)
+	base := label + "." + label + "." + label + "." // 186 chars, valid pattern
+	h253 := base + strings.Repeat("a", 253-len(base))
+	if len(h253) != 253 {
+		t.Fatalf("test setup: expected 253-char host, got %d", len(h253))
+	}
+	h254 := h253 + "a"
+
+	got, err := NormalizeHost(h253)
+	if err != nil {
+		t.Fatalf("NormalizeHost(253-char valid host) returned error: %v", err)
+	}
+	if got != h253 {
+		t.Fatalf("NormalizeHost(253-char host) = %q, want unchanged input", got)
+	}
+
+	if _, err := NormalizeHost(h254); err == nil {
+		t.Fatal("NormalizeHost(254-char host) expected error, got nil")
+	}
+}
+
+// TestNormalizeHost_PatternClauseIndependentOfLength pins the OR in line 76:
+// a short hostname (well under the 253 limit) that violates the pattern must
+// still be rejected. If `||` were mutated to `&&`, this short-but-invalid host
+// would slip through because the length clause is false.
+func TestNormalizeHost_PatternClauseIndependentOfLength(t *testing.T) {
+	cases := []string{
+		"under_score.example.com",
+		"bad host",
+		"exa$mple.com",
+		"-leadingdash.com",
+	}
+	for _, in := range cases {
+		t.Run(in, func(t *testing.T) {
+			if len(in) > 253 {
+				t.Fatalf("test setup: %q should be short to isolate the pattern clause", in)
+			}
+			if _, err := NormalizeHost(in); err == nil {
+				t.Fatalf("NormalizeHost(%q) expected error for invalid pattern, got nil", in)
+			}
+		})
+	}
+}
+
+// TestNormalizeHost_ValidShortHostAccepted ensures a short valid-pattern host is
+// accepted, so a mutation dropping the `!` on the pattern match (rejecting valid
+// hosts) is caught alongside the rejection cases above.
+func TestNormalizeHost_ValidShortHostAccepted(t *testing.T) {
+	const in = "node-1.example.com"
+	got, err := NormalizeHost(in)
+	if err != nil {
+		t.Fatalf("NormalizeHost(%q) returned error: %v", in, err)
+	}
+	if got != in {
+		t.Fatalf("NormalizeHost(%q) = %q, want %q", in, got, in)
+	}
+}

+ 61 - 2
internal/web/controller/setting.go

@@ -10,6 +10,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service/email"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
 
@@ -54,11 +55,14 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
 	g.POST("/apiTokens/create", a.createApiToken)
 	g.POST("/apiTokens/delete/:id", a.deleteApiToken)
 	g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
+	g.POST("/testSmtp", a.testSmtp)
+	g.POST("/testTgBot", a.testTgBot)
 }
 
-// getAllSetting retrieves all current settings.
+// getAllSetting retrieves all current settings as the browser-safe view:
+// secret values are redacted and surfaced as has* presence flags instead.
 func (a *SettingController) getAllSetting(c *gin.Context) {
-	allSetting, err := a.settingService.GetAllSetting()
+	allSetting, err := a.settingService.GetAllSettingView()
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
 		return
@@ -198,3 +202,58 @@ func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
 	}
 	jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabled(id, form.Enabled))
 }
+
+func (a *SettingController) testSmtp(c *gin.Context) {
+	if emailService == nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.smtpNotInitialized"), errors.New("email service not available"))
+		return
+	}
+	logger.Info("SMTP test: starting...")
+	result := emailService.TestConnection()
+	if !result.Success {
+		logger.Warning("SMTP test failed at", result.Stage+":", result.Message)
+		c.JSON(200, gin.H{
+			"success": false,
+			"stage":   result.Stage,
+			"msg":     result.Message,
+		})
+		return
+	}
+	logger.Info("SMTP test: success")
+	c.JSON(200, gin.H{
+		"success": true,
+		"stage":   result.Stage,
+		"msg":     result.Message,
+	})
+}
+
+func (a *SettingController) testTgBot(c *gin.Context) {
+	enabled, err := a.settingService.GetTgbotEnabled()
+	if err != nil || !enabled {
+		jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotEnabled"), errors.New("telegram bot disabled"))
+		return
+	}
+	// Import tgbot package would create a circular dependency, so we call
+	// the test through the global function registered at startup.
+	if testTgFunc != nil {
+		if err := testTgFunc(); err != nil {
+			jsonMsg(c, I18nWeb(c, "pages.settings.tgTestFailed")+": "+err.Error(), err)
+			return
+		}
+		jsonMsg(c, I18nWeb(c, "pages.settings.tgTestSuccess"), nil)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotRunning"), errors.New("bot not started"))
+}
+
+// testTgFunc is set from web layer to test Telegram sending without circular imports.
+var testTgFunc func() error
+
+// SetTestTgFunc registers the function used to test Telegram sending.
+func SetTestTgFunc(fn func() error) { testTgFunc = fn }
+
+// emailService is set from web layer.
+var emailService *email.EmailService
+
+// SetEmailService registers the email service for test endpoints.
+func SetEmailService(s *email.EmailService) { emailService = s }

+ 22 - 10
internal/web/entity/entity.go

@@ -39,16 +39,27 @@ type AllSetting struct {
 	Datepicker  string `json:"datepicker" form:"datepicker"`                            // Date picker format
 
 	// Telegram bot settings
-	TgBotEnable      bool   `json:"tgBotEnable" form:"tgBotEnable"`              // Enable Telegram bot notifications
-	TgBotToken       string `json:"tgBotToken" form:"tgBotToken"`                // Telegram bot token
-	TgBotProxy       string `json:"tgBotProxy" form:"tgBotProxy"`                // Proxy URL for Telegram bot
-	TgBotAPIServer   string `json:"tgBotAPIServer" form:"tgBotAPIServer"`        // Custom API server for Telegram bot
-	TgBotChatId      string `json:"tgBotChatId" form:"tgBotChatId"`              // Telegram chat ID for notifications
-	TgRunTime        string `json:"tgRunTime" form:"tgRunTime"`                  // Cron schedule for Telegram notifications
-	TgBotBackup      bool   `json:"tgBotBackup" form:"tgBotBackup"`              // Enable database backup via Telegram
-	TgBotLoginNotify bool   `json:"tgBotLoginNotify" form:"tgBotLoginNotify"`    // Send login notifications
-	TgCpu            int    `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` // CPU usage threshold for alerts (percent)
-	TgLang           string `json:"tgLang" form:"tgLang"`                        // Telegram bot language
+	TgBotEnable     bool   `json:"tgBotEnable" form:"tgBotEnable"`              // Enable Telegram bot notifications
+	TgBotToken      string `json:"tgBotToken" form:"tgBotToken"`                // Telegram bot token
+	TgBotProxy      string `json:"tgBotProxy" form:"tgBotProxy"`                // Proxy URL for Telegram bot
+	TgBotAPIServer  string `json:"tgBotAPIServer" form:"tgBotAPIServer"`        // Custom API server for Telegram bot
+	TgBotChatId     string `json:"tgBotChatId" form:"tgBotChatId"`              // Telegram chat ID for notifications
+	TgRunTime       string `json:"tgRunTime" form:"tgRunTime"`                  // Cron schedule for Telegram notifications
+	TgBotBackup     bool   `json:"tgBotBackup" form:"tgBotBackup"`              // Enable database backup via Telegram
+	TgCpu           int    `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` // CPU usage threshold for alerts (percent)
+	TgLang          string `json:"tgLang" form:"tgLang"`                        // Telegram bot language
+	TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"`      // Comma-separated event types to send via Telegram
+
+	// Email (SMTP) notification settings
+	SmtpEnable         bool   `json:"smtpEnable" form:"smtpEnable"`                        // Enable email notifications
+	SmtpHost           string `json:"smtpHost" form:"smtpHost"`                            // SMTP server host
+	SmtpPort           int    `json:"smtpPort" form:"smtpPort" validate:"gte=1,lte=65535"` // SMTP server port
+	SmtpUsername       string `json:"smtpUsername" form:"smtpUsername"`                    // SMTP username
+	SmtpPassword       string `json:"smtpPassword" form:"smtpPassword"`                    // SMTP password
+	SmtpTo             string `json:"smtpTo" form:"smtpTo"`                                // Comma-separated recipient emails
+	SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"`        // SMTP encryption: none, starttls, tls
+	SmtpEnabledEvents  string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"`          // Comma-separated event types to send via email
+	SmtpCpu            int    `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"`     // CPU threshold for email notifications
 
 	// Security settings
 	TimeLocation    string `json:"timeLocation" form:"timeLocation"`       // Time zone location
@@ -130,6 +141,7 @@ type AllSettingView struct {
 	HasApiToken       bool `json:"hasApiToken"`
 	HasWarpSecret     bool `json:"hasWarpSecret"`
 	HasNordSecret     bool `json:"hasNordSecret"`
+	HasSmtpPassword   bool `json:"hasSmtpPassword"`
 }
 
 // CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.

+ 12 - 16
internal/web/job/check_cpu_usage.go

@@ -1,18 +1,16 @@
 package job
 
 import (
-	"strconv"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
-	"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
 
 	"github.com/shirou/gopsutil/v4/cpu"
 )
 
-// CheckCpuJob monitors CPU usage and sends Telegram notifications when usage exceeds the configured threshold.
+// CheckCpuJob monitors CPU usage and publishes events when threshold is exceeded.
 type CheckCpuJob struct {
-	tgbotService   tgbot.Tgbot
 	settingService service.SettingService
 }
 
@@ -21,21 +19,19 @@ func NewCheckCpuJob() *CheckCpuJob {
 	return new(CheckCpuJob)
 }
 
-// Run checks CPU usage over the last minute and sends a Telegram alert if it exceeds the threshold.
+// Run checks CPU usage and publishes a cpu.high event with raw metric data.
 func (j *CheckCpuJob) Run() {
-	threshold, err := j.settingService.GetTgCpu()
-	if err != nil || threshold <= 0 {
-		// If threshold cannot be retrieved or is not set, skip sending notifications
+	percent, err := cpu.Percent(1*time.Minute, false)
+	if err != nil || len(percent) == 0 {
 		return
 	}
 
-	// get latest status of server
-	percent, err := cpu.Percent(1*time.Minute, false)
-	if err == nil && percent[0] > float64(threshold) {
-		msg := j.tgbotService.I18nBot("tgbot.messages.cpuThreshold",
-			"Percent=="+strconv.FormatFloat(percent[0], 'f', 2, 64),
-			"Threshold=="+strconv.Itoa(threshold))
-
-		j.tgbotService.SendMsgToTgbotAdmins(msg)
+	if EventBus != nil {
+		EventBus.Publish(eventbus.Event{
+			Type: eventbus.EventCPUHigh,
+			Data: &eventbus.SystemMetricData{
+				Percent: percent[0],
+			},
+		})
 	}
 }

+ 4 - 0
internal/web/job/check_xray_running_job.go

@@ -3,10 +3,14 @@
 package job
 
 import (
+	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
+// EventBus is set from web layer to publish events.
+var EventBus *eventbus.Bus
+
 // CheckXrayRunningJob monitors Xray process health and restarts it if it crashes.
 type CheckXrayRunningJob struct {
 	xrayService service.XrayService

+ 38 - 0
internal/web/job/node_heartbeat_job.go

@@ -2,10 +2,12 @@ package job
 
 import (
 	"context"
+	"strconv"
 	"sync"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
@@ -70,6 +72,7 @@ func (j *NodeHeartbeatJob) Run() {
 func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
 	ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
 	defer cancel()
+	prevStatus := n.Status
 	patch, err := j.nodeService.Probe(ctx, n)
 	if err != nil {
 		patch.Status = "offline"
@@ -79,6 +82,7 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
 	if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
 		logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
 	}
+	publishNodeTransition(n, prevStatus, patch)
 	// Learn the nodes this node manages so the panel can surface them as
 	// transitive sub-nodes (#4983). Fresh context — the probe budget above may
 	// be spent. Drop them when the node is unreachable.
@@ -90,3 +94,37 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
 		j.nodeService.ClearDescendants(n.Id)
 	}
 }
+
+// publishNodeTransition emits node.down / node.up only on a genuine state change.
+// An "unknown"/empty previous status (fresh start) is treated as not-online, so a
+// node coming up for the first time fires node.up but never a spurious node.down.
+func publishNodeTransition(n *model.Node, prevStatus string, patch service.HeartbeatPatch) {
+	if EventBus == nil {
+		return
+	}
+	var eventType eventbus.EventType
+	switch {
+	case prevStatus == "online" && patch.Status == "offline":
+		eventType = eventbus.EventNodeDown
+	case prevStatus != "online" && patch.Status == "online":
+		eventType = eventbus.EventNodeUp
+	default:
+		return
+	}
+	source := n.Name
+	if source == "" {
+		source = "node-" + strconv.Itoa(n.Id)
+	}
+	EventBus.Publish(eventbus.Event{
+		Type:   eventType,
+		Source: source,
+		Data: &eventbus.NodeHealthData{
+			NodeId:    n.Id,
+			LatencyMs: patch.LatencyMs,
+			CpuPct:    patch.CpuPct,
+			MemPct:    patch.MemPct,
+			XrayState: patch.XrayState,
+			XrayError: patch.XrayError,
+		},
+	})
+}

+ 43 - 0
internal/web/middleware/bodylimit.go

@@ -0,0 +1,43 @@
+package middleware
+
+import (
+	"net/http"
+	"strings"
+
+	"github.com/gin-gonic/gin"
+)
+
+// MaxBodyBytes caps the request body size for state-changing requests. It wraps
+// the body in an http.MaxBytesReader so that any handler reading it (gin's
+// ShouldBind, manual io.ReadAll, etc.) receives an error once the limit is
+// exceeded, which the existing bind-failure path reports as a 400 rather than
+// allocating an unbounded buffer or starting a long DB transaction.
+//
+// Methods without a body (GET/HEAD/OPTIONS/TRACE) and a non-positive limit are
+// passed through untouched. Paths ending in one of skipSuffixes are also passed
+// through uncapped — these are routes that legitimately accept a large upload
+// (e.g. database restore, which streams a multi-MiB SQLite file).
+func MaxBodyBytes(limit int64, skipSuffixes ...string) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		if limit > 0 {
+			switch c.Request.Method {
+			case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
+			default:
+				if c.Request.Body != nil && !hasSuffix(c.Request.URL.Path, skipSuffixes) {
+					c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
+				}
+			}
+		}
+		c.Next()
+	}
+}
+
+// hasSuffix reports whether path ends in any of the given suffixes.
+func hasSuffix(path string, suffixes []string) bool {
+	for _, s := range suffixes {
+		if strings.HasSuffix(path, s) {
+			return true
+		}
+	}
+	return false
+}

+ 80 - 0
internal/web/middleware/bodylimit_test.go

@@ -0,0 +1,80 @@
+package middleware
+
+import (
+	"bytes"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+)
+
+func TestMaxBodyBytes(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	const limit = 16
+
+	r := gin.New()
+	r.Use(MaxBodyBytes(limit))
+	r.POST("/x", func(c *gin.Context) {
+		if _, err := io.ReadAll(c.Request.Body); err != nil {
+			c.String(http.StatusRequestEntityTooLarge, "too big")
+			return
+		}
+		c.String(http.StatusOK, "ok")
+	})
+	r.GET("/x", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
+
+	// Body within the limit is read normally.
+	w := httptest.NewRecorder()
+	r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/x", strings.NewReader("0123456789")))
+	if w.Code != http.StatusOK {
+		t.Errorf("under-limit POST: got %d, want 200", w.Code)
+	}
+
+	// Body over the limit makes the handler's read fail (no unbounded buffer).
+	w = httptest.NewRecorder()
+	r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/x", bytes.NewReader(make([]byte, limit*4))))
+	if w.Code == http.StatusOK {
+		t.Errorf("over-limit POST should not succeed, got 200")
+	}
+
+	// Bodyless methods pass through untouched.
+	w = httptest.NewRecorder()
+	r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
+	if w.Code != http.StatusOK {
+		t.Errorf("GET should pass through, got %d", w.Code)
+	}
+}
+
+func TestMaxBodyBytesSkipSuffix(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	const limit = 16
+
+	r := gin.New()
+	r.Use(MaxBodyBytes(limit, "/server/importDB"))
+	read := func(c *gin.Context) {
+		if _, err := io.ReadAll(c.Request.Body); err != nil {
+			c.String(http.StatusRequestEntityTooLarge, "too big")
+			return
+		}
+		c.String(http.StatusOK, "ok")
+	}
+	r.POST("/server/importDB", read)
+	r.POST("/x", read)
+
+	// Exempt route reads an over-limit body without error.
+	w := httptest.NewRecorder()
+	r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/server/importDB", bytes.NewReader(make([]byte, limit*4))))
+	if w.Code != http.StatusOK {
+		t.Errorf("exempt route should pass through over-limit body, got %d", w.Code)
+	}
+
+	// Non-exempt route is still capped.
+	w = httptest.NewRecorder()
+	r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/x", bytes.NewReader(make([]byte, limit*4))))
+	if w.Code == http.StatusOK {
+		t.Errorf("non-exempt over-limit POST should not succeed, got 200")
+	}
+}

+ 30 - 0
internal/web/middleware/security_test.go

@@ -3,6 +3,7 @@ package middleware
 import (
 	"net/http"
 	"net/http/httptest"
+	"strings"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
@@ -80,7 +81,9 @@ func TestSecurityHeadersMiddleware(t *testing.T) {
 	gin.SetMode(gin.TestMode)
 	router := gin.New()
 	router.Use(SecurityHeadersMiddleware(true))
+	var capturedNonce string
 	router.GET("/", func(c *gin.Context) {
+		capturedNonce = c.GetString("csp_nonce")
 		c.String(http.StatusOK, "ok")
 	})
 
@@ -101,6 +104,33 @@ func TestSecurityHeadersMiddleware(t *testing.T) {
 	if got := headers.Get("Strict-Transport-Security"); got == "" {
 		t.Fatal("Strict-Transport-Security should be set for direct HTTPS")
 	}
+
+	// CSP is the highest-value header here: assert it stays nonce-bound with its hardening
+	// directives, so weakening it (unsafe-inline, dropped frame-ancestors, broken nonce) fails.
+	csp := headers.Get("Content-Security-Policy")
+	if csp == "" {
+		t.Fatal("Content-Security-Policy header must be set")
+	}
+	if capturedNonce == "" {
+		t.Fatal("csp_nonce context value must be set (the injected inline script reads it)")
+	}
+	if want := "script-src 'self' 'nonce-" + capturedNonce + "'"; !strings.Contains(csp, want) {
+		t.Fatalf("CSP script-src must be bound to the per-request nonce %q; got %q", want, csp)
+	}
+	for _, directive := range []string{"object-src 'none'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'"} {
+		if !strings.Contains(csp, directive) {
+			t.Errorf("CSP missing hardening directive %q; got %q", directive, csp)
+		}
+	}
+	// script-src must NOT allow 'unsafe-inline' (it would defeat the nonce). Check the
+	// script-src directive in isolation, since style-src legitimately uses unsafe-inline.
+	scriptDir := csp[strings.Index(csp, "script-src"):]
+	if i := strings.Index(scriptDir, ";"); i >= 0 {
+		scriptDir = scriptDir[:i]
+	}
+	if strings.Contains(scriptDir, "unsafe-inline") {
+		t.Errorf("CSP script-src must not allow 'unsafe-inline': %q", scriptDir)
+	}
 }
 
 func TestSecurityHeadersMiddlewareSkipsHSTSWithoutDirectHTTPS(t *testing.T) {

+ 99 - 0
internal/web/middleware/validate_mutation_test.go

@@ -0,0 +1,99 @@
+package middleware
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+)
+
+// The accept side of validate.go:45 — `c.ShouldBindWith(&dst, binding.JSON)` must SUCCEED
+// for a well-formed JSON body and decode it into the destination struct. If the conditional
+// is flipped (err != nil -> err == nil) or the bind call is dropped, a valid body would be
+// rejected or the fields would come back zero-valued; both fail these assertions.
+func TestBindJSONAndValidate_ValidJSONDecodesAndPasses(t *testing.T) {
+	var got *sampleBody
+	r := newRouter(func(c *gin.Context) {
+		var ok bool
+		got, ok = BindJSONAndValidate[sampleBody](c)
+		if !ok {
+			t.Fatalf("expected ok=true for valid JSON; got false")
+		}
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader(`{"port":443,"protocol":"vless","tag":"inbound-443"}`))
+	req.Header.Set("Content-Type", "application/json")
+	r.ServeHTTP(rec, req)
+
+	if got == nil {
+		t.Fatal("expected decoded struct; got nil")
+	}
+	if got.Port != 443 || got.Protocol != "vless" || got.Tag != "inbound-443" {
+		t.Fatalf("decoded JSON mismatch: %+v", got)
+	}
+}
+
+// The reject side of validate.go:45 — a malformed JSON body must be caught by the bind
+// conditional, returning (nil,false) with a parse-error Message and NO validator Issues.
+// If the conditional is flipped so malformed input bypasses the bind check, control falls
+// through to validate.Struct on a zero-valued struct, which would instead emit validator
+// Issues (e.g. rule="required"/"gte"). Asserting empty Issues + non-empty Message pins the
+// distinct parse-failure path that line 45 owns.
+func TestBindJSONAndValidate_MalformedJSONRejectedWithoutValidatorIssues(t *testing.T) {
+	r := newRouter(func(c *gin.Context) {
+		if _, ok := BindJSONAndValidate[sampleBody](c); ok {
+			t.Fatal("expected ok=false on malformed JSON; got true")
+		}
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader(`{"port":}`))
+	req.Header.Set("Content-Type", "application/json")
+	r.ServeHTTP(rec, req)
+
+	msg := decodeMsg(t, rec.Body.String())
+	if msg.Success {
+		t.Fatal("expected Success=false on malformed JSON")
+	}
+	payload, err := payloadFromObj(msg.Obj)
+	if err != nil {
+		t.Fatalf("payload extraction: %v", err)
+	}
+	if len(payload.Issues) != 0 {
+		t.Fatalf("expected empty Issues for a JSON parse error (not validator output); got %+v", payload.Issues)
+	}
+	if payload.Message == "" {
+		t.Fatal("expected non-empty Message describing the JSON parse error")
+	}
+}
+
+// BindJSONAndValidateInto shares the same line-45-style bind conditional (line 57). Cover its
+// accept side: a valid JSON body must bind onto the caller-supplied destination and pass,
+// overwriting any pre-populated field. A flipped/dropped bind check leaves the destination
+// untouched (or returns false), which these assertions catch.
+func TestBindJSONAndValidateInto_ValidJSONBindsOntoDestination(t *testing.T) {
+	dst := &sampleBody{Tag: "preset"}
+	r := newRouter(func(c *gin.Context) {
+		if !BindJSONAndValidateInto(c, dst) {
+			t.Fatal("expected ok=true for valid JSON; got false")
+		}
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader(`{"port":8443,"protocol":"trojan","tag":"inbound-8443"}`))
+	req.Header.Set("Content-Type", "application/json")
+	r.ServeHTTP(rec, req)
+
+	if dst.Port != 8443 || dst.Protocol != "trojan" {
+		t.Fatalf("expected JSON to bind onto destination; got %+v", dst)
+	}
+	if dst.Tag != "inbound-8443" {
+		t.Fatalf("expected payload Tag to overwrite preset; got %q", dst.Tag)
+	}
+}

+ 4 - 4
internal/web/runtime/remote.go

@@ -87,9 +87,6 @@ func (r *Remote) baseURL() (string, error) {
 		return "", fmt.Errorf("invalid node port %d", r.node.Port)
 	}
 	bp := r.node.BasePath
-	if bp == "" {
-		bp = "/"
-	}
 	if !strings.HasSuffix(bp, "/") {
 		bp += "/"
 	}
@@ -342,7 +339,10 @@ func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string
 	}
 	id, err := r.resolveRemoteID(ctx, ib.Tag)
 	if err != nil {
-		return nil
+		// Can't confirm the delete reached the node — surface it so the caller
+		// marks the node dirty and a reconcile converges, instead of silently
+		// dropping the delete and letting the next snapshot resurrect the client.
+		return fmt.Errorf("remote DeleteUser: resolve tag %q: %w", ib.Tag, err)
 	}
 	body := map[string]any{"inboundIds": []int{id}}
 	_, err = r.do(ctx, http.MethodPost,

+ 117 - 0
internal/web/runtime/remote_test.go

@@ -1,12 +1,20 @@
 package runtime
 
 import (
+	"context"
 	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
+type stubEgress struct{ url string }
+
+func (s stubEgress) NodeEgressProxyURL(int) string { return s.url }
+
 // cacheGetTag must resolve a remote inbound id even when the n<id>- prefix
 // sits on only one side: the node may store the bare tag while the central
 // panel pushes the prefixed form, or vice versa. Without this a mismatch makes
@@ -50,6 +58,115 @@ func TestWireInboundIncludesShareAddressFields(t *testing.T) {
 	}
 }
 
+func TestRemoteHTTPClientEgressProxy(t *testing.T) {
+	// OutboundTag + a resolver → a dedicated proxy client (not the shared default).
+	withTag := NewRemote(&model.Node{Id: 1, Scheme: "https", TlsVerifyMode: "verify", OutboundTag: "warp"}, stubEgress{url: "socks5://127.0.0.1:1080"})
+	c, err := withTag.httpClient()
+	if err != nil {
+		t.Fatalf("httpClient: %v", err)
+	}
+	if c == defaultNodeHTTPClient {
+		t.Fatal("OutboundTag + resolver must produce a dedicated egress client, not the shared default")
+	}
+	// No OutboundTag → no egress proxy → shared default client (verify mode).
+	noTag := NewRemote(&model.Node{Id: 2, Scheme: "https", TlsVerifyMode: "verify"}, stubEgress{url: "socks5://127.0.0.1:1080"})
+	c2, err := noTag.httpClient()
+	if err != nil {
+		t.Fatalf("httpClient: %v", err)
+	}
+	if c2 != defaultNodeHTTPClient {
+		t.Fatal("no OutboundTag must use the shared default client")
+	}
+}
+
+func TestRemoteDoSetsContentType(t *testing.T) {
+	var gotCT string
+	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotCT = r.Header.Get("Content-Type")
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"success":true}`))
+	}))
+	defer srv.Close()
+
+	r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
+	if _, err := r.do(context.Background(), http.MethodPost, "x", url.Values{"a": {"b"}}); err != nil {
+		t.Fatalf("do: %v", err)
+	}
+	if gotCT != "application/x-www-form-urlencoded" {
+		t.Fatalf("Content-Type = %q, want application/x-www-form-urlencoded", gotCT)
+	}
+}
+
+func TestRemoteBaseURL(t *testing.T) {
+	cases := []struct {
+		name    string
+		scheme  string
+		port    int
+		bp      string
+		want    string
+		wantErr bool
+	}{
+		{"https default path", "https", 443, "", "https://example.com:443/", false},
+		{"http custom path gets trailing slash", "http", 8080, "/panel", "http://example.com:8080/panel/", false},
+		{"empty scheme defaults to https", "", 2096, "/", "https://example.com:2096/", false},
+		{"invalid scheme defaults to https", "ftp", 2096, "/", "https://example.com:2096/", false},
+		{"port zero rejected", "https", 0, "/", "", true},
+		{"port above range rejected", "https", 65536, "/", "", true},
+		{"negative port rejected", "https", -1, "/", "", true},
+		{"max port accepted", "https", 65535, "/", "https://example.com:65535/", false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			r := NewRemote(&model.Node{Address: "example.com", Scheme: c.scheme, Port: c.port, BasePath: c.bp}, nil)
+			got, err := r.baseURL()
+			if c.wantErr {
+				if err == nil {
+					t.Fatalf("expected error for scheme=%q port=%d", c.scheme, c.port)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if got != c.want {
+				t.Fatalf("baseURL = %q, want %q", got, c.want)
+			}
+		})
+	}
+}
+
+func TestIsNonEmptySlice(t *testing.T) {
+	cases := []struct {
+		name string
+		in   any
+		want bool
+	}{
+		{"non-empty slice", []any{1}, true},
+		{"empty slice", []any{}, false},
+		{"nil slice", []any(nil), false},
+		{"not a slice", "x", false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			if got := isNonEmptySlice(c.in); got != c.want {
+				t.Fatalf("isNonEmptySlice(%#v) = %v, want %v", c.in, got, c.want)
+			}
+		})
+	}
+}
+
+func TestWireInboundTrafficReset(t *testing.T) {
+	with := wireInbound(&model.Inbound{TrafficReset: "daily"})
+	if got := with.Get("trafficReset"); got != "daily" {
+		t.Fatalf("trafficReset = %q, want daily", got)
+	}
+	// Empty TrafficReset must be omitted entirely, not sent as an empty field.
+	without := wireInbound(&model.Inbound{})
+	if without.Has("trafficReset") {
+		t.Fatalf("trafficReset must be omitted when empty, got %q", without.Get("trafficReset"))
+	}
+}
+
 func TestWireInboundDefaultsShareAddressStrategy(t *testing.T) {
 	values := wireInbound(&model.Inbound{})
 

+ 73 - 0
internal/web/runtime/tls_client_property_test.go

@@ -0,0 +1,73 @@
+package runtime
+
+import (
+	"bytes"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/hex"
+	"strings"
+	"testing"
+
+	"pgregory.net/rapid"
+)
+
+func insertColons(h string) string {
+	var b strings.Builder
+	for i := 0; i < len(h); i += 2 {
+		if i > 0 {
+			b.WriteByte(':')
+		}
+		b.WriteString(h[i : i+2])
+	}
+	return b.String()
+}
+
+// TestProp_DecodeCertPin_FormatAgnostic asserts that for ANY 32-byte pin, every
+// accepted encoding (hex lower/upper, openssl colon-hex, base64 std/raw/url) decodes
+// back to the same bytes. Generalizes the fixed-input TestDecodeCertPin so a mutant
+// that breaks one decoding path is caught across the whole input space.
+func TestProp_DecodeCertPin_FormatAgnostic(t *testing.T) {
+	rapid.Check(t, func(t *rapid.T) {
+		raw := rapid.SliceOfN(rapid.Byte(), sha256.Size, sha256.Size).Draw(t, "raw")
+		hx := hex.EncodeToString(raw)
+		forms := []string{
+			hx,
+			strings.ToUpper(hx),
+			insertColons(hx),
+			base64.StdEncoding.EncodeToString(raw),
+			base64.RawStdEncoding.EncodeToString(raw),
+			base64.URLEncoding.EncodeToString(raw),
+			base64.RawURLEncoding.EncodeToString(raw),
+		}
+		for _, f := range forms {
+			got, err := DecodeCertPin(f)
+			if err != nil {
+				t.Fatalf("DecodeCertPin(%q) errored: %v", f, err)
+			}
+			if !bytes.Equal(got, raw) {
+				t.Fatalf("DecodeCertPin(%q) = %x, want %x", f, got, raw)
+			}
+		}
+	})
+}
+
+// FuzzDecodeCertPin asserts the security-load-bearing decoder never panics, never
+// returns a non-32-byte slice with a nil error, and never returns bytes alongside an
+// error. Seeded from the known-good/known-bad cases.
+func FuzzDecodeCertPin(f *testing.F) {
+	seed := sha256.Sum256([]byte("seed"))
+	f.Add(hex.EncodeToString(seed[:]))
+	f.Add(base64.StdEncoding.EncodeToString(seed[:]))
+	f.Add(insertColons(hex.EncodeToString(seed[:])))
+	f.Add("")
+	f.Add("not-a-pin")
+	f.Fuzz(func(t *testing.T, s string) {
+		got, err := DecodeCertPin(s)
+		if err == nil && len(got) != sha256.Size {
+			t.Fatalf("DecodeCertPin(%q): nil error but %d bytes, want %d", s, len(got), sha256.Size)
+		}
+		if err != nil && got != nil {
+			t.Fatalf("DecodeCertPin(%q): error %v but returned bytes %x", s, err, got)
+		}
+	})
+}

+ 61 - 2
internal/web/runtime/tls_client_test.go

@@ -116,8 +116,67 @@ func TestHTTPClientForNodeVerifyShared(t *testing.T) {
 }
 
 func TestHTTPClientForNodePinInvalid(t *testing.T) {
-	if _, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: "not-a-pin"}, ""); err == nil {
-		t.Fatal("expected error for invalid pin")
+	// pin mode must fail closed, and with a specific error per cause — not merely
+	// "some error" (which a bug anywhere in the build path would also satisfy).
+	cases := []struct {
+		name    string
+		pin     string
+		wantErr string
+	}{
+		{"garbage pin", "not-a-pin", "must be a SHA-256 hash"},
+		{"empty pin", "", "certificate pin is empty"},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			_, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: c.pin}, "")
+			if err == nil {
+				t.Fatalf("expected error for pin %q", c.pin)
+			}
+			if !strings.Contains(err.Error(), c.wantErr) {
+				t.Fatalf("error = %q, want it to contain %q", err.Error(), c.wantErr)
+			}
+		})
+	}
+}
+
+// TestHTTPClientForNode_ProxyPinPreservesPinEnforcement covers the proxy+pin branch
+// (tls_client.go:43-52): when a node uses a proxy AND pin mode, the proxy client's
+// transport must carry the pinning tls.Config (the `transport.TLSClientConfig = tlsCfg`
+// line). Dropping it would silently disable certificate pinning whenever a proxy is set.
+func TestHTTPClientForNode_ProxyPinPreservesPinEnforcement(t *testing.T) {
+	pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
+	n := &model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: pin}
+
+	c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
+	if err != nil {
+		t.Fatalf("HTTPClientForNode: %v", err)
+	}
+	if c == defaultNodeHTTPClient {
+		t.Fatal("proxy client must not be the shared default client")
+	}
+	tr, ok := c.Transport.(*http.Transport)
+	if !ok {
+		t.Fatalf("transport is %T, want *http.Transport", c.Transport)
+	}
+	if tr.TLSClientConfig == nil || tr.TLSClientConfig.VerifyConnection == nil {
+		t.Fatal("pin mode over a proxy must install a pinning tls.Config (VerifyConnection); pin enforcement was dropped")
+	}
+}
+
+// TestHTTPClientForNode_ProxyVerifyNoPin covers the proxy+verify branch
+// (tls_client.go:40-42): verify mode over a proxy returns the proxy client as-is,
+// using system-CA verification and NOT a pin VerifyConnection.
+func TestHTTPClientForNode_ProxyVerifyNoPin(t *testing.T) {
+	n := &model.Node{Scheme: "https", TlsVerifyMode: "verify"}
+	c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
+	if err != nil {
+		t.Fatalf("HTTPClientForNode: %v", err)
+	}
+	if c == defaultNodeHTTPClient {
+		t.Fatal("proxy client must not be the shared default client")
+	}
+	if tr, ok := c.Transport.(*http.Transport); ok && tr.TLSClientConfig != nil && tr.TLSClientConfig.VerifyConnection != nil {
+		t.Fatal("verify mode must not install a pin VerifyConnection")
 	}
 }
 

+ 24 - 10
internal/web/service/client_inbound_apply.go

@@ -742,15 +742,17 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 			}
 		}
 
-		if needApiDel {
-			rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
-			if perr != nil {
-				return false, perr
-			}
-			if dirty {
-				markDirty = true
-			}
-			if oldInbound.NodeID == nil {
+		if oldInbound.NodeID == nil {
+			// Local inbound: a disabled client isn't in the running Xray, so only
+			// a live one (needApiDel) needs an API removal.
+			if needApiDel {
+				rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+				if perr != nil {
+					return false, perr
+				}
+				if dirty {
+					markDirty = true
+				}
 				if !push {
 					needRestart = true
 				} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
@@ -762,7 +764,19 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 					logger.Debug("Error in deleting client on", rt.Name(), ":", email)
 					needRestart = true
 				}
-			} else if push {
+			}
+		} else {
+			// Node inbound: propagate the delete regardless of the enable flag —
+			// the node's own DB still carries a disabled client and would
+			// resurrect it on the next snapshot otherwise.
+			rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+			if perr != nil {
+				return false, perr
+			}
+			if dirty {
+				markDirty = true
+			}
+			if push {
 				if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
 					logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
 					markDirty = true

+ 297 - 0
internal/web/service/email/email.go

@@ -0,0 +1,297 @@
+package email
+
+import (
+	"crypto/tls"
+	"fmt"
+	"net"
+	"net/smtp"
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+// EmailService sends email notifications via SMTP.
+type EmailService struct {
+	settingService service.SettingService
+}
+
+// SMTPTestResult holds the result of an SMTP connection test.
+type SMTPTestResult struct {
+	Success bool   `json:"success"`
+	Stage   string `json:"stage"`   // "connect" | "auth" | "send"
+	Message string `json:"message"` // classified error message
+}
+
+// NewEmailService creates a new EmailService.
+func NewEmailService(settingService service.SettingService) *EmailService {
+	return &EmailService{settingService: settingService}
+}
+
+// Send sends an HTML email to all configured recipients.
+func (s *EmailService) Send(subject, body string) error {
+	host, err := s.settingService.GetSmtpHost()
+	if err != nil || host == "" {
+		return fmt.Errorf("smtp host not configured")
+	}
+	port, err := s.settingService.GetSmtpPort()
+	if err != nil || port <= 0 {
+		port = 587
+	}
+	username, _ := s.settingService.GetSmtpUsername()
+	password, _ := s.settingService.GetSmtpPassword()
+	toStr, _ := s.settingService.GetSmtpTo()
+	encryptionType, _ := s.settingService.GetSmtpEncryptionType()
+
+	from := username
+	if from == "" {
+		return fmt.Errorf("smtp from not configured")
+	}
+
+	recipients := parseRecipients(toStr)
+	if len(recipients) == 0 {
+		return fmt.Errorf("no recipients configured")
+	}
+
+	addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
+	msg := buildMessage(from, recipients, subject, body)
+
+	// Authenticate only when credentials are set. Go's PlainAuth refuses to run
+	// over the unencrypted "none" transport, so an open relay must use nil auth.
+	var auth smtp.Auth
+	if username != "" && password != "" {
+		auth = smtp.PlainAuth("", username, password, host)
+	}
+
+	// Wrap in a channel with timeout to prevent indefinite blocking
+	type result struct{ err error }
+	ch := make(chan result, 1)
+	go func() {
+		switch encryptionType {
+		case "tls":
+			ch <- result{s.sendWithTLS(addr, auth, from, recipients, msg, host)}
+		case "starttls", "none":
+			ch <- result{smtp.SendMail(addr, auth, from, recipients, msg)}
+		default:
+			ch <- result{fmt.Errorf("unknown SMTP encryption type: %s", encryptionType)}
+		}
+	}()
+
+	select {
+	case r := <-ch:
+		return r.err
+	case <-time.After(30 * time.Second):
+		return fmt.Errorf("smtp connection timed out after 30s")
+	}
+}
+
+// TestConnection tests SMTP connection stage by stage and sends a test email.
+func (s *EmailService) TestConnection() SMTPTestResult {
+	host, err := s.settingService.GetSmtpHost()
+	if err != nil || host == "" {
+		return SMTPTestResult{false, "connect", "smtpHostNotConfigured"}
+	}
+	port, err := s.settingService.GetSmtpPort()
+	if err != nil || port <= 0 {
+		port = 587
+	}
+	username, _ := s.settingService.GetSmtpUsername()
+	password, _ := s.settingService.GetSmtpPassword()
+	toStr, _ := s.settingService.GetSmtpTo()
+	encryptionType, _ := s.settingService.GetSmtpEncryptionType()
+
+	from := username
+
+	recipients := parseRecipients(toStr)
+	if len(recipients) == 0 {
+		return SMTPTestResult{false, "send", "smtpNoRecipients"}
+	}
+
+	addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
+
+	// Stage 1: Connect
+	var conn net.Conn
+	dialer := &net.Dialer{Timeout: 5 * time.Second}
+
+	switch encryptionType {
+	case "tls":
+		conn, err = tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{
+			ServerName:         host,
+			InsecureSkipVerify: false,
+		})
+	default:
+		conn, err = dialer.Dial("tcp", addr)
+	}
+
+	if err != nil {
+		return SMTPTestResult{false, "connect", classifySMTPError(err)}
+	}
+	defer conn.Close()
+
+	// Stage 2: Handshake + Auth
+	client, err := smtp.NewClient(conn, host)
+	if err != nil {
+		return SMTPTestResult{false, "auth", classifySMTPError(err)}
+	}
+	defer client.Close()
+
+	if err = client.Hello("localhost"); err != nil {
+		return SMTPTestResult{false, "auth", classifySMTPError(err)}
+	}
+
+	// STARTTLS upgrade for non-TLS connections
+	if encryptionType == "starttls" {
+		if ok, _ := client.Extension("STARTTLS"); ok {
+			if err = client.StartTLS(&tls.Config{ServerName: host}); err != nil {
+				return SMTPTestResult{false, "auth", classifySMTPError(err)}
+			}
+		}
+	}
+
+	if username != "" && password != "" {
+		auth := smtp.PlainAuth("", username, password, host)
+		if err = client.Auth(auth); err != nil {
+			return SMTPTestResult{false, "auth", classifySMTPError(err)}
+		}
+	}
+
+	// Stage 3: Send test email
+	if err = client.Mail(from); err != nil {
+		return SMTPTestResult{false, "send", classifySMTPError(err)}
+	}
+	for _, r := range recipients {
+		if err = client.Rcpt(r); err != nil {
+			return SMTPTestResult{false, "send", classifySMTPError(err)}
+		}
+	}
+
+	msg := buildMessage(from, recipients, "[3x-ui] Test email",
+		`<html><body style="font-family:monospace;font-size:14px">
+<h2>Test email from 3x-ui</h2>
+<p>If you received this, SMTP is configured correctly.</p>
+</body></html>`)
+
+	w, err := client.Data()
+	if err != nil {
+		return SMTPTestResult{false, "send", classifySMTPError(err)}
+	}
+	if _, err = w.Write(msg); err != nil {
+		return SMTPTestResult{false, "send", classifySMTPError(err)}
+	}
+	if err = w.Close(); err != nil {
+		return SMTPTestResult{false, "send", classifySMTPError(err)}
+	}
+
+	return SMTPTestResult{true, "send", "smtpTestSuccess"}
+}
+
+func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
+	// Dial with explicit timeout
+	dialer := &net.Dialer{Timeout: 10 * time.Second}
+	conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{
+		ServerName:         host,
+		InsecureSkipVerify: false,
+	})
+	if err != nil {
+		return err
+	}
+	defer conn.Close()
+
+	client, err := smtp.NewClient(conn, host)
+	if err != nil {
+		return err
+	}
+	defer client.Close()
+
+	if err = client.Hello("localhost"); err != nil {
+		return err
+	}
+	if auth != nil {
+		if err = client.Auth(auth); err != nil {
+			return err
+		}
+	}
+	if err = client.Mail(from); err != nil {
+		return err
+	}
+	for _, r := range to {
+		if err = client.Rcpt(r); err != nil {
+			return err
+		}
+	}
+	w, err := client.Data()
+	if err != nil {
+		return err
+	}
+	if _, err = w.Write(msg); err != nil {
+		return err
+	}
+	return w.Close()
+}
+
+// SendTest sends a test email and returns any error with detail.
+func (s *EmailService) SendTest() error {
+	return s.Send(
+		"[3x-ui] Test email",
+		`<html><body style="font-family:monospace;font-size:14px">
+<h2>Test email from 3x-ui</h2>
+<p>If you received this, SMTP is configured correctly.</p>
+</body></html>`,
+	)
+}
+
+// classifySMTPError maps raw SMTP errors to human-readable messages.
+func classifySMTPError(err error) string {
+	msg := err.Error()
+	msgLower := strings.ToLower(msg)
+
+	switch {
+	case strings.Contains(msg, "535") || strings.Contains(msgLower, "authentication"):
+		return "pages.settings.smtpErrorAuth"
+	case strings.Contains(msg, "534") || strings.Contains(msgLower, "starttls"):
+		return "pages.settings.smtpErrorStarttls"
+	case strings.Contains(msg, "465") || strings.Contains(msgLower, "tls"):
+		return "pages.settings.smtpErrorTls"
+	case strings.Contains(msgLower, "connection refused") || strings.Contains(msgLower, "dial"):
+		return "pages.settings.smtpErrorRefused"
+	case strings.Contains(msgLower, "timeout"):
+		return "pages.settings.smtpErrorTimeout"
+	case strings.Contains(msg, "550") || strings.Contains(msgLower, "relay"):
+		return "pages.settings.smtpErrorRelay"
+	case strings.Contains(msgLower, "eof"):
+		return "pages.settings.smtpErrorEof"
+	default:
+		return fmt.Sprintf("pages.settings.smtpErrorUnknown: %s", msg)
+	}
+}
+
+func parseRecipients(toStr string) []string {
+	if toStr == "" {
+		return nil
+	}
+	var out []string
+	for _, s := range strings.Split(toStr, ",") {
+		s = strings.TrimSpace(s)
+		if s != "" {
+			out = append(out, s)
+		}
+	}
+	return out
+}
+
+func buildMessage(from string, to []string, subject, body string) []byte {
+	headers := map[string]string{
+		"From":         from,
+		"To":           strings.Join(to, ","),
+		"Subject":      subject,
+		"MIME-Version": "1.0",
+		"Content-Type": "text/html; charset=utf-8",
+	}
+	var msg strings.Builder
+	for k, v := range headers {
+		msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
+	}
+	msg.WriteString("\r\n")
+	msg.WriteString(body)
+	return []byte(msg.String())
+}

+ 52 - 0
internal/web/service/email/ratelimiter_test.go

@@ -0,0 +1,52 @@
+package email
+
+import (
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
+)
+
+func TestRateLimiterAllow(t *testing.T) {
+	rl := eventbus.NewRateLimiter(time.Minute)
+
+	if !rl.Allow(eventbus.EventOutboundDown, "proxy-1") {
+		t.Error("first call should be allowed")
+	}
+}
+
+func TestRateLimiterCooldown(t *testing.T) {
+	rl := eventbus.NewRateLimiter(100 * time.Millisecond)
+
+	rl.Allow(eventbus.EventOutboundDown, "proxy-1")
+
+	if rl.Allow(eventbus.EventOutboundDown, "proxy-1") {
+		t.Error("should be blocked during cooldown")
+	}
+
+	time.Sleep(110 * time.Millisecond)
+
+	if !rl.Allow(eventbus.EventOutboundDown, "proxy-1") {
+		t.Error("should be allowed after cooldown")
+	}
+}
+
+func TestRateLimiterPerType(t *testing.T) {
+	rl := eventbus.NewRateLimiter(time.Minute)
+
+	rl.Allow(eventbus.EventOutboundDown, "proxy-1")
+
+	if !rl.Allow(eventbus.EventOutboundUp, "proxy-1") {
+		t.Error("different event types should be independent")
+	}
+}
+
+func TestRateLimiterPerSource(t *testing.T) {
+	rl := eventbus.NewRateLimiter(time.Minute)
+
+	rl.Allow(eventbus.EventOutboundDown, "proxy-1")
+
+	if !rl.Allow(eventbus.EventOutboundDown, "proxy-2") {
+		t.Error("different sources should be independent")
+	}
+}

+ 182 - 0
internal/web/service/email/subscriber.go

@@ -0,0 +1,182 @@
+package email
+
+import (
+	"fmt"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+// Subscriber handles event bus messages and sends email notifications.
+type Subscriber struct {
+	settingService service.SettingService
+	emailService   *EmailService
+	limiter        *eventbus.RateLimiter
+}
+
+// NewSubscriber creates a new email event subscriber.
+func NewSubscriber(settingService service.SettingService, emailService *EmailService) *Subscriber {
+	return &Subscriber{
+		settingService: settingService,
+		emailService:   emailService,
+		limiter:        eventbus.NewRateLimiter(1 * time.Minute),
+	}
+}
+
+// HandleEvent is the eventbus subscriber callback.
+func (s *Subscriber) HandleEvent(e eventbus.Event) {
+	if !s.isEventEnabled(e.Type) {
+		return
+	}
+	if e.Type != eventbus.EventLoginAttempt {
+		if !s.limiter.Allow(e.Type, e.Source) {
+			return
+		}
+	}
+	subject, body := s.formatMessage(e)
+	if subject == "" {
+		return
+	}
+	if err := s.emailService.Send(subject, body); err != nil {
+		logger.Warning("email subscriber: send failed:", err)
+	}
+}
+
+func (s *Subscriber) isEventEnabled(t eventbus.EventType) bool {
+	events, err := s.settingService.GetSmtpEnabledEvents()
+	if err != nil || events == "" {
+		return false
+	}
+	for _, e := range strings.Split(events, ",") {
+		if strings.TrimSpace(e) == string(t) {
+			return true
+		}
+	}
+	return false
+}
+
+func i18n(key string, params ...string) string {
+	return locale.I18n(locale.Bot, key, params...)
+}
+
+func (s *Subscriber) formatMessage(e eventbus.Event) (subject, body string) {
+	h, _ := hostname()
+	host := h
+	ts := e.Timestamp.Format("2006-01-02 15:04:05")
+
+	wrap := func(title, content string) string {
+		// Strip newlines from title to prevent broken HTML
+		title = strings.ReplaceAll(title, "\r\n", "")
+		title = strings.ReplaceAll(title, "\n", "")
+		return fmt.Sprintf(`<html><body style="font-family:monospace;font-size:14px;color:#333">
+<h2 style="color:#555;border-bottom:1px solid #ddd;padding-bottom:8px">📡 %s %s</h2>
+%s
+<p style="color:#999;font-size:12px;margin-top:20px">%s</p>
+</body></html>`, host, title, content, i18n("tgbot.messages.time", "Time=="+ts))
+	}
+
+	kv := func(key, val string) string {
+		return fmt.Sprintf("<p><b>%s:</b> %s</p>", key, val)
+	}
+
+	switch e.Type {
+	case eventbus.EventOutboundDown:
+		subject = host + " " + i18n("tgbot.messages.eventOutboundDown", "Tag=="+e.Source)
+		content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusDown")+`</span>`)
+		content += kv(i18n("email.labelOutbound"), e.Source)
+		if data, ok := e.Data.(*eventbus.OutboundHealthData); ok {
+			if data.Error != "" {
+				content += kv(i18n("email.labelError"), data.Error)
+			}
+			if data.Delay > 0 {
+				content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.Delay))
+			}
+		}
+		body = wrap(i18n("tgbot.messages.eventOutboundDown", "Tag=="+e.Source), content)
+
+	case eventbus.EventOutboundUp:
+		subject = host + " " + i18n("tgbot.messages.eventOutboundUp", "Tag=="+e.Source)
+		content := kv(i18n("email.labelStatus"), `<span style="color:green">`+i18n("email.statusUp")+`</span>`)
+		content += kv(i18n("email.labelOutbound"), e.Source)
+		if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 {
+			content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.Delay))
+		}
+		body = wrap(i18n("tgbot.messages.eventOutboundUp", "Tag=="+e.Source), content)
+
+	case eventbus.EventXrayCrash:
+		subject = host + " " + i18n("tgbot.messages.eventXrayCrash")
+		content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusCrashed")+`</span>`)
+		if e.Data != nil {
+			content += kv(i18n("email.labelError"), fmt.Sprint(e.Data))
+		}
+		body = wrap(i18n("tgbot.messages.eventXrayCrash"), content)
+
+	case eventbus.EventNodeDown:
+		subject = host + " " + i18n("tgbot.messages.eventNodeDown", "Name=="+e.Source)
+		content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusDown")+`</span>`)
+		content += kv(i18n("email.labelNode"), e.Source)
+		if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" {
+			content += kv(i18n("email.labelError"), data.XrayError)
+		}
+		body = wrap(i18n("tgbot.messages.eventNodeDown", "Name=="+e.Source), content)
+
+	case eventbus.EventNodeUp:
+		subject = host + " " + i18n("tgbot.messages.eventNodeUp", "Name=="+e.Source)
+		content := kv(i18n("email.labelStatus"), `<span style="color:green">`+i18n("email.statusUp")+`</span>`)
+		content += kv(i18n("email.labelNode"), e.Source)
+		if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.LatencyMs > 0 {
+			content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.LatencyMs))
+		}
+		body = wrap(i18n("tgbot.messages.eventNodeUp", "Name=="+e.Source), content)
+
+	case eventbus.EventCPUHigh:
+		if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
+			smtpCpu, err := s.settingService.GetSmtpCpu()
+			if err != nil || smtpCpu <= 0 || data.Percent <= float64(smtpCpu) {
+				return
+			}
+			subject = host + " " + i18n("tgbot.messages.cpuThreshold",
+				"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
+				"Threshold=="+fmt.Sprintf("%d", smtpCpu))
+			content := kv(i18n("email.labelStatus"), `<span style="color:orange">`+i18n("email.statusHigh")+`</span>`)
+			body = wrap(subject, content)
+		}
+
+	case eventbus.EventLoginAttempt:
+		if data, ok := e.Data.(*eventbus.LoginEventData); ok {
+			if data.Status == "success" {
+				subject = host + " " + i18n("tgbot.messages.loginSuccess")
+				content := kv(i18n("email.labelStatus"), `<span style="color:green">`+i18n("email.statusSuccess")+`</span>`)
+				content += kv(i18n("email.labelUsername"), data.Username)
+				content += kv(i18n("email.labelIP"), data.IP)
+				body = wrap(i18n("tgbot.messages.loginSuccess"), content)
+			} else {
+				subject = host + " " + i18n("tgbot.messages.loginFailed")
+				content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusFailed")+`</span>`)
+				if data.Reason != "" {
+					content += kv(i18n("email.labelReason"), data.Reason)
+				}
+				content += kv(i18n("email.labelUsername"), data.Username)
+				content += kv(i18n("email.labelIP"), data.IP)
+				body = wrap(i18n("tgbot.messages.loginFailed"), content)
+			}
+		} else {
+			subject = host + " " + i18n("tgbot.messages.loginFailed")
+			content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusFailed")+`</span>`)
+			content += kv(i18n("email.labelSource"), e.Source)
+			body = wrap(i18n("tgbot.messages.loginFailed"), content)
+		}
+	}
+
+	return
+}
+
+func hostname() (string, error) {
+	return os.Hostname()
+}

+ 1 - 1
internal/web/service/inbound_migration.go

@@ -244,7 +244,7 @@ func (s *InboundService) MigrationRequirements() {
 			SET tag = REPLACE(tag, '0.0.0.0:', '')
 			WHERE position('0.0.0.0:' in tag) > 0;`
 	}
-	err = tx.Raw(tagCleanup).Error
+	err = tx.Exec(tagCleanup).Error
 	if err != nil {
 		return
 	}

+ 39 - 0
internal/web/service/inbound_migration_test.go

@@ -90,6 +90,45 @@ func TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound(t *
 	}
 }
 
+// TestMigrationRequirements_CleansLegacyZeroAddrTag guards the legacy tag cleanup that
+// strips the auto-generated "0.0.0.0:" prefix. The inbound is MultiDomain TLS so the
+// externalProxy detection query returns rows and the cleanup is reached (it early-returns
+// at len(externalProxy)==0 otherwise). The cleanup must use tx.Exec, not tx.Raw, which
+// only builds a non-SELECT statement without running it.
+func TestMigrationRequirements_CleansLegacyZeroAddrTag(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	db := database.GetDB()
+	legacy := &model.Inbound{
+		UserId:         1,
+		Tag:            "inbound-0.0.0.0:30002",
+		Enable:         true,
+		Port:           30002,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[]}`,
+		StreamSettings: `{"security":"tls","tlsSettings":{"settings":{"domains":[{"domain":"example.com"}]}}}`,
+	}
+	if err := db.Create(legacy).Error; err != nil {
+		t.Fatalf("create legacy inbound: %v", err)
+	}
+
+	svc := InboundService{}
+	svc.MigrationRequirements()
+
+	var got model.Inbound
+	if err := db.First(&got, legacy.Id).Error; err != nil {
+		t.Fatalf("reload inbound: %v", err)
+	}
+	if got.Tag != "inbound-30002" {
+		t.Fatalf("legacy 0.0.0.0: tag not stripped: got %q, want %q", got.Tag, "inbound-30002")
+	}
+}
+
 func TestMigrationRequirements_NormalizesShareAddressFields(t *testing.T) {
 	setupConflictDB(t)
 	db := database.GetDB()

+ 61 - 15
internal/web/service/inbound_node.go

@@ -153,6 +153,25 @@ func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email strin
 	}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
 }
 
+// mergeActivationExpiry reconciles a node-reported client expiry with the value
+// already stored on the master. "Start after first connect" persists a negative
+// duration that each node converts to an absolute deadline (now+duration) the
+// first time the client connects there. The per-email client_traffics row is
+// shared across every node, so a node that has not yet seen a first connection
+// keeps reporting the negative duration — which must never reset a deadline
+// another node already activated.
+//
+// A node may legitimately move an already-activated deadline forward (traffic
+// reset / auto-renew extends it), so any positive node value is still adopted —
+// only an un-activated (<= 0) value is rejected once an absolute deadline
+// exists. Kept in lockstep with the SQL CASE in setRemoteTrafficLocked.
+func mergeActivationExpiry(existing, node int64) int64 {
+	if existing > 0 && node <= 0 {
+		return existing
+	}
+	return node
+}
+
 func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
 	var structuralChange bool
 	err := submitTrafficWrite(func() error {
@@ -268,13 +287,28 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 	// entirely — an email whose stats moved to (or always lived under) a
 	// sibling inbound still needs its baseline for the sibling's delta
 	// computation (#5202).
+	//
+	// Xray counts traffic per email, not per inbound, so a multi-attached
+	// client's shared counter is copied onto every inbound it's on. Fold each
+	// email to its per-field max (nodeEmailTotals) so divergent copies can't make
+	// the reset clamp re-add a lower sibling as fresh traffic (#5274).
 	snapEmailsAll := make(map[string]struct{})
+	nodeEmailTotals := make(map[string]nodeTrafficCounter)
 	for _, snapIb := range snap.Inbounds {
 		if snapIb == nil {
 			continue
 		}
 		for i := range snapIb.ClientStats {
-			snapEmailsAll[snapIb.ClientStats[i].Email] = struct{}{}
+			email := snapIb.ClientStats[i].Email
+			snapEmailsAll[email] = struct{}{}
+			cur := nodeEmailTotals[email]
+			if snapIb.ClientStats[i].Up > cur.Up {
+				cur.Up = snapIb.ClientStats[i].Up
+			}
+			if snapIb.ClientStats[i].Down > cur.Down {
+				cur.Down = snapIb.ClientStats[i].Down
+			}
+			nodeEmailTotals[email] = cur
 		}
 	}
 
@@ -500,14 +534,17 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		for _, cs := range snapIb.ClientStats {
 			snapEmails[cs.Email] = struct{}{}
 
+			// Node-wide total, not this inbound's possibly-stale copy (#5274).
+			canon := nodeEmailTotals[cs.Email]
+
 			base, seen := nodeBaselines[cs.Email]
 			var deltaUp, deltaDown int64
 			if seen {
-				if deltaUp = cs.Up - base.Up; deltaUp < 0 {
-					deltaUp = cs.Up
+				if deltaUp = canon.Up - base.Up; deltaUp < 0 {
+					deltaUp = canon.Up
 				}
-				if deltaDown = cs.Down - base.Down; deltaDown < 0 {
-					deltaDown = cs.Down
+				if deltaDown = canon.Down - base.Down; deltaDown < 0 {
+					deltaDown = canon.Down
 				}
 			}
 
@@ -522,8 +559,8 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 					Total:      cs.Total,
 					ExpiryTime: cs.ExpiryTime,
 					Reset:      cs.Reset,
-					Up:         cs.Up,
-					Down:       cs.Down,
+					Up:         canon.Up,
+					Down:       canon.Down,
 					LastOnline: cs.LastOnline,
 				}
 				if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "email"}}, DoNothing: true}).
@@ -534,40 +571,49 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				centralCSByEmail[cs.Email] = row
 				existingEmails[cs.Email] = struct{}{}
 				structuralChange = true
-				if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, cs.Up, cs.Down); err != nil {
+				if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
 					return false, err
 				}
-				nodeBaselines[cs.Email] = nodeTrafficCounter{Up: cs.Up, Down: cs.Down}
+				nodeBaselines[cs.Email] = nodeTrafficCounter{Up: canon.Up, Down: canon.Down}
 				continue
 			}
 
 			if existing := centralCSByEmail[cs.Email]; existing != nil &&
 				(existing.Enable != cs.Enable ||
 					existing.Total != cs.Total ||
-					existing.ExpiryTime != cs.ExpiryTime ||
+					existing.ExpiryTime != mergeActivationExpiry(existing.ExpiryTime, cs.ExpiryTime) ||
 					existing.Reset != cs.Reset) {
 				structuralChange = true
 			}
 
 			enableExpr := database.ClientTrafficEnableMergeExpr()
+			// expiry_time merge mirrors mergeActivationExpiry: a node that has not
+			// yet seen the client's first connection keeps reporting the negative
+			// "start after first connect" duration, which must never reset the
+			// absolute deadline another node already activated. A positive node
+			// value is still adopted (e.g. auto-renew moves the deadline forward).
+			// CAST(? AS BIGINT): in the `<= 0` comparison Postgres would otherwise
+			// infer int4 from the literal and overflow on real expiry values.
 			if err := tx.Exec(
 				fmt.Sprintf(
 					`UPDATE client_traffics
-					 SET up = up + ?, down = down + ?, enable = %s, total = ?, expiry_time = ?, reset = ?,
-					     last_online = %s
+					 SET up = up + ?, down = down + ?, enable = %s, total = ?,
+					     expiry_time = CASE WHEN expiry_time > 0 AND CAST(? AS BIGINT) <= 0 THEN expiry_time ELSE CAST(? AS BIGINT) END,
+					     reset = ?, last_online = %s
 					 WHERE email = ?`,
 					enableExpr,
 					database.GreatestExpr("last_online", "?"),
 				),
-				deltaUp, deltaDown, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset,
+				deltaUp, deltaDown, cs.Enable, cs.Total,
+				cs.ExpiryTime, cs.ExpiryTime, cs.Reset,
 				cs.LastOnline, cs.Email,
 			).Error; err != nil {
 				return false, err
 			}
-			if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, cs.Up, cs.Down); err != nil {
+			if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
 				return false, err
 			}
-			nodeBaselines[cs.Email] = nodeTrafficCounter{Up: cs.Up, Down: cs.Down}
+			nodeBaselines[cs.Email] = nodeTrafficCounter{Up: canon.Up, Down: canon.Down}
 		}
 
 		for k, existing := range centralCS {

+ 59 - 25
internal/web/service/node.go

@@ -357,9 +357,27 @@ func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node
 	if err := s.normalize(n); err != nil {
 		return nil, err
 	}
-	return runtime.NewRemote(n, nil).ListInboundOptions(ctx)
+	if n.OutboundTag == "" {
+		return runtime.NewRemote(n, nil).ListInboundOptions(ctx)
+	}
+	// Mirror ProbeWithOutbound: a node being added/edited has no persistent
+	// egress bridge yet, so route the list call through a temporary one or the
+	// remote panel stays unreachable and the request times out.
+	var options []runtime.RemoteInboundOption
+	var err error
+	s.withOutboundBridge(n.Id, n.OutboundTag, func(proxyURL string) {
+		options, err = runtime.NewRemote(n, staticEgressResolver(proxyURL)).ListInboundOptions(ctx)
+	})
+	return options, err
 }
 
+// staticEgressResolver hands a fixed proxy URL to runtime.NewRemote. An empty
+// string yields a direct connection, so it doubles as the graceful fallback
+// when a temporary bridge can't be built.
+type staticEgressResolver string
+
+func (r staticEgressResolver) NodeEgressProxyURL(int) string { return string(r) }
+
 // EnsureInboundTagAllowed adds a panel-managed inbound's tag to the node's
 // selection when the node syncs in "selected" mode. Without it, the next
 // traffic sync would filter the tag out of the snapshot and the orphan sweep
@@ -611,23 +629,46 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
 	if outboundTag == "" {
 		return s.Probe(ctx, n)
 	}
+	var patch HeartbeatPatch
+	var err error
+	s.withOutboundBridge(n.Id, outboundTag, func(proxyURL string) {
+		if proxyURL == "" {
+			patch, err = s.Probe(ctx, n)
+			return
+		}
+		patch, err = s.probe(ctx, n, proxyURL)
+	})
+	return patch, err
+}
+
+// withOutboundBridge stands up a temporary loopback SOCKS5 inbound in the
+// running Xray, routes it through outboundTag, and runs fn with the bridge's
+// proxy URL before tearing it down. It is used to reach a node through its
+// connection outbound before the persistent egress bridge has been injected
+// into the config (e.g. while the node is still being added or edited). When
+// Xray isn't running or the bridge can't be built, fn runs with an empty
+// proxyURL so callers fall back to a direct connection.
+func (s *NodeService) withOutboundBridge(nodeID int, outboundTag string, fn func(proxyURL string)) {
 	proc := XrayProcess()
 	if proc == nil || !proc.IsRunning() {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
 	apiPort := proc.GetAPIPort()
 	if apiPort <= 0 {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
 
 	listener, err := net.Listen("tcp", "127.0.0.1:0")
 	if err != nil {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
 	port := listener.Addr().(*net.TCPAddr).Port
 	listener.Close()
 
-	tag := fmt.Sprintf("node-test-%d-%d", n.Id, time.Now().UnixNano())
+	tag := fmt.Sprintf("node-test-%d-%d", nodeID, time.Now().UnixNano())
 	proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", port)
 
 	inboundJSON, err := json.Marshal(xray.InboundConfig{
@@ -638,7 +679,8 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
 		Tag:      tag,
 	})
 	if err != nil {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
 
 	cfg := proc.GetConfig()
@@ -659,31 +701,31 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
 	routing["rules"] = append([]any{rule}, rules...)
 	routingJSON, err := json.Marshal(routing)
 	if err != nil {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
 	originalRoutingJSON := cfg.RouterConfig
 
 	api := xray.XrayAPI{}
 	if err := api.Init(apiPort); err != nil {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
 	defer api.Close()
 
 	if err := api.AddInbound(inboundJSON); err != nil {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
-	removed := false
 	defer func() {
-		if removed {
-			return
-		}
 		if err := api.DelInbound(tag); err != nil {
-			logger.Warning("remove temp node test inbound failed:", err)
+			logger.Warning("remove temp node bridge inbound failed:", err)
 		}
 	}()
 
 	if err := api.ApplyRoutingConfig(routingJSON); err != nil {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
 	defer func() {
 		restore := originalRoutingJSON
@@ -691,19 +733,11 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
 			restore = []byte("{}")
 		}
 		if err := api.ApplyRoutingConfig(restore); err != nil {
-			logger.Warning("restore routing after node test failed:", err)
+			logger.Warning("restore routing after node bridge failed:", err)
 		}
 	}()
 
-	patch, err := s.probe(ctx, n, proxyURL)
-	removed = true
-	if delErr := api.DelInbound(tag); delErr != nil {
-		logger.Warning("remove temp node test inbound failed:", delErr)
-	}
-	if err != nil {
-		return patch, err
-	}
-	return patch, nil
+	fn(proxyURL)
 }
 
 func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) (HeartbeatPatch, error) {

+ 141 - 0
internal/web/service/node_client_expiry_sync_test.go

@@ -0,0 +1,141 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// TestMergeActivationExpiry covers the pure reconciliation rule in isolation.
+func TestMergeActivationExpiry(t *testing.T) {
+	const (
+		dur   = int64(-2592000000) // 30 days as a "start after first connect" duration
+		early = int64(1000)        // earliest absolute deadline (first connection)
+		late  = int64(2000)        // a later absolute deadline
+	)
+	cases := []struct {
+		name           string
+		existing, node int64
+		want           int64
+	}{
+		{"master unset takes node duration", 0, dur, dur},
+		{"master unset takes node activation", 0, early, early},
+		{"activation adopted over stored duration", dur, early, early},
+		{"node still un-activated does not reset deadline", early, dur, early},
+		{"node un-activated zero does not reset deadline", early, 0, early},
+		{"node renewal extends the deadline forward", early, late, late},
+		{"node positive adopted even if earlier", late, early, early},
+		{"both un-activated keep node value", dur, dur, dur},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			if got := mergeActivationExpiry(c.existing, c.node); got != c.want {
+				t.Fatalf("mergeActivationExpiry(%d,%d) = %d, want %d", c.existing, c.node, got, c.want)
+			}
+		})
+	}
+}
+
+// TestNodeFirstConnectExpiry_NotClobbered reproduces the multi-node bug: a
+// client is attached to inbounds on two nodes with a "start after first connect"
+// expiry. The client connects only on node 1, which activates an absolute
+// deadline; node 2 never sees a connection and keeps reporting the negative
+// duration. The shared per-email client_traffics row must hold the activated
+// deadline — a later node-2 sync must not reset it back to "not started".
+func TestNodeFirstConnectExpiry_NotClobbered(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	createNodeInbound(t, db, 2, "n2-in", 41002)
+	svc := &InboundService{}
+
+	const email = "delayed"
+	const duration = int64(-2592000000) // 30 days, not yet started
+
+	// Both nodes start out reporting the un-activated negative duration.
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true})
+	syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true})
+	if got := readTraffic(t, db, email).ExpiryTime; got != duration {
+		t.Fatalf("before any connection: expiry = %d, want %d", got, duration)
+	}
+
+	// Client connects on node 1: it activates an absolute deadline.
+	const activated = int64(1893456000000) // some absolute ms timestamp
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
+	if got := readTraffic(t, db, email).ExpiryTime; got != activated {
+		t.Fatalf("after node 1 activation: expiry = %d, want %d", got, activated)
+	}
+
+	// Node 2 (no connection there) keeps reporting the negative duration. This
+	// must NOT reset the activated deadline.
+	syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true})
+	if got := readTraffic(t, db, email).ExpiryTime; got != activated {
+		t.Fatalf("node 2 clobbered the activated deadline: expiry = %d, want %d", got, activated)
+	}
+
+	// Subsequent node 1 syncs keep the same absolute deadline.
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, ExpiryTime: activated, Enable: true})
+	if got := readTraffic(t, db, email).ExpiryTime; got != activated {
+		t.Fatalf("after further node 1 sync: expiry = %d, want %d", got, activated)
+	}
+}
+
+// TestNodeFirstConnectExpiry_NotClobbered_WithSettings exercises the full
+// production sync path — snapshots carrying real settings JSON, which drives the
+// GetClients/SyncInbound branch inside setRemoteTrafficLocked — to prove that
+// branch does not re-derive the per-email client_traffics.expiry_time from the
+// node's (still negative) settings and undo the merge guard.
+func TestNodeFirstConnectExpiry_NotClobbered_WithSettings(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "delayed")
+	createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "delayed")
+	svc := &InboundService{}
+
+	const email = "delayed"
+	const duration = int64(-2592000000)
+	const activated = int64(1893456000000)
+
+	negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}`
+	actSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":1893456000000}]}`
+
+	// Both nodes start un-activated.
+	syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
+	syncNodeWithSettings(t, svc, 2, "n2-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
+
+	// Node 1 activates (both its ClientStats and its settings now carry the
+	// absolute deadline, like a real node after adjustTraffics).
+	syncNodeWithSettings(t, svc, 1, "n1-in", actSettings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
+	if got := readTraffic(t, db, email).ExpiryTime; got != activated {
+		t.Fatalf("after node 1 activation: expiry = %d, want %d", got, activated)
+	}
+
+	// Node 2 still reports the negative duration in BOTH ClientStats and
+	// settings. Neither the merge nor SyncInbound may reset the deadline.
+	syncNodeWithSettings(t, svc, 2, "n2-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
+	if got := readTraffic(t, db, email).ExpiryTime; got != activated {
+		t.Fatalf("node 2 settings-sync clobbered the deadline: expiry = %d, want %d", got, activated)
+	}
+}
+
+// TestNodeRenewExtendsExpiry guards against over-correcting: a node that renews
+// a client (traffic reset / auto-renew) legitimately moves the deadline FORWARD
+// to a later absolute timestamp, and that must still propagate to the master.
+// The guard only rejects un-activated (<= 0) values, never a positive one.
+func TestNodeRenewExtendsExpiry(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	svc := &InboundService{}
+
+	const email = "renewing"
+	const first = int64(1893456000000)
+	const renewed = first + int64(2592000000) // +30 days after auto-renew
+
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 10, Down: 10, ExpiryTime: first, Enable: true})
+	if got := readTraffic(t, db, email).ExpiryTime; got != first {
+		t.Fatalf("after activation: expiry = %d, want %d", got, first)
+	}
+
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 20, Down: 20, ExpiryTime: renewed, Enable: true})
+	if got := readTraffic(t, db, email).ExpiryTime; got != renewed {
+		t.Fatalf("node renewal did not propagate: expiry = %d, want %d", got, renewed)
+	}
+}

+ 43 - 0
internal/web/service/node_client_traffic_sum_test.go

@@ -274,6 +274,49 @@ func TestStatsUnderSiblingInbound_KeepsNodeBaseline(t *testing.T) {
 	assertUpDown(t, readTraffic(t, db, email), 70, 70, "delta accrues once baseline survives")
 }
 
+// TestMultiAttach_SameNode_DivergentSiblings reproduces #5274: a client is
+// attached to several inbounds of the SAME node. Xray reports client traffic
+// globally per email, so the node's enriched inbound list copies one shared
+// counter onto every inbound the client is on. When those copies diverge — a
+// legacy per-inbound row surviving the v3.2.x→v3.3.x upgrade, or any drift —
+// the per-inbound delta loop used to treat the lower sibling as a node-counter
+// reset and re-add its full value, inflating the client far past real usage.
+// The merge must collapse the email to its node-wide total and count it once.
+func TestMultiAttach_SameNode_DivergentSiblings(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInboundWithClient(t, db, 1, "n1-a", 41001, "multi")
+	createNodeInboundWithClient(t, db, 1, "n1-b", 41002, "multi")
+	createNodeInboundWithClient(t, db, 1, "n1-c", 41003, "multi")
+	svc := &InboundService{}
+
+	const email = "multi"
+	settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
+
+	// The three inbounds report the same email with diverging values; the
+	// node's true per-email total is the largest (the shared global counter).
+	sync := func(a, b, c int64) {
+		t.Helper()
+		snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
+			{Tag: "n1-a", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: a, Down: a, Enable: true}}},
+			{Tag: "n1-b", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: b, Down: b, Enable: true}}},
+			{Tag: "n1-c", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: c, Down: c, Enable: true}}},
+		}}
+		if _, err := svc.setRemoteTrafficLocked(1, snap, false); err != nil {
+			t.Fatalf("sync: %v", err)
+		}
+	}
+
+	sync(100, 50, 80)
+	assertUpDown(t, readTraffic(t, db, email), 100, 100, "first sync counts the node total once, not the sum")
+
+	sync(150, 60, 90)
+	assertUpDown(t, readTraffic(t, db, email), 150, 150, "second sync: grew by 50, not by every sibling")
+
+	// Equal siblings (the healthy current-schema case) must still accrue once.
+	sync(200, 200, 200)
+	assertUpDown(t, readTraffic(t, db, email), 200, 200, "equal siblings accrue the single increment")
+}
+
 func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
 	db := initTrafficTestDB(t)
 	svc := &InboundService{}

+ 42 - 0
internal/web/service/node_dirty_test.go

@@ -65,6 +65,48 @@ func TestSetRemoteTraffic_DirtyPreservesConfig(t *testing.T) {
 	}
 }
 
+// Deleting a *disabled* client attached to a node inbound must still propagate
+// to the node. The node's own DB carries the (disabled) client, so the central
+// panel has to mark the node dirty (→ reconcile) instead of dropping the delete
+// and letting the next traffic snapshot resurrect the client. Regression for
+// the enable-flag gate that used to skip the node path entirely (#5352).
+func TestDelInboundClientByEmail_DisabledNodeClientMarksDirty(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	// Offline node so nodePushPlan reports dirty without needing a live runtime.
+	node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "offline"}
+	if err := db.Create(node).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	id := node.Id
+
+	central := &model.Inbound{
+		UserId:   1,
+		NodeID:   &id,
+		Tag:      "in-443-tcp",
+		Enable:   true,
+		Port:     443,
+		Protocol: model.VLESS,
+		Settings: `{"clients":[{"email":"a@x","enable":false}]}`,
+	}
+	if err := db.Create(central).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	inboundSvc := &InboundService{}
+	clientSvc := &ClientService{}
+	if _, err := clientSvc.DelInboundClientByEmail(inboundSvc, central.Id, "a@x", false); err != nil {
+		t.Fatalf("DelInboundClientByEmail: %v", err)
+	}
+
+	if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(id); err != nil {
+		t.Fatalf("NodeSyncState: %v", err)
+	} else if !dirty {
+		t.Fatal("deleting a disabled node client must mark the node dirty (#5352)")
+	}
+}
+
 // ClearNodeDirty must be a compare-and-swap on config_dirty_at so a concurrent
 // edit that re-dirties the node during a reconcile is not silently cleared.
 func TestNodeDirty_ClearIsCASOnDirtyAt(t *testing.T) {

+ 49 - 2
internal/web/service/port_conflict.go

@@ -115,8 +115,11 @@ func (d *portConflictDetail) String() string {
 	}
 	if name == "" {
 		name = fmt.Sprintf("#%d", d.InboundID)
-	} else {
+	} else if d.InboundID > 0 {
 		name = fmt.Sprintf("'%s' (#%d)", name, d.InboundID)
+	} else {
+		// reserved/system inbounds (e.g. the Xray API) have no DB id.
+		name = fmt.Sprintf("'%s'", name)
 	}
 	listen := d.Listen
 	if isAnyListen(listen) {
@@ -126,7 +129,52 @@ func (d *portConflictDetail) String() string {
 		d.Port, transportTagSuffix(d.Transports), name, listen)
 }
 
+// defaultXrayAPIPort is the loopback port of the internal Xray API inbound
+// (tag "api") seeded into the config template. Used as a fallback when the
+// template can't be parsed.
+const defaultXrayAPIPort = 62789
+
+// reservedAPIPort returns the port of the internal Xray API inbound declared
+// in the config template, falling back to defaultXrayAPIPort.
+func reservedAPIPort() int {
+	tmpl, err := (&SettingService{}).GetXrayConfigTemplate()
+	if err != nil || tmpl == "" {
+		return defaultXrayAPIPort
+	}
+	var parsed struct {
+		Inbounds []struct {
+			Port int    `json:"port"`
+			Tag  string `json:"tag"`
+		} `json:"inbounds"`
+	}
+	if json.Unmarshal([]byte(tmpl), &parsed) != nil {
+		return defaultXrayAPIPort
+	}
+	for _, in := range parsed.Inbounds {
+		if in.Tag == "api" && in.Port > 0 {
+			return in.Port
+		}
+	}
+	return defaultXrayAPIPort
+}
+
 func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (*portConflictDetail, error) {
+	newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
+
+	// The internal Xray API inbound (tag "api", loopback TCP) isn't a DB row,
+	// so a local user inbound reusing its port would leave Xray binding the
+	// port twice (#5304). Nodes run their own Xray, so this only applies to
+	// the local panel.
+	if inbound.NodeID == nil && inbound.Port == reservedAPIPort() &&
+		newBits&transportTCP != 0 && listenOverlaps("127.0.0.1", inbound.Listen) {
+		return &portConflictDetail{
+			Tag:        "api",
+			Listen:     "127.0.0.1",
+			Port:       inbound.Port,
+			Transports: transportTCP,
+		}, nil
+	}
+
 	db := database.GetDB()
 
 	var candidates []*model.Inbound
@@ -138,7 +186,6 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
 		return nil, err
 	}
 
-	newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
 	for _, c := range candidates {
 		if !sameNode(c.NodeID, inbound.NodeID) {
 			continue

+ 62 - 0
internal/web/service/port_conflict_test.go

@@ -669,3 +669,65 @@ func TestIsAutoGeneratedTag(t *testing.T) {
 		})
 	}
 }
+
+// the internal Xray API inbound (tag "api", loopback TCP) isn't a DB row, so
+// checkPortConflict must still reject a local user inbound that reuses its
+// reserved port — otherwise Xray binds the port twice (#5304).
+func TestCheckPortConflict_ReservedAPIPortBlockedLocal(t *testing.T) {
+	setupConflictDB(t)
+
+	svc := &InboundService{}
+	candidate := &model.Inbound{
+		Tag:            "user-62789",
+		Listen:         "0.0.0.0",
+		Port:           defaultXrayAPIPort,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"tcp"}`,
+	}
+	got, err := svc.checkPortConflict(candidate, 0)
+	if err != nil {
+		t.Fatalf("checkPortConflict: %v", err)
+	}
+	if got == nil {
+		t.Fatalf("local inbound on the reserved API port %d must conflict", defaultXrayAPIPort)
+	}
+	if msg := got.String(); !strings.Contains(msg, "api") {
+		t.Fatalf("conflict message should name the api inbound; got %q", msg)
+	}
+}
+
+// nodes run their own Xray with their own API port, so a node inbound on the
+// central panel's reserved API port must be allowed.
+func TestCheckPortConflict_ReservedAPIPortAllowedOnNode(t *testing.T) {
+	setupConflictDB(t)
+
+	svc := &InboundService{}
+	candidate := &model.Inbound{
+		Tag:            "node-62789",
+		Listen:         "0.0.0.0",
+		Port:           defaultXrayAPIPort,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"tcp"}`,
+		NodeID:         intPtr(1),
+	}
+	if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
+		t.Fatalf("node inbound on the reserved API port must be allowed; got=%v err=%v", got, err)
+	}
+}
+
+// the API inbound is TCP-only, so a UDP-only inbound (e.g. hysteria) may share
+// its port — same tcp/udp coexistence the rest of the checks allow.
+func TestCheckPortConflict_ReservedAPIPortUDPCoexists(t *testing.T) {
+	setupConflictDB(t)
+
+	svc := &InboundService{}
+	candidate := &model.Inbound{
+		Tag:      "hyst-62789",
+		Listen:   "0.0.0.0",
+		Port:     defaultXrayAPIPort,
+		Protocol: model.Hysteria,
+	}
+	if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
+		t.Fatalf("udp-only inbound must coexist with the tcp API inbound; got=%v err=%v", got, err)
+	}
+}

+ 107 - 5
internal/web/service/setting.go

@@ -53,7 +53,6 @@ var defaultValueMap = map[string]string{
 	"tgBotChatId":                 "",
 	"tgRunTime":                   "@daily",
 	"tgBotBackup":                 "false",
-	"tgBotLoginNotify":            "true",
 	"tgCpu":                       "80",
 	"tgLang":                      "en-US",
 	"twoFactorEnable":             "false",
@@ -119,6 +118,20 @@ var defaultValueMap = map[string]string{
 	"ldapDefaultTotalGB":    "0",
 	"ldapDefaultExpiryDays": "0",
 	"ldapDefaultLimitIP":    "0",
+
+	// Event bus — per-subscriber event filtering (empty = all disabled)
+	"tgEnabledEvents":   "login.attempt,cpu.high",
+	"smtpEnabledEvents": "login.attempt,cpu.high",
+	"smtpCpu":           "80",
+
+	// Email (SMTP) notifications
+	"smtpEnable":         "false",
+	"smtpHost":           "",
+	"smtpPort":           "587",
+	"smtpUsername":       "",
+	"smtpPassword":       "",
+	"smtpTo":             "",
+	"smtpEncryptionType": "starttls", // no, starttls, tls
 }
 
 // SettingService provides business logic for application settings management.
@@ -220,6 +233,7 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
 	view.HasLdapPassword = secretConfigured(allSetting.LdapPassword)
 	view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
 	view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
+	view.HasSmtpPassword = secretConfigured(allSetting.SmtpPassword)
 	var apiTokenCount int64
 	if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
 		view.HasApiToken = apiTokenCount > 0
@@ -227,6 +241,7 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
 	view.TgBotToken = ""
 	view.TwoFactorToken = ""
 	view.LdapPassword = ""
+	view.SmtpPassword = ""
 	return view, nil
 }
 
@@ -504,10 +519,6 @@ func (s *SettingService) GetTgBotBackup() (bool, error) {
 	return s.getBool("tgBotBackup")
 }
 
-func (s *SettingService) GetTgBotLoginNotify() (bool, error) {
-	return s.getBool("tgBotLoginNotify")
-}
-
 func (s *SettingService) GetTgCpu() (int, error) {
 	return s.getInt("tgCpu")
 }
@@ -918,6 +929,90 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
 	return s.getInt("ldapDefaultLimitIP")
 }
 
+// Event bus — per-subscriber event filtering
+
+func (s *SettingService) GetTgEnabledEvents() (string, error) {
+	return s.getString("tgEnabledEvents")
+}
+
+func (s *SettingService) SetTgEnabledEvents(events string) error {
+	return s.setString("tgEnabledEvents", events)
+}
+
+func (s *SettingService) GetSmtpEnabledEvents() (string, error) {
+	return s.getString("smtpEnabledEvents")
+}
+
+func (s *SettingService) SetSmtpEnabledEvents(events string) error {
+	return s.setString("smtpEnabledEvents", events)
+}
+
+// Email (SMTP) settings
+
+func (s *SettingService) GetSmtpEnable() (bool, error) {
+	return s.getBool("smtpEnable")
+}
+
+func (s *SettingService) SetSmtpEnable(value bool) error {
+	return s.setBool("smtpEnable", value)
+}
+
+func (s *SettingService) GetSmtpHost() (string, error) {
+	return s.getString("smtpHost")
+}
+
+func (s *SettingService) SetSmtpHost(value string) error {
+	return s.setString("smtpHost", value)
+}
+
+func (s *SettingService) GetSmtpPort() (int, error) {
+	return s.getInt("smtpPort")
+}
+
+func (s *SettingService) SetSmtpPort(value int) error {
+	return s.setInt("smtpPort", value)
+}
+
+func (s *SettingService) GetSmtpUsername() (string, error) {
+	return s.getString("smtpUsername")
+}
+
+func (s *SettingService) SetSmtpUsername(value string) error {
+	return s.setString("smtpUsername", value)
+}
+
+func (s *SettingService) GetSmtpPassword() (string, error) {
+	return s.getString("smtpPassword")
+}
+
+func (s *SettingService) SetSmtpPassword(value string) error {
+	return s.setString("smtpPassword", value)
+}
+
+func (s *SettingService) GetSmtpTo() (string, error) {
+	return s.getString("smtpTo")
+}
+
+func (s *SettingService) SetSmtpTo(value string) error {
+	return s.setString("smtpTo", value)
+}
+
+func (s *SettingService) GetSmtpEncryptionType() (string, error) {
+	return s.getString("smtpEncryptionType")
+}
+
+func (s *SettingService) SetSmtpEncryptionType(value string) error {
+	return s.setString("smtpEncryptionType", value)
+}
+
+func (s *SettingService) GetSmtpCpu() (int, error) {
+	return s.getInt("smtpCpu")
+}
+
+func (s *SettingService) SetSmtpCpu(value int) error {
+	return s.setInt("smtpCpu", value)
+}
+
 func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
 	if err := s.preserveRedactedSecrets(allSetting); err != nil {
 		return err
@@ -967,6 +1062,13 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting)
 		}
 		allSetting.TwoFactorToken = value
 	}
+	if strings.TrimSpace(allSetting.SmtpPassword) == "" {
+		value, err := s.GetSmtpPassword()
+		if err != nil {
+			return err
+		}
+		allSetting.SmtpPassword = value
+	}
 	return nil
 }
 

+ 11 - 2
internal/web/service/setting_security_test.go

@@ -32,6 +32,9 @@ func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
 	if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
 		t.Fatal(err)
 	}
+	if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
+		t.Fatal(err)
+	}
 	if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil {
 		t.Fatal(err)
 	}
@@ -40,10 +43,10 @@ func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" {
+	if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" || view.SmtpPassword != "" {
 		t.Fatalf("settings view leaked secrets: %#v", view)
 	}
-	if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken {
+	if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken || !view.HasSmtpPassword {
 		t.Fatalf("settings view did not report configured secret flags: %#v", view)
 	}
 }
@@ -63,6 +66,9 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
 	if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil {
 		t.Fatal(err)
 	}
+	if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
+		t.Fatal(err)
+	}
 
 	view, err := s.GetAllSettingView()
 	if err != nil {
@@ -81,6 +87,9 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
 	if got, _ := s.GetTwoFactorToken(); got != "totp-secret" {
 		t.Fatalf("2fa token = %q, want preserved secret", got)
 	}
+	if got, _ := s.GetSmtpPassword(); got != "smtp-secret" {
+		t.Fatalf("smtp password = %q, want preserved secret", got)
+	}
 }
 
 func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {

+ 4 - 0
internal/web/service/tgbot/tgbot.go

@@ -15,6 +15,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/global"
@@ -43,6 +44,9 @@ var (
 	hostname    string
 	hashStorage *global.HashStorage
 
+	// EventBus is set from web layer to publish login/security events.
+	EventBus *eventbus.Bus
+
 	// Performance improvements
 	messageWorkerPool   chan struct{} // Semaphore for limiting concurrent message processing
 	optimizedHTTPClient *http.Client  // HTTP client with connection pooling and timeouts

+ 150 - 0
internal/web/service/tgbot/tgbot_event.go

@@ -0,0 +1,150 @@
+package tgbot
+
+import (
+	"fmt"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
+)
+
+var cachedHostname string
+
+func getHostname() string {
+	if cachedHostname != "" {
+		return cachedHostname
+	}
+	h, err := os.Hostname()
+	if err != nil {
+		cachedHostname = "unknown"
+	} else {
+		cachedHostname = h
+	}
+	return cachedHostname
+}
+
+var tgEventLimiter = eventbus.NewRateLimiter(1 * time.Minute)
+
+// HandleEvent is the eventbus subscriber callback. It formats incoming events
+// as Telegram messages and sends them to all admin chats.
+func (t *Tgbot) HandleEvent(e eventbus.Event) {
+	if !t.isEventEnabled(e.Type) {
+		return
+	}
+	if e.Type != eventbus.EventLoginAttempt {
+		if !tgEventLimiter.Allow(e.Type, e.Source) {
+			return
+		}
+	}
+	msg := t.formatEventMessage(e)
+	if msg != "" {
+		t.SendMsgToTgbotAdmins(msg)
+	}
+}
+
+func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool {
+	events, err := t.settingService.GetTgEnabledEvents()
+	if err != nil || events == "" {
+		return false
+	}
+	for _, e := range strings.Split(events, ",") {
+		if strings.TrimSpace(e) == string(eventType) {
+			return true
+		}
+	}
+	return false
+}
+
+func (t *Tgbot) formatEventMessage(e eventbus.Event) string {
+	host := getHostname()
+	header := fmt.Sprintf("<b>📡 %s</b>\n", host)
+
+	switch e.Type {
+	case eventbus.EventOutboundDown:
+		msg := header + t.I18nBot("tgbot.messages.eventOutboundDown",
+			"Tag=="+e.Source)
+		if data, ok := e.Data.(*eventbus.OutboundHealthData); ok {
+			if data.Error != "" {
+				msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail",
+					"Error=="+data.Error)
+			}
+			if data.Delay > 0 {
+				msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail",
+					"Delay=="+fmt.Sprintf("%d", data.Delay))
+			}
+		}
+		return msg
+
+	case eventbus.EventOutboundUp:
+		msg := header + t.I18nBot("tgbot.messages.eventOutboundUp",
+			"Tag=="+e.Source)
+		if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 {
+			msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail",
+				"Delay=="+fmt.Sprintf("%d", data.Delay))
+		}
+		return msg
+
+	case eventbus.EventXrayCrash:
+		errStr := ""
+		if e.Data != nil {
+			errStr = fmt.Sprint(e.Data)
+		}
+		msg := header + "🔥 " + t.I18nBot("tgbot.messages.eventXrayCrash")
+		if errStr != "" {
+			msg += "\n" + t.I18nBot("tgbot.messages.eventXrayCrashError", "Error=="+errStr)
+		}
+		return msg
+
+	case eventbus.EventNodeDown:
+		msg := header + "🔴 " + t.I18nBot("tgbot.messages.eventNodeDown", "Name=="+e.Source)
+		if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" {
+			msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail", "Error=="+data.XrayError)
+		}
+		return msg
+
+	case eventbus.EventNodeUp:
+		msg := header + "🟢 " + t.I18nBot("tgbot.messages.eventNodeUp", "Name=="+e.Source)
+		if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.LatencyMs > 0 {
+			msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", "Delay=="+fmt.Sprintf("%d", data.LatencyMs))
+		}
+		return msg
+
+	case eventbus.EventCPUHigh:
+		if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
+			tgCpu, err := t.settingService.GetTgCpu()
+			if err != nil || tgCpu <= 0 || data.Percent <= float64(tgCpu) {
+				return ""
+			}
+			return header + "🔴 " + t.I18nBot("tgbot.messages.cpuThreshold",
+				"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
+				"Threshold=="+strconv.Itoa(tgCpu))
+		}
+		return ""
+
+	case eventbus.EventLoginAttempt:
+		if data, ok := e.Data.(*eventbus.LoginEventData); ok {
+			if data.Status == "success" {
+				msg := t.I18nBot("tgbot.messages.loginSuccess")
+				msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host)
+				msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username)
+				msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP)
+				msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time)
+				return msg
+			}
+			msg := t.I18nBot("tgbot.messages.loginFailed")
+			msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host)
+			if data.Reason != "" {
+				msg += t.I18nBot("tgbot.messages.reason", "Reason=="+data.Reason)
+			}
+			msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username)
+			msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP)
+			msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time)
+			return msg
+		}
+		return header + t.I18nBot("tgbot.messages.eventLoginFallback", "Source=="+e.Source)
+	}
+
+	return ""
+}

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio