7 Commity b11ceac18e ... 129f50d92a

Autor SHA1 Wiadomość Data
  Tomi lla 129f50d92a feat(sub): auto-detect subscription format by User-Agent (Updated) (#5826) 12 godzin temu
  H-TTTTT f2b17397f4 fix(frontend): stabilize speed tags on inbound and client pages (#5930) 12 godzin temu
  Sangeeth Thilakarathna 658e6ab3d3 feat(frontend): show client comments on mobile cards (#5942) 12 godzin temu
  Yuri Khachaturyan 1cfd7b49b0 fix(email): build an RFC 5322 message with a proper From address and name (#5941) 12 godzin temu
  Matt Van Horn ae0da4c51f fix: stop forcing port 53 on DoH/DoQ DNS server entries (#5950) 12 godzin temu
  Sangeeth Thilakarathna 65b5074b60 fix(script): remove release download time limit (#5952) 12 godzin temu
  Mikhail Grigorev b18c87dc4b fix(script): Remove old mtg binary (#5955) 12 godzin temu
59 zmienionych plików z 2129 dodań i 125 usunięć
  1. 103 0
      frontend/public/openapi.json
  2. 14 0
      frontend/src/components/clients/ClientCardComment.tsx
  3. 8 2
      frontend/src/components/clients/ClientSpeedTag.tsx
  4. 63 0
      frontend/src/components/form/GoRegexInput.tsx
  5. 1 0
      frontend/src/components/form/index.ts
  6. 21 0
      frontend/src/components/utility/speedTagStyle.ts
  7. 14 0
      frontend/src/generated/examples.ts
  8. 56 0
      frontend/src/generated/schemas.ts
  9. 14 0
      frontend/src/generated/types.ts
  10. 14 0
      frontend/src/generated/zod.ts
  11. 7 0
      frontend/src/models/setting.ts
  12. 9 2
      frontend/src/pages/api-docs/endpoints.ts
  13. 11 0
      frontend/src/pages/clients/ClientsPage.css
  14. 9 4
      frontend/src/pages/clients/ClientsPage.tsx
  15. 8 2
      frontend/src/pages/inbounds/list/InboundSpeedTag.tsx
  16. 4 3
      frontend/src/pages/inbounds/list/useInboundColumns.tsx
  17. 10 0
      frontend/src/pages/settings/EmailTab.tsx
  18. 15 0
      frontend/src/pages/settings/SubscriptionFormatsTab.css
  19. 70 6
      frontend/src/pages/settings/SubscriptionFormatsTab.tsx
  20. 17 1
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  21. 2 1
      frontend/src/pages/xray/dns/DnsServerModal.tsx
  22. 11 2
      frontend/src/schemas/dns.ts
  23. 5 0
      frontend/src/schemas/setting.ts
  24. 0 1
      frontend/src/test/__snapshots__/dns.test.ts.snap
  25. 27 0
      frontend/src/test/client-card-comment.test.tsx
  26. 42 0
      frontend/src/test/dns.test.ts
  27. 84 0
      frontend/src/test/speed-tag-stable.test.tsx
  28. 29 0
      frontend/src/test/subscription-general-tab.test.tsx
  29. 2 2
      install.sh
  30. 273 55
      internal/sub/controller.go
  31. 329 0
      internal/sub/controller_test.go
  32. 19 1
      internal/sub/external_only_sub_test.go
  33. 2 2
      internal/sub/host_sub_test.go
  34. 2 3
      internal/sub/json_service.go
  35. 54 5
      internal/sub/sub.go
  36. 1 1
      internal/sub/sub_scale_test.go
  37. 1 1
      internal/sub/vless_route_sub_test.go
  38. 19 0
      internal/web/controller/setting.go
  39. 46 0
      internal/web/controller/setting_test.go
  40. 29 0
      internal/web/entity/check_valid_test.go
  41. 14 0
      internal/web/entity/entity.go
  42. 48 15
      internal/web/service/email/email.go
  43. 81 0
      internal/web/service/email/email_test.go
  44. 97 1
      internal/web/service/setting.go
  45. 207 0
      internal/web/service/setting_clash_test.go
  46. 18 1
      internal/web/translation/ar-EG.json
  47. 18 1
      internal/web/translation/en-US.json
  48. 18 1
      internal/web/translation/es-ES.json
  49. 18 1
      internal/web/translation/fa-IR.json
  50. 18 1
      internal/web/translation/id-ID.json
  51. 18 1
      internal/web/translation/ja-JP.json
  52. 18 1
      internal/web/translation/pt-BR.json
  53. 18 1
      internal/web/translation/ru-RU.json
  54. 18 1
      internal/web/translation/tr-TR.json
  55. 18 1
      internal/web/translation/uk-UA.json
  56. 18 1
      internal/web/translation/vi-VN.json
  57. 18 1
      internal/web/translation/zh-CN.json
  58. 18 1
      internal/web/translation/zh-TW.json
  59. 3 2
      update.sh

+ 103 - 0
frontend/public/openapi.json

@@ -142,6 +142,12 @@
           "smtpEncryptionType": {
             "type": "string"
           },
+          "smtpFrom": {
+            "type": "string"
+          },
+          "smtpFromName": {
+            "type": "string"
+          },
           "smtpHost": {
             "type": "string"
           },
@@ -170,6 +176,9 @@
           "subCertFile": {
             "type": "string"
           },
+          "subClashAutoDetect": {
+            "type": "boolean"
+          },
           "subClashEnable": {
             "type": "boolean"
           },
@@ -185,6 +194,9 @@
           "subClashURI": {
             "type": "string"
           },
+          "subClashUserAgentRegex": {
+            "type": "string"
+          },
           "subDomain": {
             "type": "string"
           },
@@ -206,6 +218,12 @@
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subJsonAlwaysArray": {
+            "type": "boolean"
+          },
+          "subJsonAutoDetect": {
+            "type": "boolean"
+          },
           "subJsonEnable": {
             "type": "boolean"
           },
@@ -224,6 +242,9 @@
           "subJsonURI": {
             "type": "string"
           },
+          "subJsonUserAgentRegex": {
+            "type": "string"
+          },
           "subKeyFile": {
             "type": "string"
           },
@@ -375,6 +396,8 @@
           "smtpEnable",
           "smtpEnabledEvents",
           "smtpEncryptionType",
+          "smtpFrom",
+          "smtpFromName",
           "smtpHost",
           "smtpMemory",
           "smtpPassword",
@@ -383,11 +406,13 @@
           "smtpUsername",
           "subAnnounce",
           "subCertFile",
+          "subClashAutoDetect",
           "subClashEnable",
           "subClashEnableRouting",
           "subClashPath",
           "subClashRules",
           "subClashURI",
+          "subClashUserAgentRegex",
           "subDomain",
           "subEnable",
           "subEnableRouting",
@@ -395,12 +420,15 @@
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
+          "subJsonAlwaysArray",
+          "subJsonAutoDetect",
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonMux",
           "subJsonPath",
           "subJsonRules",
           "subJsonURI",
+          "subJsonUserAgentRegex",
           "subKeyFile",
           "subListen",
           "subPath",
@@ -575,6 +603,12 @@
           "smtpEncryptionType": {
             "type": "string"
           },
+          "smtpFrom": {
+            "type": "string"
+          },
+          "smtpFromName": {
+            "type": "string"
+          },
           "smtpHost": {
             "type": "string"
           },
@@ -603,6 +637,9 @@
           "subCertFile": {
             "type": "string"
           },
+          "subClashAutoDetect": {
+            "type": "boolean"
+          },
           "subClashEnable": {
             "type": "boolean"
           },
@@ -618,6 +655,9 @@
           "subClashURI": {
             "type": "string"
           },
+          "subClashUserAgentRegex": {
+            "type": "string"
+          },
           "subDomain": {
             "type": "string"
           },
@@ -639,6 +679,12 @@
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subJsonAlwaysArray": {
+            "type": "boolean"
+          },
+          "subJsonAutoDetect": {
+            "type": "boolean"
+          },
           "subJsonEnable": {
             "type": "boolean"
           },
@@ -657,6 +703,9 @@
           "subJsonURI": {
             "type": "string"
           },
+          "subJsonUserAgentRegex": {
+            "type": "string"
+          },
           "subKeyFile": {
             "type": "string"
           },
@@ -815,6 +864,8 @@
           "smtpEnable",
           "smtpEnabledEvents",
           "smtpEncryptionType",
+          "smtpFrom",
+          "smtpFromName",
           "smtpHost",
           "smtpMemory",
           "smtpPassword",
@@ -823,11 +874,13 @@
           "smtpUsername",
           "subAnnounce",
           "subCertFile",
+          "subClashAutoDetect",
           "subClashEnable",
           "subClashEnableRouting",
           "subClashPath",
           "subClashRules",
           "subClashURI",
+          "subClashUserAgentRegex",
           "subDomain",
           "subEnable",
           "subEnableRouting",
@@ -835,12 +888,15 @@
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
+          "subJsonAlwaysArray",
+          "subJsonAutoDetect",
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonMux",
           "subJsonPath",
           "subJsonRules",
           "subJsonURI",
+          "subJsonUserAgentRegex",
           "subKeyFile",
           "subListen",
           "subPath",
@@ -9247,6 +9303,53 @@
         }
       }
     },
+    "/panel/api/setting/validateRegex": {
+      "post": {
+        "tags": [
+          "Settings"
+        ],
+        "summary": "Validate any regular expression with the backend Go RE2 compiler without saving it.",
+        "operationId": "post_panel_api_setting_validateRegex",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "regex": "(?m)^general-purpose$"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "msg": ""
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/setting/updateUser": {
       "post": {
         "tags": [

+ 14 - 0
frontend/src/components/clients/ClientCardComment.tsx

@@ -0,0 +1,14 @@
+type ClientCardCommentProps = {
+  comment?: string;
+  className?: string;
+};
+
+export default function ClientCardComment({ comment, className = 'client-card-comment' }: ClientCardCommentProps) {
+  if (!comment) return null;
+
+  return (
+    <span className={className} title={comment}>
+      {comment}
+    </span>
+  );
+}

+ 8 - 2
frontend/src/components/clients/ClientSpeedTag.tsx

@@ -2,6 +2,7 @@ import { Tag } from 'antd';
 
 import { SizeFormatter } from '@/utils';
 import type { ClientSpeedEntry } from '@/hooks/useClients';
+import { SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
 
 export type { ClientSpeedEntry };
 
@@ -11,11 +12,16 @@ export function isActiveSpeed(speed?: ClientSpeedEntry): speed is ClientSpeedEnt
 
 interface ClientSpeedTagProps {
   speed: ClientSpeedEntry;
+  tableCell?: boolean;
 }
 
-export function ClientSpeedTag({ speed }: ClientSpeedTagProps) {
+export function ClientSpeedTag({ speed, tableCell = false }: ClientSpeedTagProps) {
   return (
-    <Tag color="blue">
+    <Tag
+      color="blue"
+      className={tableCell ? SPEED_TAG_CLASS_NAME : undefined}
+      style={tableCell ? SPEED_TAG_STYLE : undefined}
+    >
       ↑ {SizeFormatter.speedFormat(speed.up)}
       {' / '}
       ↓ {SizeFormatter.speedFormat(speed.down)}

+ 63 - 0
frontend/src/components/form/GoRegexInput.tsx

@@ -0,0 +1,63 @@
+import { useRef, useState } from 'react';
+import { Input, Typography } from 'antd';
+
+import { HttpUtil } from '@/utils';
+
+interface GoRegexInputProps {
+  value: string;
+  ariaLabel?: string;
+  placeholder?: string;
+  maxLength?: number;
+  onChange: (value: string) => void;
+  externalError?: string;
+}
+
+export async function validateGoRegex(value: string): Promise<string> {
+  const result = await HttpUtil.post(
+    '/panel/api/setting/validateRegex',
+    { regex: value },
+    { silent: true },
+  );
+  return result.success ? '' : result.msg || 'Invalid Go RE2 regular expression';
+}
+
+export default function GoRegexInput({
+  value,
+  ariaLabel,
+  placeholder,
+  maxLength = 2048,
+  onChange,
+  externalError,
+}: GoRegexInputProps) {
+  const [error, setError] = useState('');
+  const validationSequence = useRef(0);
+
+  async function validate() {
+    const sequence = ++validationSequence.current;
+    const nextError = await validateGoRegex(value);
+    if (sequence === validationSequence.current) {
+      setError(nextError);
+    }
+  }
+
+  const displayError = externalError ?? error;
+
+  return (
+    <div style={{ width: '100%' }}>
+      <Input
+        value={value}
+        aria-label={ariaLabel}
+        placeholder={placeholder}
+        maxLength={maxLength}
+        status={displayError ? 'error' : undefined}
+        onChange={(event) => {
+          validationSequence.current += 1;
+          setError('');
+          onChange(event.target.value);
+        }}
+        onBlur={() => void validate()}
+      />
+      {displayError && <Typography.Text type="danger">{displayError}</Typography.Text>}
+    </div>
+  );
+}

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

@@ -1,6 +1,7 @@
 export { default as DateTimePicker } from './DateTimePicker';
 export { default as JsonEditor } from './JsonEditor';
 export { default as HeaderMapEditor } from './HeaderMapEditor';
+export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
 export { default as SelectAllClearButtons } from './SelectAllClearButtons';
 export { default as RemarkTemplateField } from './RemarkTemplateField';
 export { default as RemarkVarPicker } from './RemarkVarPicker';

+ 21 - 0
frontend/src/components/utility/speedTagStyle.ts

@@ -0,0 +1,21 @@
+import type { CSSProperties } from 'react';
+
+export const SPEED_TAG_CLASS_NAME = 'table-speed-tag' as const;
+export const SPEED_TAG_WIDTH = 200 as const;
+export const SPEED_TABLE_CELL_INLINE_PADDING = 8 as const;
+
+export const SPEED_TAG_STYLE = {
+  width: SPEED_TAG_WIDTH,
+  display: 'inline-flex',
+  alignItems: 'center',
+  justifyContent: 'center',
+  textAlign: 'center',
+  whiteSpace: 'nowrap',
+  fontVariantNumeric: 'tabular-nums',
+  marginInlineEnd: 0,
+  boxSizing: 'border-box',
+  overflow: 'hidden',
+  textOverflow: 'ellipsis',
+} as const satisfies CSSProperties;
+
+export const SPEED_COLUMN_WIDTH = SPEED_TAG_WIDTH + SPEED_TABLE_CELL_INLINE_PADDING * 2;

+ 14 - 0
frontend/src/generated/examples.ts

@@ -35,6 +35,8 @@ export const EXAMPLES: Record<string, unknown> = {
     "smtpEnable": false,
     "smtpEnabledEvents": "",
     "smtpEncryptionType": "",
+    "smtpFrom": "",
+    "smtpFromName": "",
     "smtpHost": "",
     "smtpMemory": 0,
     "smtpPassword": "",
@@ -43,11 +45,13 @@ export const EXAMPLES: Record<string, unknown> = {
     "smtpUsername": "",
     "subAnnounce": "",
     "subCertFile": "",
+    "subClashAutoDetect": false,
     "subClashEnable": false,
     "subClashEnableRouting": false,
     "subClashPath": "",
     "subClashRules": "",
     "subClashURI": "",
+    "subClashUserAgentRegex": "",
     "subDomain": "",
     "subEnable": false,
     "subEnableRouting": false,
@@ -55,12 +59,15 @@ export const EXAMPLES: Record<string, unknown> = {
     "subHideSettings": false,
     "subIncyEnableRouting": false,
     "subIncyRoutingRules": "",
+    "subJsonAlwaysArray": false,
+    "subJsonAutoDetect": false,
     "subJsonEnable": false,
     "subJsonFinalMask": "",
     "subJsonMux": "",
     "subJsonPath": "",
     "subJsonRules": "",
     "subJsonURI": "",
+    "subJsonUserAgentRegex": "",
     "subKeyFile": "",
     "subListen": "",
     "subPath": "",
@@ -138,6 +145,8 @@ export const EXAMPLES: Record<string, unknown> = {
     "smtpEnable": false,
     "smtpEnabledEvents": "",
     "smtpEncryptionType": "",
+    "smtpFrom": "",
+    "smtpFromName": "",
     "smtpHost": "",
     "smtpMemory": 0,
     "smtpPassword": "",
@@ -146,11 +155,13 @@ export const EXAMPLES: Record<string, unknown> = {
     "smtpUsername": "",
     "subAnnounce": "",
     "subCertFile": "",
+    "subClashAutoDetect": false,
     "subClashEnable": false,
     "subClashEnableRouting": false,
     "subClashPath": "",
     "subClashRules": "",
     "subClashURI": "",
+    "subClashUserAgentRegex": "",
     "subDomain": "",
     "subEnable": false,
     "subEnableRouting": false,
@@ -158,12 +169,15 @@ export const EXAMPLES: Record<string, unknown> = {
     "subHideSettings": false,
     "subIncyEnableRouting": false,
     "subIncyRoutingRules": "",
+    "subJsonAlwaysArray": false,
+    "subJsonAutoDetect": false,
     "subJsonEnable": false,
     "subJsonFinalMask": "",
     "subJsonMux": "",
     "subJsonPath": "",
     "subJsonRules": "",
     "subJsonURI": "",
+    "subJsonUserAgentRegex": "",
     "subKeyFile": "",
     "subListen": "",
     "subPath": "",

+ 56 - 0
frontend/src/generated/schemas.ts

@@ -116,6 +116,12 @@ export const SCHEMAS: Record<string, unknown> = {
       "smtpEncryptionType": {
         "type": "string"
       },
+      "smtpFrom": {
+        "type": "string"
+      },
+      "smtpFromName": {
+        "type": "string"
+      },
       "smtpHost": {
         "type": "string"
       },
@@ -144,6 +150,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subCertFile": {
         "type": "string"
       },
+      "subClashAutoDetect": {
+        "type": "boolean"
+      },
       "subClashEnable": {
         "type": "boolean"
       },
@@ -159,6 +168,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subClashURI": {
         "type": "string"
       },
+      "subClashUserAgentRegex": {
+        "type": "string"
+      },
       "subDomain": {
         "type": "string"
       },
@@ -180,6 +192,12 @@ export const SCHEMAS: Record<string, unknown> = {
       "subIncyRoutingRules": {
         "type": "string"
       },
+      "subJsonAlwaysArray": {
+        "type": "boolean"
+      },
+      "subJsonAutoDetect": {
+        "type": "boolean"
+      },
       "subJsonEnable": {
         "type": "boolean"
       },
@@ -198,6 +216,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonURI": {
         "type": "string"
       },
+      "subJsonUserAgentRegex": {
+        "type": "string"
+      },
       "subKeyFile": {
         "type": "string"
       },
@@ -349,6 +370,8 @@ export const SCHEMAS: Record<string, unknown> = {
       "smtpEnable",
       "smtpEnabledEvents",
       "smtpEncryptionType",
+      "smtpFrom",
+      "smtpFromName",
       "smtpHost",
       "smtpMemory",
       "smtpPassword",
@@ -357,11 +380,13 @@ export const SCHEMAS: Record<string, unknown> = {
       "smtpUsername",
       "subAnnounce",
       "subCertFile",
+      "subClashAutoDetect",
       "subClashEnable",
       "subClashEnableRouting",
       "subClashPath",
       "subClashRules",
       "subClashURI",
+      "subClashUserAgentRegex",
       "subDomain",
       "subEnable",
       "subEnableRouting",
@@ -369,12 +394,15 @@ export const SCHEMAS: Record<string, unknown> = {
       "subHideSettings",
       "subIncyEnableRouting",
       "subIncyRoutingRules",
+      "subJsonAlwaysArray",
+      "subJsonAutoDetect",
       "subJsonEnable",
       "subJsonFinalMask",
       "subJsonMux",
       "subJsonPath",
       "subJsonRules",
       "subJsonURI",
+      "subJsonUserAgentRegex",
       "subKeyFile",
       "subListen",
       "subPath",
@@ -549,6 +577,12 @@ export const SCHEMAS: Record<string, unknown> = {
       "smtpEncryptionType": {
         "type": "string"
       },
+      "smtpFrom": {
+        "type": "string"
+      },
+      "smtpFromName": {
+        "type": "string"
+      },
       "smtpHost": {
         "type": "string"
       },
@@ -577,6 +611,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subCertFile": {
         "type": "string"
       },
+      "subClashAutoDetect": {
+        "type": "boolean"
+      },
       "subClashEnable": {
         "type": "boolean"
       },
@@ -592,6 +629,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subClashURI": {
         "type": "string"
       },
+      "subClashUserAgentRegex": {
+        "type": "string"
+      },
       "subDomain": {
         "type": "string"
       },
@@ -613,6 +653,12 @@ export const SCHEMAS: Record<string, unknown> = {
       "subIncyRoutingRules": {
         "type": "string"
       },
+      "subJsonAlwaysArray": {
+        "type": "boolean"
+      },
+      "subJsonAutoDetect": {
+        "type": "boolean"
+      },
       "subJsonEnable": {
         "type": "boolean"
       },
@@ -631,6 +677,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonURI": {
         "type": "string"
       },
+      "subJsonUserAgentRegex": {
+        "type": "string"
+      },
       "subKeyFile": {
         "type": "string"
       },
@@ -789,6 +838,8 @@ export const SCHEMAS: Record<string, unknown> = {
       "smtpEnable",
       "smtpEnabledEvents",
       "smtpEncryptionType",
+      "smtpFrom",
+      "smtpFromName",
       "smtpHost",
       "smtpMemory",
       "smtpPassword",
@@ -797,11 +848,13 @@ export const SCHEMAS: Record<string, unknown> = {
       "smtpUsername",
       "subAnnounce",
       "subCertFile",
+      "subClashAutoDetect",
       "subClashEnable",
       "subClashEnableRouting",
       "subClashPath",
       "subClashRules",
       "subClashURI",
+      "subClashUserAgentRegex",
       "subDomain",
       "subEnable",
       "subEnableRouting",
@@ -809,12 +862,15 @@ export const SCHEMAS: Record<string, unknown> = {
       "subHideSettings",
       "subIncyEnableRouting",
       "subIncyRoutingRules",
+      "subJsonAlwaysArray",
+      "subJsonAutoDetect",
       "subJsonEnable",
       "subJsonFinalMask",
       "subJsonMux",
       "subJsonPath",
       "subJsonRules",
       "subJsonURI",
+      "subJsonUserAgentRegex",
       "subKeyFile",
       "subListen",
       "subPath",

+ 14 - 0
frontend/src/generated/types.ts

@@ -41,6 +41,8 @@ export interface AllSetting {
   smtpEnable: boolean;
   smtpEnabledEvents: string;
   smtpEncryptionType: string;
+  smtpFrom: string;
+  smtpFromName: string;
   smtpHost: string;
   smtpMemory: number;
   smtpPassword: string;
@@ -49,11 +51,13 @@ export interface AllSetting {
   smtpUsername: string;
   subAnnounce: string;
   subCertFile: string;
+  subClashAutoDetect: boolean;
   subClashEnable: boolean;
   subClashEnableRouting: boolean;
   subClashPath: string;
   subClashRules: string;
   subClashURI: string;
+  subClashUserAgentRegex: string;
   subDomain: string;
   subEnable: boolean;
   subEnableRouting: boolean;
@@ -61,12 +65,15 @@ export interface AllSetting {
   subHideSettings: boolean;
   subIncyEnableRouting: boolean;
   subIncyRoutingRules: string;
+  subJsonAlwaysArray: boolean;
+  subJsonAutoDetect: boolean;
   subJsonEnable: boolean;
   subJsonFinalMask: string;
   subJsonMux: string;
   subJsonPath: string;
   subJsonRules: string;
   subJsonURI: string;
+  subJsonUserAgentRegex: string;
   subKeyFile: string;
   subListen: string;
   subPath: string;
@@ -145,6 +152,8 @@ export interface AllSettingView {
   smtpEnable: boolean;
   smtpEnabledEvents: string;
   smtpEncryptionType: string;
+  smtpFrom: string;
+  smtpFromName: string;
   smtpHost: string;
   smtpMemory: number;
   smtpPassword: string;
@@ -153,11 +162,13 @@ export interface AllSettingView {
   smtpUsername: string;
   subAnnounce: string;
   subCertFile: string;
+  subClashAutoDetect: boolean;
   subClashEnable: boolean;
   subClashEnableRouting: boolean;
   subClashPath: string;
   subClashRules: string;
   subClashURI: string;
+  subClashUserAgentRegex: string;
   subDomain: string;
   subEnable: boolean;
   subEnableRouting: boolean;
@@ -165,12 +176,15 @@ export interface AllSettingView {
   subHideSettings: boolean;
   subIncyEnableRouting: boolean;
   subIncyRoutingRules: string;
+  subJsonAlwaysArray: boolean;
+  subJsonAutoDetect: boolean;
   subJsonEnable: boolean;
   subJsonFinalMask: string;
   subJsonMux: string;
   subJsonPath: string;
   subJsonRules: string;
   subJsonURI: string;
+  subJsonUserAgentRegex: string;
   subKeyFile: string;
   subListen: string;
   subPath: string;

+ 14 - 0
frontend/src/generated/zod.ts

@@ -53,6 +53,8 @@ export const AllSettingSchema = z.object({
   smtpEnable: z.boolean(),
   smtpEnabledEvents: z.string(),
   smtpEncryptionType: z.string(),
+  smtpFrom: z.string(),
+  smtpFromName: z.string(),
   smtpHost: z.string(),
   smtpMemory: z.number().int().min(0).max(100),
   smtpPassword: z.string(),
@@ -61,11 +63,13 @@ export const AllSettingSchema = z.object({
   smtpUsername: z.string(),
   subAnnounce: z.string(),
   subCertFile: z.string(),
+  subClashAutoDetect: z.boolean(),
   subClashEnable: z.boolean(),
   subClashEnableRouting: z.boolean(),
   subClashPath: z.string(),
   subClashRules: z.string(),
   subClashURI: z.string(),
+  subClashUserAgentRegex: z.string(),
   subDomain: z.string(),
   subEnable: z.boolean(),
   subEnableRouting: z.boolean(),
@@ -73,12 +77,15 @@ export const AllSettingSchema = z.object({
   subHideSettings: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyRoutingRules: z.string(),
+  subJsonAlwaysArray: z.boolean(),
+  subJsonAutoDetect: z.boolean(),
   subJsonEnable: z.boolean(),
   subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
   subJsonPath: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),
+  subJsonUserAgentRegex: z.string(),
   subKeyFile: z.string(),
   subListen: z.string(),
   subPath: z.string(),
@@ -158,6 +165,8 @@ export const AllSettingViewSchema = z.object({
   smtpEnable: z.boolean(),
   smtpEnabledEvents: z.string(),
   smtpEncryptionType: z.string(),
+  smtpFrom: z.string(),
+  smtpFromName: z.string(),
   smtpHost: z.string(),
   smtpMemory: z.number().int().min(0).max(100),
   smtpPassword: z.string(),
@@ -166,11 +175,13 @@ export const AllSettingViewSchema = z.object({
   smtpUsername: z.string(),
   subAnnounce: z.string(),
   subCertFile: z.string(),
+  subClashAutoDetect: z.boolean(),
   subClashEnable: z.boolean(),
   subClashEnableRouting: z.boolean(),
   subClashPath: z.string(),
   subClashRules: z.string(),
   subClashURI: z.string(),
+  subClashUserAgentRegex: z.string(),
   subDomain: z.string(),
   subEnable: z.boolean(),
   subEnableRouting: z.boolean(),
@@ -178,12 +189,15 @@ export const AllSettingViewSchema = z.object({
   subHideSettings: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyRoutingRules: z.string(),
+  subJsonAlwaysArray: z.boolean(),
+  subJsonAutoDetect: z.boolean(),
   subJsonEnable: z.boolean(),
   subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
   subJsonPath: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),
+  subJsonUserAgentRegex: z.string(),
   subKeyFile: z.string(),
   subListen: z.string(),
   subPath: z.string(),

+ 7 - 0
frontend/src/models/setting.ts

@@ -29,6 +29,11 @@ export class AllSetting {
   xrayTemplateConfig = '';
   subEnable = true;
   subJsonEnable = false;
+  subJsonAutoDetect = false;
+  subJsonAlwaysArray = false;
+  subJsonUserAgentRegex = '';
+  subClashAutoDetect = false;
+  subClashUserAgentRegex = '';
   subTitle = '';
   subSupportUrl = '';
   subProfileUrl = '';
@@ -91,6 +96,8 @@ export class AllSetting {
   smtpPort = 587;
   smtpUsername = '';
   smtpPassword = '';
+  smtpFrom = '';
+  smtpFromName = '';
   smtpTo = '';
   smtpEncryptionType = 'starttls';
   smtpEnabledEvents = '';

+ 9 - 2
frontend/src/pages/api-docs/endpoints.ts

@@ -1145,7 +1145,7 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/setting/all',
         summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
-        response: '{\n  "success": true,\n  "obj": {\n    "webPort": 2053,\n    "webCertFile": "",\n    "webKeyFile": "",\n    "webBasePath": "/",\n    "subPort": 10882,\n    "subPath": "/sub/",\n    "tgBotEnable": false,\n    "tgBotToken": "",\n    ...\n  }\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "webPort": 2053,\n    "webCertFile": "",\n    "webKeyFile": "",\n    "webBasePath": "/",\n    "subPort": 10882,\n    "subPath": "/sub/",\n    "subClashAutoDetect": false,\n    "subClashUserAgentRegex": "",\n    "subJsonEnable": false,\n    "subJsonAutoDetect": false,\n    "subJsonAlwaysArray": false,\n    "subJsonUserAgentRegex": "",\n    "subJsonPath": "/json/",\n    "subJsonURI": "https://sub.example.com/json/",\n    "subClashEnable": true,\n    "subClashPath": "/clash/",\n    "subClashURI": "https://sub.example.com/clash/",\n    "tgBotEnable": false,\n    "tgBotToken": "",\n    ...\n  }\n}',
       },
       {
         method: 'POST',
@@ -1156,7 +1156,14 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/setting/update',
         summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
-        body: '{\n  "webPort": 2053,\n  "webBasePath": "/",\n  "subPort": 10882,\n  "subPath": "/sub/",\n  "tgBotEnable": false,\n  ...\n}',
+        body: '{\n  "webPort": 2053,\n  "webBasePath": "/",\n  "subPort": 10882,\n  "subPath": "/sub/",\n  "subClashAutoDetect": false,\n  "subClashUserAgentRegex": "",\n  "subJsonEnable": false,\n  "subJsonAutoDetect": false,\n  "subJsonAlwaysArray": false,\n  "subJsonUserAgentRegex": "",\n  "subJsonPath": "/json/",\n  "subJsonURI": "https://sub.example.com/json/",\n  "subClashEnable": true,\n  "subClashPath": "/clash/",\n  "subClashURI": "https://sub.example.com/clash/",\n  "tgBotEnable": false,\n  ...\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/setting/validateRegex',
+        summary: 'Validate any regular expression with the backend Go RE2 compiler without saving it.',
+        body: '{\n  "regex": "(?m)^general-purpose$"\n}',
+        response: '{\n  "success": true,\n  "msg": ""\n}',
       },
       {
         method: 'POST',

+ 11 - 0
frontend/src/pages/clients/ClientsPage.css

@@ -162,6 +162,17 @@
   background: color-mix(in srgb, var(--ant-color-primary) 6%, transparent);
 }
 
+.client-card-comment {
+  display: block;
+  margin-top: 6px;
+  color: var(--ant-color-text-secondary);
+  font-size: 12px;
+  max-height: 4.5em;
+  overflow-y: auto;
+  overflow-wrap: anywhere;
+  white-space: pre-wrap;
+}
+
 .client-card-speed {
   margin-top: 6px;
   font-size: 12px;

+ 9 - 4
frontend/src/pages/clients/ClientsPage.tsx

@@ -62,10 +62,12 @@ import { useDatepicker } from '@/hooks/useDatepicker';
 import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
 import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
 import ClientSpeedTag, { isActiveSpeed } from '@/components/clients/ClientSpeedTag';
+import ClientCardComment from '@/components/clients/ClientCardComment';
 import AppSidebar from '@/layouts/AppSidebar';
 import { IntlUtil, SizeFormatter } from '@/utils';
 import { setMessageInstance } from '@/utils/messageBus';
 import { LazyMount } from '@/components/utility';
+import { SPEED_COLUMN_WIDTH, SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
 const ClientFormModal = lazy(() => import('./ClientFormModal'));
 const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
 const ClientQrModal = lazy(() => import('./ClientQrModal'));
@@ -818,7 +820,7 @@ export default function ClientsPage() {
         <div className="email-cell">
           <span className="email">{record.email}</span>
           {record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
-          {record.comment && <span className="sub" title={record.comment}>{record.comment}</span>}
+          <ClientCardComment comment={record.comment} className="sub" />
         </div>
       ),
     },
@@ -907,12 +909,14 @@ export default function ClientsPage() {
     {
       title: t('pages.clients.speed'),
       key: 'speed',
-      width: 110,
+      width: SPEED_COLUMN_WIDTH,
       align: 'center',
       render: (_v, record) => {
         const speed = clientSpeed[record.email];
-        if (!isActiveSpeed(speed)) return <Tag color="default">—</Tag>;
-        return <ClientSpeedTag speed={speed} />;
+        if (!isActiveSpeed(speed)) {
+          return <Tag color="default" className={SPEED_TAG_CLASS_NAME} style={SPEED_TAG_STYLE}>—</Tag>;
+        }
+        return <ClientSpeedTag speed={speed} tableCell />;
       },
     },
     {
@@ -1433,6 +1437,7 @@ export default function ClientsPage() {
                                       </Dropdown>
                                     </div>
                                   </div>
+                                  <ClientCardComment comment={row.comment} />
                                   <ClientTrafficCell
                                     compact
                                     up={row.traffic?.up}

+ 8 - 2
frontend/src/pages/inbounds/list/InboundSpeedTag.tsx

@@ -1,6 +1,7 @@
 import { Tag, Tooltip } from 'antd';
 
 import { SizeFormatter } from '@/utils';
+import { SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
 
 import type { InboundSpeedEntry } from './types';
 
@@ -12,12 +13,17 @@ export function isActiveSpeed(speed?: InboundSpeedEntry): speed is InboundSpeedE
 interface InboundSpeedTagProps {
   speed: InboundSpeedEntry;
   withTooltip?: boolean;
+  tableCell?: boolean;
 }
 
 // Blue "↑ up / ↓ down" rate tag, optionally with a stacked breakdown tooltip.
-export function InboundSpeedTag({ speed, withTooltip = false }: InboundSpeedTagProps) {
+export function InboundSpeedTag({ speed, withTooltip = false, tableCell = false }: InboundSpeedTagProps) {
   const tag = (
-    <Tag color="blue">
+    <Tag
+      color="blue"
+      className={tableCell ? SPEED_TAG_CLASS_NAME : undefined}
+      style={tableCell ? SPEED_TAG_STYLE : undefined}
+    >
       ↑ {SizeFormatter.speedFormat(speed.up)}
       {' / '}
       ↓ {SizeFormatter.speedFormat(speed.down)}

+ 4 - 3
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -10,6 +10,7 @@ import type { NodeRecord } from '@/api/queries/useNodesQuery';
 import { coerceInboundJsonField } from '@/models/dbinbound';
 
 import { RowActionsCell } from './RowActions';
+import { SPEED_COLUMN_WIDTH, SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
 import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
 import {
   readStreamHints,
@@ -324,14 +325,14 @@ export function useInboundColumns({
         title: t('pages.inbounds.speed'),
         key: 'speed',
         align: 'center',
-        width: 110,
+        width: SPEED_COLUMN_WIDTH,
         sorter: (a, b) => speedTotal(a) - speedTotal(b),
         render: (_, record) => {
           const speed = inboundSpeed[record.id];
           if (!isActiveSpeed(speed)) {
-            return <Tag color='default'>—</Tag>;
+            return <Tag color="default" className={SPEED_TAG_CLASS_NAME} style={SPEED_TAG_STYLE}>—</Tag>;
           }
-          return <InboundSpeedTag speed={speed} withTooltip />;
+          return <InboundSpeedTag speed={speed} withTooltip tableCell />;
         },
       },
       {

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

@@ -82,6 +82,16 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
                 onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} />
             </SettingListItem>
 
+            <SettingListItem paddings="small" title={t('pages.settings.smtpFrom')} description={t('pages.settings.smtpFromDesc')}>
+              <Input value={allSetting.smtpFrom} placeholder="[email protected]"
+                onChange={(e) => updateSetting({ smtpFrom: e.target.value })} />
+            </SettingListItem>
+
+            <SettingListItem paddings="small" title={t('pages.settings.smtpFromName')} description={t('pages.settings.smtpFromNameDesc')}>
+              <Input value={allSetting.smtpFromName} placeholder="3x-ui"
+                onChange={(e) => updateSetting({ smtpFromName: 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 })} />

+ 15 - 0
frontend/src/pages/settings/SubscriptionFormatsTab.css

@@ -1,3 +1,18 @@
+.subscription-format-sections {
+  display: grid;
+  gap: 16px;
+}
+
+.subscription-format-card-title {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.subscription-format-card .ant-card-body {
+  padding: 0;
+}
+
 .format-settings {
   margin-bottom: 8px;
   border: 1px solid var(--ant-color-border-secondary);

+ 70 - 6
frontend/src/pages/settings/SubscriptionFormatsTab.tsx

@@ -1,6 +1,7 @@
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
+  Card,
   Input,
   InputNumber,
   Select,
@@ -8,6 +9,8 @@ import {
   Tabs,
 } from 'antd';
 import {
+  FileTextOutlined,
+  NodeIndexOutlined,
   PartitionOutlined,
   RocketOutlined,
   SendOutlined,
@@ -15,6 +18,7 @@ import {
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
+import { GoRegexInput } from '@/components/form';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
@@ -148,9 +152,18 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
         key: '1',
         label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
         children: (
-          <>
+          <div className="subscription-format-sections">
             {allSetting.subJsonEnable && (
-              <>
+              <Card
+                size="small"
+                className="subscription-format-card"
+                title={(
+                  <span className="subscription-format-card-title">
+                    <FileTextOutlined />
+                    {t('pages.settings.subJsonEnableTitle')}
+                  </span>
+                )}
+              >
                 <SettingListItem paddings="small" title={<>JSON {t('pages.settings.subPath')}</>} description={t('pages.settings.subPathDesc')}>
                   <Input
                     value={allSetting.subJsonPath}
@@ -166,10 +179,40 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
                     onChange={(e) => updateSetting({ subJsonURI: e.target.value })}
                   />
                 </SettingListItem>
-              </>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subJsonAlwaysArray')}
+                  description={t('pages.settings.subJsonAlwaysArrayDesc')}
+                >
+                  <Switch checked={allSetting.subJsonAlwaysArray} onChange={(value) => updateSetting({ subJsonAlwaysArray: value })} />
+                </SettingListItem>
+                <SettingListItem paddings="small" title={t('pages.settings.subJsonAutoDetect')} description={t('pages.settings.subJsonAutoDetectDesc')}>
+                  <Switch checked={allSetting.subJsonAutoDetect} onChange={(v) => updateSetting({ subJsonAutoDetect: v })} />
+                </SettingListItem>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subJsonUserAgentRegex')}
+                  description={t('pages.settings.subJsonUserAgentRegexDesc')}
+                >
+                  <GoRegexInput
+                    value={allSetting.subJsonUserAgentRegex}
+                    placeholder="(?i)^myclient([ /]|$)"
+                    onChange={(value) => updateSetting({ subJsonUserAgentRegex: value })}
+                  />
+                </SettingListItem>
+              </Card>
             )}
             {allSetting.subClashEnable && (
-              <>
+              <Card
+                size="small"
+                className="subscription-format-card"
+                title={(
+                  <span className="subscription-format-card-title">
+                    <NodeIndexOutlined />
+                    {t('pages.settings.subClashEnableTitle')}
+                  </span>
+                )}
+              >
                 <SettingListItem paddings="small" title={<>Clash {t('pages.settings.subPath')}</>} description={t('pages.settings.subPathDesc')}>
                   <Input
                     value={allSetting.subClashPath}
@@ -185,9 +228,30 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
                     onChange={(e) => updateSetting({ subClashURI: e.target.value })}
                   />
                 </SettingListItem>
-              </>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subClashAutoDetect')}
+                  description={t('pages.settings.subClashAutoDetectDesc')}
+                >
+                  <Switch
+                    checked={allSetting.subClashAutoDetect}
+                    onChange={(v) => updateSetting({ subClashAutoDetect: v })}
+                  />
+                </SettingListItem>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subClashUserAgentRegex')}
+                  description={t('pages.settings.subClashUserAgentRegexDesc')}
+                >
+                  <GoRegexInput
+                    value={allSetting.subClashUserAgentRegex}
+                    placeholder="(?i)(clash|mihomo)"
+                    onChange={(value) => updateSetting({ subClashUserAgentRegex: value })}
+                  />
+                </SettingListItem>
+              </Card>
             )}
-          </>
+          </div>
         ),
       },
       {

+ 17 - 1
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -1,6 +1,7 @@
-import { Input, InputNumber, Switch, Tabs } from 'antd';
+import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
 import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
 import { RemarkTemplateField } from '@/components/form';
@@ -15,6 +16,7 @@ interface SubscriptionGeneralTabProps {
 
 export default function SubscriptionGeneralTab({ allSetting, updateSetting }: SubscriptionGeneralTabProps) {
   const { t } = useTranslation();
+  const navigate = useNavigate();
   const { isMobile } = useMediaQuery();
 
   return (
@@ -33,6 +35,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
             <SettingListItem paddings="small" title={t('pages.settings.subClashEnableTitle')}>
               <Switch checked={allSetting.subClashEnable} onChange={(v) => updateSetting({ subClashEnable: v })} />
             </SettingListItem>
+            {(allSetting.subJsonEnable || allSetting.subClashEnable) && (
+              <Alert
+                type="info"
+                showIcon
+                style={{ margin: '12px 20px' }}
+                title={t('pages.settings.subFormatsTipTitle')}
+                description={t('pages.settings.subFormatsTipDesc')}
+                action={(
+                  <Button size="small" onClick={() => navigate('/settings#subscription-formats')}>
+                    {t('pages.settings.subFormatsTipAction')}
+                  </Button>
+                )}
+              />
+            )}
             <SettingListItem paddings="small" title={t('pages.settings.subListen')} description={t('pages.settings.subListenDesc')}>
               <Input value={allSetting.subListen} onChange={(e) => updateSetting({ subListen: e.target.value })} />
             </SettingListItem>

+ 2 - 1
frontend/src/pages/xray/dns/DnsServerModal.tsx

@@ -10,6 +10,7 @@ import {
   DnsQueryStrategySchema,
   DnsServerObjectInnerSchema,
   DnsServerObjectSchema,
+  isEncryptedDnsAddress,
   type DnsServerObject,
 } from '@/schemas/dns';
 
@@ -109,7 +110,6 @@ function valuesToWire(values: DnsServerForm): DnsServerValue {
 
   const out: Record<string, unknown> = {
     address: values.address,
-    port: values.port,
     domains: values.domains.filter(Boolean),
     expectedIPs: values.expectedIPs.filter(Boolean),
     unexpectedIPs: values.unexpectedIPs.filter(Boolean),
@@ -121,6 +121,7 @@ function valuesToWire(values: DnsServerForm): DnsServerValue {
     serveExpiredTTL: values.serveExpiredTTL,
     timeoutMs: values.timeoutMs,
   };
+  if (!isEncryptedDnsAddress(values.address)) out.port = values.port;
   if (values.tag) out.tag = values.tag;
   if (values.clientIP) out.clientIP = values.clientIP;
   return out as DnsServerValue;

+ 11 - 2
frontend/src/schemas/dns.ts

@@ -14,9 +14,13 @@ const DnsHostValueSchema = z.union([z.string(), z.array(z.string())]);
 export const DnsHostsSchema = z.record(z.string(), DnsHostValueSchema);
 export type DnsHosts = z.infer<typeof DnsHostsSchema>;
 
+export function isEncryptedDnsAddress(address: string): boolean {
+  return /^(https|https\+local|h2c|h2c\+local|quic\+local):\/\//i.test(address);
+}
+
 export const DnsServerObjectInnerSchema = z.object({
   address: z.string(),
-  port: PortSchema.default(53),
+  port: PortSchema.optional(),
   domains: z.array(z.string()).optional(),
   expectedIPs: z.array(z.string()).optional(),
   unexpectedIPs: z.array(z.string()).optional(),
@@ -41,7 +45,12 @@ export const DnsServerObjectSchema = z.preprocess(
     return val;
   },
   DnsServerObjectInnerSchema,
-);
+).transform((v) => {
+  if (v.port === undefined && !isEncryptedDnsAddress(v.address)) {
+    return { ...v, port: 53 };
+  }
+  return v;
+});
 export type DnsServerObject = z.infer<typeof DnsServerObjectSchema>;
 
 export const DnsServerEntrySchema = z.union([z.string(), DnsServerObjectSchema]);

+ 5 - 0
frontend/src/schemas/setting.ts

@@ -33,6 +33,11 @@ export const AllSettingSchema = z.object({
   xrayTemplateConfig: z.string().optional(),
   subEnable: z.boolean().optional(),
   subJsonEnable: z.boolean().optional(),
+  subJsonAutoDetect: z.boolean().optional(),
+  subJsonAlwaysArray: z.boolean().optional(),
+  subJsonUserAgentRegex: z.string().max(2048).optional(),
+  subClashAutoDetect: z.boolean().optional(),
+  subClashUserAgentRegex: z.string().max(2048).optional(),
   subTitle: z.string().optional(),
   subSupportUrl: z.string().optional(),
   subProfileUrl: z.string().optional(),

+ 0 - 1
frontend/src/test/__snapshots__/dns.test.ts.snap

@@ -41,7 +41,6 @@ exports[`DnsObjectSchema fixtures > parses full byte-stably 1`] = `
       "address": "quic+local://dns.adguard.com",
       "disableCache": true,
       "finalQuery": true,
-      "port": 53,
       "serveExpiredTTL": 60,
       "serveStale": false,
       "timeoutMs": 5000,

+ 27 - 0
frontend/src/test/client-card-comment.test.tsx

@@ -0,0 +1,27 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import ClientCardComment from '@/components/clients/ClientCardComment';
+
+describe('ClientCardComment', () => {
+  it('renders a client comment in the card', () => {
+    render(<ClientCardComment comment={'Primary mobile client\nLine two'} />);
+
+    const comment = screen.getByText(/Primary mobile client/);
+    expect(comment.className).toContain('client-card-comment');
+    expect(comment.textContent).toBe('Primary mobile client\nLine two');
+    expect(comment.getAttribute('title')).toBe('Primary mobile client\nLine two');
+  });
+
+  it('supports the compact desktop style', () => {
+    render(<ClientCardComment comment="Desktop comment" className="sub" />);
+
+    expect(screen.getByText('Desktop comment').className).toBe('sub');
+  });
+
+  it('renders nothing when no comment is set', () => {
+    const { container } = render(<ClientCardComment comment="" />);
+
+    expect(container.childElementCount).toBe(0);
+  });
+});

+ 42 - 0
frontend/src/test/dns.test.ts

@@ -41,3 +41,45 @@ describe('DnsServerObjectSchema fixtures', () => {
     });
   }
 });
+
+describe('DnsServerObjectSchema port defaulting', () => {
+  it('defaults port 53 for a plain address', () => {
+    const parsed = DnsServerObjectSchema.parse({ address: '8.8.8.8' });
+    expect(parsed.port).toBe(53);
+  });
+
+  it('defaults port 53 for a tcp address', () => {
+    const parsed = DnsServerObjectSchema.parse({ address: 'tcp://1.1.1.1' });
+    expect(parsed.port).toBe(53);
+  });
+
+  it('omits port for a DoH (https://) address', () => {
+    const parsed = DnsServerObjectSchema.parse({ address: 'https://cloudflare-dns.com/dns-query' });
+    expect(parsed.port).toBeUndefined();
+  });
+
+  it('omits port for a DoHL (https+local://) address', () => {
+    const parsed = DnsServerObjectSchema.parse({ address: 'https+local://dns.google/dns-query' });
+    expect(parsed.port).toBeUndefined();
+  });
+
+  it('omits port for a DoQ (quic+local://) address', () => {
+    const parsed = DnsServerObjectSchema.parse({ address: 'quic+local://dns.adguard.com' });
+    expect(parsed.port).toBeUndefined();
+  });
+
+  it('omits port for an h2c and h2c+local address', () => {
+    expect(DnsServerObjectSchema.parse({ address: 'h2c://dns.example.com/dns-query' }).port).toBeUndefined();
+    expect(DnsServerObjectSchema.parse({ address: 'h2c+local://dns.example.com/dns-query' }).port).toBeUndefined();
+  });
+
+  it('omits port for an uppercase encrypted scheme', () => {
+    const parsed = DnsServerObjectSchema.parse({ address: 'HTTPS://dns.google/dns-query' });
+    expect(parsed.port).toBeUndefined();
+  });
+
+  it('preserves an explicit port on an encrypted address', () => {
+    const parsed = DnsServerObjectSchema.parse({ address: 'https://dns.google/dns-query', port: 8443 });
+    expect(parsed.port).toBe(8443);
+  });
+});

+ 84 - 0
frontend/src/test/speed-tag-stable.test.tsx

@@ -0,0 +1,84 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { ClientSpeedTag } from '@/components/clients/ClientSpeedTag';
+import {
+  SPEED_COLUMN_WIDTH,
+  SPEED_TABLE_CELL_INLINE_PADDING,
+  SPEED_TAG_CLASS_NAME,
+  SPEED_TAG_STYLE,
+  SPEED_TAG_WIDTH,
+} from '@/components/utility/speedTagStyle';
+import { InboundSpeedTag } from '@/pages/inbounds/list/InboundSpeedTag';
+import { SizeFormatter } from '@/utils';
+import '@/styles/utils.css';
+
+const SMALL_RATE = { up: 512, down: 1023 } as const;
+const LARGE_RATE = { up: 12_340_000, down: 99_990_000 } as const;
+const FORMATTER_BOUNDARY_RATE = {
+  up: SizeFormatter.ONE_GB - 1,
+  down: SizeFormatter.ONE_GB - 1,
+} as const;
+const FORMATTER_BOUNDARY_NATURAL_WIDTH = 192.765625;
+
+function firstTag(): HTMLElement {
+  const tag = document.querySelector('.ant-tag');
+  if (!(tag instanceof HTMLElement)) {
+    throw new Error('expected an Ant Design Tag in the document');
+  }
+  return tag;
+}
+
+function expectFluidTag(tag: HTMLElement) {
+  expect(tag.classList.contains(SPEED_TAG_CLASS_NAME)).toBe(false);
+  expect(tag.style.width).toBe('');
+}
+
+function expectStableTableTag(tag: HTMLElement) {
+  const style = getComputedStyle(tag);
+  expect(tag.classList.contains(SPEED_TAG_CLASS_NAME)).toBe(true);
+  expect(style.width).toBe(`${SPEED_TAG_WIDTH}px`);
+  expect(style.display).toBe(SPEED_TAG_STYLE.display);
+  expect(style.justifyContent).toBe(SPEED_TAG_STYLE.justifyContent);
+  expect(style.alignItems).toBe(SPEED_TAG_STYLE.alignItems);
+  expect(style.textAlign).toBe(SPEED_TAG_STYLE.textAlign);
+  expect(style.whiteSpace).toBe(SPEED_TAG_STYLE.whiteSpace);
+  expect(style.fontVariantNumeric).toBe(SPEED_TAG_STYLE.fontVariantNumeric);
+  expect(style.overflow).toBe('hidden');
+  expect(style.textOverflow).toBe('ellipsis');
+}
+
+describe('stable table speed tags (issue #5912)', () => {
+  it('scopes ClientSpeedTag stable sizing to table cells', () => {
+    const { rerender } = render(<ClientSpeedTag speed={SMALL_RATE} />);
+    expectFluidTag(firstTag());
+
+    rerender(<ClientSpeedTag speed={LARGE_RATE} tableCell />);
+    expectStableTableTag(firstTag());
+  });
+
+  it('scopes InboundSpeedTag stable sizing to table cells', () => {
+    const { rerender } = render(<InboundSpeedTag speed={SMALL_RATE} />);
+    expectFluidTag(firstTag());
+
+    rerender(<InboundSpeedTag speed={LARGE_RATE} withTooltip tableCell />);
+    expectStableTableTag(firstTag());
+  });
+
+  it('fits the widest formatter rollover and includes small-cell padding', () => {
+    render(
+      <>
+        <ClientSpeedTag speed={FORMATTER_BOUNDARY_RATE} tableCell />
+        <InboundSpeedTag speed={FORMATTER_BOUNDARY_RATE} tableCell />
+      </>,
+    );
+    const tags = Array.from(document.querySelectorAll<HTMLElement>('.ant-tag'));
+    expect(tags).toHaveLength(2);
+    expect(tags[0]?.textContent).toBe('↑ 1024.00 MB/s / ↓ 1024.00 MB/s');
+    expect(tags[1]?.textContent).toBe(tags[0]?.textContent);
+    for (const tag of tags) expectStableTableTag(tag);
+
+    expect(SPEED_TAG_WIDTH).toBeGreaterThan(FORMATTER_BOUNDARY_NATURAL_WIDTH);
+    expect(SPEED_COLUMN_WIDTH).toBe(SPEED_TAG_WIDTH + SPEED_TABLE_CELL_INLINE_PADDING * 2);
+  });
+});

+ 29 - 0
frontend/src/test/subscription-general-tab.test.tsx

@@ -0,0 +1,29 @@
+import { fireEvent, screen } from '@testing-library/react';
+import { MemoryRouter, useLocation } from 'react-router-dom';
+import { describe, expect, it, vi } from 'vitest';
+
+import { AllSetting } from '@/models/setting';
+import SubscriptionGeneralTab from '@/pages/settings/SubscriptionGeneralTab';
+import { renderWithProviders } from './test-utils';
+
+function LocationProbe() {
+  const location = useLocation();
+  return <output data-testid="location">{location.pathname}{location.hash}</output>;
+}
+
+describe('SubscriptionGeneralTab', () => {
+  it('uses router navigation to open subscription format settings', () => {
+    const allSetting = new AllSetting({ subClashEnable: true });
+
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/settings#subscription']}>
+        <SubscriptionGeneralTab allSetting={allSetting} updateSetting={vi.fn()} />
+        <LocationProbe />
+      </MemoryRouter>,
+    );
+
+    fireEvent.click(screen.getByRole('button', { name: 'Open Sub Formats' }));
+
+    expect(screen.getByTestId('location').textContent).toBe('/settings#subscription-formats');
+  });
+});

+ 2 - 2
install.sh

@@ -1424,7 +1424,7 @@ install_x-ui() {
             exit 1
         fi
         echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..."
-        curl -fLR --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 300 -o ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz
+        curl -fLR --retry 5 --retry-delay 3 --connect-timeout 15 --speed-limit 1 --speed-time 300 -o ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz
         if [[ $? -ne 0 ]]; then
             echo -e "${red}Downloading x-ui failed, please be sure that your server can access GitHub ${plain}"
             exit 1
@@ -1454,7 +1454,7 @@ install_x-ui() {
 
         url="https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz"
         echo -e "Beginning to install x-ui ${tag_version}"
-        curl -fLR --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 300 -o ${xui_folder}-linux-$(arch).tar.gz ${url}
+        curl -fLR --retry 5 --retry-delay 3 --connect-timeout 15 --speed-limit 1 --speed-time 300 -o ${xui_folder}-linux-$(arch).tar.gz ${url}
         if [[ $? -ne 0 ]]; then
             echo -e "${red}Download x-ui ${tag_version} failed, please check if the version exists ${plain}"
             exit 1

+ 273 - 55
internal/sub/controller.go

@@ -10,10 +10,12 @@ import (
 	"net/url"
 	"os"
 	"path/filepath"
+	"regexp"
 	"strconv"
 	"strings"
 	"sync"
 	"time"
+	"unicode"
 
 	"github.com/gin-gonic/gin"
 
@@ -54,13 +56,18 @@ type SUBController struct {
 	subIncyEnableRouting bool
 	subIncyRoutingRules  string
 
-	subPath        string
-	subJsonPath    string
-	subClashPath   string
-	jsonEnabled    bool
-	clashEnabled   bool
-	subEncrypt     bool
-	updateInterval string
+	subPath            string
+	subJsonPath        string
+	subClashPath       string
+	subClashAutoDetect bool
+	clashUserAgent     *regexp.Regexp
+	jsonAutoDetect     bool
+	jsonUserAgent      *regexp.Regexp
+	jsonAlwaysArray    bool
+	jsonEnabled        bool
+	clashEnabled       bool
+	subEncrypt         bool
+	updateInterval     string
 
 	subService      *SubService
 	subJsonService  *SubJsonService
@@ -71,56 +78,198 @@ type SUBController struct {
 	subTemplateCache map[string]*cachedSubTemplate
 }
 
+type subControllerConfig struct {
+	subPath      string
+	subJsonPath  string
+	subClashPath string
+
+	subClashAutoDetect     bool
+	subClashUserAgentRegex string
+	subJsonAutoDetect      bool
+	subJsonUserAgentRegex  string
+	subJsonAlwaysArray     bool
+	subJsonEnabled         bool
+	subClashEnabled        bool
+
+	subEncrypt     bool
+	remarkTemplate string
+	updateInterval string
+
+	subJsonMux            string
+	subJsonRules          string
+	subJsonFinalMask      string
+	subClashEnableRouting bool
+	subClashRules         string
+
+	subTitle         string
+	subSupportURL    string
+	subProfileURL    string
+	subAnnounce      string
+	subEnableRouting bool
+	subRoutingRules  string
+	subHideSettings  bool
+
+	subIncyEnableRouting bool
+	subIncyRoutingRules  string
+}
+
+type SUBControllerOption func(*subControllerConfig)
+
+func WithSUBPath(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subPath = value }
+}
+
+func WithSUBJsonPath(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonPath = value }
+}
+
+func WithSUBClashPath(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subClashPath = value }
+}
+
+func WithSUBClashAutoDetect(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subClashAutoDetect = value }
+}
+
+func WithSUBClashUserAgentRegex(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subClashUserAgentRegex = value }
+}
+
+func WithSUBJsonAutoDetect(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonAutoDetect = value }
+}
+
+func WithSUBJsonUserAgentRegex(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonUserAgentRegex = value }
+}
+
+func WithSUBJsonAlwaysArray(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonAlwaysArray = value }
+}
+
+func WithSUBJsonEnabled(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonEnabled = value }
+}
+
+func WithSUBClashEnabled(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subClashEnabled = value }
+}
+
+func WithSUBEncryption(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subEncrypt = value }
+}
+
+func WithSUBRemarkTemplate(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.remarkTemplate = value }
+}
+
+func WithSUBUpdateInterval(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.updateInterval = value }
+}
+
+func WithSUBJsonMux(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonMux = value }
+}
+
+func WithSUBJsonRules(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonRules = value }
+}
+
+func WithSUBJsonFinalMask(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonFinalMask = value }
+}
+
+func WithSUBClashEnableRouting(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subClashEnableRouting = value }
+}
+
+func WithSUBClashRules(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subClashRules = value }
+}
+
+func WithSUBTitle(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subTitle = value }
+}
+
+func WithSUBSupportURL(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subSupportURL = value }
+}
+
+func WithSUBProfileURL(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subProfileURL = value }
+}
+
+func WithSUBAnnounce(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subAnnounce = value }
+}
+
+func WithSUBEnableRouting(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subEnableRouting = value }
+}
+
+func WithSUBRoutingRules(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subRoutingRules = value }
+}
+
+func WithSUBHideSettings(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subHideSettings = value }
+}
+
+func WithSUBIncyEnableRouting(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subIncyEnableRouting = value }
+}
+
+func WithSUBIncyRoutingRules(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subIncyRoutingRules = value }
+}
+
+func defaultSUBControllerConfig() subControllerConfig {
+	return subControllerConfig{
+		subPath:        "/sub/",
+		subJsonPath:    "/json/",
+		subClashPath:   "/clash/",
+		subEncrypt:     true,
+		remarkTemplate: service.DefaultRemarkTemplate,
+		updateInterval: "12",
+	}
+}
+
 // NewSUBController creates a new subscription controller with the given configuration.
-func NewSUBController(
-	g *gin.RouterGroup,
-	subPath string,
-	jsonPath string,
-	clashPath string,
-	jsonEnabled bool,
-	clashEnabled bool,
-	encrypt bool,
-	remarkTemplate string,
-	update string,
-	jsonMux string,
-	jsonRules string,
-	jsonFinalMask string,
-	clashEnableRouting bool,
-	clashRules string,
-	subTitle string,
-	subSupportUrl string,
-	subProfileUrl string,
-	subAnnounce string,
-	subEnableRouting bool,
-	subRoutingRules string,
-	subHideSettings bool,
-	subIncyEnableRouting bool,
-	subIncyRoutingRules string,
-) *SUBController {
-	sub := NewSubService(remarkTemplate)
+func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBController {
+	config := defaultSUBControllerConfig()
+	for _, option := range options {
+		option(&config)
+	}
+
+	sub := NewSubService(config.remarkTemplate)
 	a := &SUBController{
-		subTitle:         subTitle,
-		subSupportUrl:    subSupportUrl,
-		subProfileUrl:    subProfileUrl,
-		subAnnounce:      subAnnounce,
-		subEnableRouting: subEnableRouting,
-		subRoutingRules:  subRoutingRules,
-		subHideSettings:  subHideSettings,
-
-		subIncyEnableRouting: subIncyEnableRouting,
-		subIncyRoutingRules:  subIncyRoutingRules,
-
-		subPath:        subPath,
-		subJsonPath:    jsonPath,
-		subClashPath:   clashPath,
-		jsonEnabled:    jsonEnabled,
-		clashEnabled:   clashEnabled,
-		subEncrypt:     encrypt,
-		updateInterval: update,
+		subTitle:         config.subTitle,
+		subSupportUrl:    config.subSupportURL,
+		subProfileUrl:    config.subProfileURL,
+		subAnnounce:      config.subAnnounce,
+		subEnableRouting: config.subEnableRouting,
+		subRoutingRules:  config.subRoutingRules,
+		subHideSettings:  config.subHideSettings,
+
+		subIncyEnableRouting: config.subIncyEnableRouting,
+		subIncyRoutingRules:  config.subIncyRoutingRules,
+
+		subPath:            config.subPath,
+		subJsonPath:        config.subJsonPath,
+		subClashPath:       config.subClashPath,
+		subClashAutoDetect: config.subClashAutoDetect,
+		clashUserAgent:     compileUserAgentRegex("Clash/Mihomo", config.subClashUserAgentRegex, service.DefaultSubClashUserAgentRegex),
+		jsonAutoDetect:     config.subJsonAutoDetect,
+		jsonUserAgent:      compileUserAgentRegex("Xray JSON", config.subJsonUserAgentRegex, service.DefaultSubJsonUserAgentRegex),
+		jsonAlwaysArray:    config.subJsonAlwaysArray,
+		jsonEnabled:        config.subJsonEnabled,
+		clashEnabled:       config.subClashEnabled,
+		subEncrypt:         config.subEncrypt,
+		updateInterval:     config.updateInterval,
 
 		subService:      sub,
-		subJsonService:  NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
-		subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
+		subJsonService:  NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub),
+		subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub),
 
 		subTemplateCache: map[string]*cachedSubTemplate{},
 	}
@@ -186,9 +335,22 @@ func (a *SUBController) maybeServeSubPage(c *gin.Context) bool {
 
 // subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
 func (a *SUBController) subs(c *gin.Context) {
+	userAgent := c.GetHeader("User-Agent")
 	if a.maybeServeSubPage(c) {
+		logSubscriptionRoute(userAgent, "html")
+		return
+	}
+	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) {
+		logSubscriptionRoute(userAgent, "clash")
+		a.subClashs(c)
+		return
+	}
+	if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) {
+		logSubscriptionRoute(userAgent, "json")
+		a.serveJson(c, true, "application/json; charset=utf-8")
 		return
 	}
+	logSubscriptionRoute(userAgent, "raw")
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	subReq := a.subService.ForRequest(host)
@@ -224,6 +386,58 @@ func (a *SUBController) subs(c *gin.Context) {
 	}
 }
 
+func shouldAutoServeClash(autoDetect, clashEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
+	return shouldAutoServeFormat(autoDetect, clashEnabled, wantsHTML, userAgent, userAgentRegex)
+}
+
+func shouldAutoServeJson(autoDetect, jsonEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
+	return shouldAutoServeFormat(autoDetect, jsonEnabled, wantsHTML, userAgent, userAgentRegex)
+}
+
+func shouldAutoServeFormat(autoDetect, formatEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
+	if !autoDetect || !formatEnabled || wantsHTML || userAgentRegex == nil {
+		return false
+	}
+	return userAgentRegex.MatchString(userAgent)
+}
+
+func logSubscriptionRoute(userAgent, branch string) {
+	logger.Debugf("Subscription request routed: branch=%s user_agent=%q", branch, sanitizeUserAgentForLog(userAgent))
+}
+
+func sanitizeUserAgentForLog(userAgent string) string {
+	clean := strings.Map(func(r rune) rune {
+		if unicode.IsControl(r) {
+			return ' '
+		}
+		return r
+	}, userAgent)
+	runes := []rune(clean)
+	if len(runes) > 512 {
+		return string(runes[:512])
+	}
+	return clean
+}
+
+func compileUserAgentRegex(name, pattern, defaultPattern string) *regexp.Regexp {
+	pattern = strings.TrimSpace(pattern)
+	if pattern == "" {
+		pattern = strings.TrimSpace(defaultPattern)
+	}
+	if pattern == "" {
+		return nil
+	}
+	compiled, err := regexp.Compile(pattern)
+	if err == nil {
+		return compiled
+	}
+	logger.Warningf("Invalid %s User-Agent regex %q; falling back to default %q: %v", name, pattern, defaultPattern, err)
+	if strings.TrimSpace(defaultPattern) == "" {
+		return nil
+	}
+	return regexp.MustCompile(defaultPattern)
+}
+
 // serveSubPage renders internal/web/dist/subpage.html for the current subscription
 // request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
 // we inject that here, along with window.X_UI_BASE_PATH so the
@@ -387,9 +601,13 @@ func (a *SUBController) subJsons(c *gin.Context) {
 	if a.maybeServeSubPage(c) {
 		return
 	}
+	a.serveJson(c, a.jsonAlwaysArray, "text/plain; charset=utf-8")
+}
+
+func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) {
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
-	jsonSub, header, err := a.subJsonService.GetJson(subId, host)
+	jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray)
 	if err != nil || len(jsonSub) == 0 {
 		writeSubError(c, err)
 	} else {
@@ -399,7 +617,7 @@ func (a *SUBController) subJsons(c *gin.Context) {
 		}
 		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
 
-		c.String(200, jsonSub)
+		c.Data(200, contentType, []byte(jsonSub))
 	}
 }
 

+ 329 - 0
internal/sub/controller_test.go

@@ -2,10 +2,18 @@ package sub
 
 import (
 	"bytes"
+	"encoding/base64"
+	"net/http"
+	"net/http/httptest"
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 	"time"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 // newTestSUBController builds a controller with just the bits loadSubTemplate
@@ -14,6 +22,327 @@ func newTestSUBController() *SUBController {
 	return &SUBController{subTemplateCache: map[string]*cachedSubTemplate{}}
 }
 
+type subscriptionTestRouterConfig struct {
+	clashAutoDetect     bool
+	clashUserAgentRegex string
+	jsonAutoDetect      bool
+	jsonUserAgentRegex  string
+	jsonAlwaysArray     bool
+}
+
+func newSubscriptionTestRouter(config subscriptionTestRouterConfig) *gin.Engine {
+	router := gin.New()
+	options := []SUBControllerOption{
+		WithSUBJsonEnabled(true),
+		WithSUBClashEnabled(true),
+	}
+	if config.clashAutoDetect {
+		options = append(options, WithSUBClashAutoDetect(true))
+	}
+	if config.clashUserAgentRegex != "" {
+		options = append(options, WithSUBClashUserAgentRegex(config.clashUserAgentRegex))
+	}
+	if config.jsonAutoDetect {
+		options = append(options, WithSUBJsonAutoDetect(true))
+	}
+	if config.jsonUserAgentRegex != "" {
+		options = append(options, WithSUBJsonUserAgentRegex(config.jsonUserAgentRegex))
+	}
+	if config.jsonAlwaysArray {
+		options = append(options, WithSUBJsonAlwaysArray(true))
+	}
+	NewSUBController(router.Group("/"), options...)
+	return router
+}
+
+func TestNewSUBControllerOptions(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	defaults := NewSUBController(gin.New().Group("/"))
+	if defaults.subPath != "/sub/" || defaults.subJsonPath != "/json/" || defaults.subClashPath != "/clash/" {
+		t.Fatalf("default paths = %q, %q, %q", defaults.subPath, defaults.subJsonPath, defaults.subClashPath)
+	}
+	if !defaults.subEncrypt || defaults.updateInterval != "12" {
+		t.Fatalf("default encryption/update = %v, %q", defaults.subEncrypt, defaults.updateInterval)
+	}
+	if defaults.subService.remarkTemplate != service.DefaultRemarkTemplate {
+		t.Fatalf("default remark template = %q", defaults.subService.remarkTemplate)
+	}
+	if defaults.jsonEnabled || defaults.clashEnabled {
+		t.Fatalf("format endpoints enabled by default: json=%v clash=%v", defaults.jsonEnabled, defaults.clashEnabled)
+	}
+
+	configured := NewSUBController(
+		gin.New().Group("/"),
+		WithSUBPath("/custom/"),
+		WithSUBJsonEnabled(true),
+		WithSUBEncryption(false),
+		WithSUBUpdateInterval("24"),
+	)
+	if configured.subPath != "/custom/" || !configured.jsonEnabled || configured.subEncrypt || configured.updateInterval != "24" {
+		t.Fatalf("configured values were not applied: path=%q json=%v encrypt=%v update=%q",
+			configured.subPath, configured.jsonEnabled, configured.subEncrypt, configured.updateInterval)
+	}
+}
+
+func TestShouldAutoServeClash(t *testing.T) {
+	tests := []struct {
+		name         string
+		autoDetect   bool
+		clashEnabled bool
+		wantsHTML    bool
+		userAgent    string
+		pattern      string
+		want         bool
+	}{
+		{name: "clash verge", autoDetect: true, clashEnabled: true, userAgent: "Clash-Verge/v2.4.2", want: true},
+		{name: "mihomo", autoDetect: true, clashEnabled: true, userAgent: "mihomo/1.19.12", want: true},
+		{name: "clash case insensitive", autoDetect: true, clashEnabled: true, userAgent: "CLASH-META/1.0", want: true},
+		{name: "flclash covered by clash", autoDetect: true, clashEnabled: true, userAgent: "FlClash/0.8.91", want: true},
+		{name: "generic client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "GenericClient/1.10.0"},
+		{name: "other client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "OtherClient/2.2"},
+		{name: "unknown raw fallback", autoDetect: true, clashEnabled: true, userAgent: "CustomClient/1.0"},
+		{name: "empty raw fallback", autoDetect: true, clashEnabled: true},
+		{name: "browser HTML wins", autoDetect: true, clashEnabled: true, wantsHTML: true, userAgent: "Clash-Verge/v2.4.2"},
+		{name: "disabled by default", clashEnabled: true, userAgent: "mihomo/1.19.12"},
+		{name: "clash endpoint disabled", autoDetect: true, userAgent: "mihomo/1.19.12"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := shouldAutoServeClash(tt.autoDetect, tt.clashEnabled, tt.wantsHTML, tt.userAgent, compileUserAgentRegex("Clash/Mihomo", tt.pattern, service.DefaultSubClashUserAgentRegex))
+			if got != tt.want {
+				t.Fatalf("shouldAutoServeClash() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestShouldAutoServeClashUsesConfiguredRegex(t *testing.T) {
+	configured := compileUserAgentRegex("Clash/Mihomo", `(?i)^custom-client/`, service.DefaultSubClashUserAgentRegex)
+	if !shouldAutoServeClash(true, true, false, "Custom-Client/1.0", configured) {
+		t.Fatal("configured User-Agent regex did not match")
+	}
+	if shouldAutoServeClash(true, true, false, "Mihomo/1.19", configured) {
+		t.Fatal("built-in User-Agent matched after a custom regex replaced it")
+	}
+}
+
+func TestShouldAutoServeJson(t *testing.T) {
+	configured := compileUserAgentRegex("Xray JSON", `(?i)^jsonclient([ /]|$)`, service.DefaultSubJsonUserAgentRegex)
+	for _, userAgent := range []string{"JsonClient/1.6.32", "jsonclient 1.6.32"} {
+		if !shouldAutoServeJson(true, true, false, userAgent, configured) {
+			t.Errorf("configured Xray JSON regex did not match %q", userAgent)
+		}
+	}
+	for _, userAgent := range []string{"GenericClient/1.10.0", "OtherClient/2.2", "ThirdClient/7.0", "CustomClient/1.0"} {
+		if shouldAutoServeJson(true, true, false, userAgent, configured) {
+			t.Errorf("configured Xray JSON regex unexpectedly matched %q", userAgent)
+		}
+	}
+	if shouldAutoServeJson(false, true, false, "JsonClient/1.6.32", configured) {
+		t.Fatal("disabled Xray JSON auto-detection matched")
+	}
+	if shouldAutoServeJson(true, false, false, "JsonClient/1.6.32", configured) {
+		t.Fatal("disabled JSON endpoint matched")
+	}
+	if shouldAutoServeJson(true, true, true, "JsonClient/1.6.32", configured) {
+		t.Fatal("browser HTML request matched Xray JSON")
+	}
+
+	empty := compileUserAgentRegex("Xray JSON", "", service.DefaultSubJsonUserAgentRegex)
+	if empty != nil {
+		t.Fatal("empty Xray JSON default should not compile to a matcher")
+	}
+	if shouldAutoServeJson(true, true, false, "JsonClient/1.6.32", empty) {
+		t.Fatal("empty Xray JSON default should not auto-serve")
+	}
+}
+
+func TestShouldAutoServeJsonUsesConfiguredRegex(t *testing.T) {
+	configured := compileUserAgentRegex("Xray JSON", `(?i)^custom-json/`, service.DefaultSubJsonUserAgentRegex)
+	if !shouldAutoServeJson(true, true, false, "Custom-JSON/1.0", configured) {
+		t.Fatal("configured Xray JSON User-Agent regex did not match")
+	}
+	if shouldAutoServeJson(true, true, false, "OtherClient/1.10.0", configured) {
+		t.Fatal("unrelated User-Agent matched after a custom regex was configured")
+	}
+}
+
+func TestCompileUserAgentRegexFallsBackForInvalidPattern(t *testing.T) {
+	compiled := compileUserAgentRegex("Clash/Mihomo", "[", service.DefaultSubClashUserAgentRegex)
+	if !compiled.MatchString("Mihomo/1.19") {
+		t.Fatal("invalid regex did not fall back to the default pattern")
+	}
+}
+
+func TestSanitizeUserAgentForLog(t *testing.T) {
+	if got := sanitizeUserAgentForLog("client/1.0\r\nforged\tline"); got != "client/1.0  forged line" {
+		t.Fatalf("sanitizeUserAgentForLog() = %q", got)
+	}
+	long := strings.Repeat("界", 513)
+	if got := sanitizeUserAgentForLog(long); len([]rune(got)) != 512 {
+		t.Fatalf("sanitized User-Agent length = %d runes, want 512", len([]rune(got)))
+	}
+}
+
+func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "auto", 4480, 1, `{"network":"tcp","security":"none"}`)
+	gin.SetMode(gin.TestMode)
+
+	t.Run("recognized client receives YAML", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+		req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
+			t.Fatalf("Content-Type = %q, want YAML", got)
+		}
+		if body := resp.Body.String(); !strings.Contains(body, "proxies:") || !strings.Contains(body, "type: vless") {
+			t.Fatalf("auto-detected body is not Clash YAML:\n%s", body)
+		}
+	})
+
+	t.Run("Clash wins when both format regexes match", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+		req.Header.Set("User-Agent", "Hybrid/1.0")
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{
+			clashAutoDetect:     true,
+			clashUserAgentRegex: `(?i)^hybrid/`,
+			jsonAutoDetect:      true,
+			jsonUserAgentRegex:  `(?i)^hybrid/`,
+		}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
+			t.Fatalf("Content-Type = %q, want Clash YAML", got)
+		}
+	})
+
+	t.Run("disabled setting preserves raw base64", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+		req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
+		if err != nil {
+			t.Fatalf("raw response is not base64: %v", err)
+		}
+		if !strings.Contains(string(decoded), "vless://") {
+			t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
+		}
+	})
+
+	t.Run("configured regex controls detection", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+		req.Header.Set("User-Agent", "Mihomo/1.19")
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{
+			clashAutoDetect:     true,
+			clashUserAgentRegex: `(?i)^custom-client/`,
+		}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		if got := resp.Header().Get("Content-Type"); got == "application/yaml; charset=utf-8" {
+			t.Fatalf("Content-Type = %q, custom regex should preserve raw response", got)
+		}
+	})
+
+	t.Run("unrecognized client preserves raw base64", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+		req.Header.Set("User-Agent", "GenericClient/1.10.0")
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{
+			clashAutoDetect: true,
+			jsonAutoDetect:  true,
+		}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
+		if err != nil {
+			t.Fatalf("raw response is not base64: %v", err)
+		}
+		if !strings.Contains(string(decoded), "vless://") {
+			t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
+		}
+	})
+
+	t.Run("recognized Xray JSON client receives configuration array", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+		req.Header.Set("User-Agent", "JsonClient/1.6.32")
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{
+			jsonAutoDetect:     true,
+			jsonUserAgentRegex: `(?i)^jsonclient([ /]|$)`,
+		}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		if got := resp.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
+			t.Fatalf("Content-Type = %q, want JSON", got)
+		}
+		if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") || !strings.Contains(body, `"outbounds"`) {
+			t.Fatalf("auto-detected body is not an Xray JSON configuration array:\n%s", body)
+		}
+	})
+
+	t.Run("explicit JSON endpoint preserves legacy single object by default", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
+			t.Fatalf("Content-Type = %q, want legacy text/plain", got)
+		}
+		if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "{") {
+			t.Fatalf("legacy explicit JSON body is not an object: %s", body)
+		}
+	})
+
+	t.Run("explicit JSON endpoint can follow XTLS array standard", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{jsonAlwaysArray: true}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
+			t.Fatalf("Content-Type = %q, want legacy text/plain", got)
+		}
+		if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") {
+			t.Fatalf("standards-compliant explicit JSON body is not an array: %s", body)
+		}
+	})
+}
+
 func writeFile(t *testing.T, path, content string) {
 	t.Helper()
 	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {

+ 19 - 1
internal/sub/external_only_sub_test.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"encoding/json"
 	"strings"
 	"testing"
 
@@ -27,7 +28,8 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
 
 	base := NewSubService("")
 
-	jsonOut, _, err := NewSubJsonService("", "", "", base).GetJson("ext-only", "sub.example.com")
+	jsonService := NewSubJsonService("", "", "", base)
+	jsonOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", false)
 	if err != nil {
 		t.Fatalf("GetJson err = %v", err)
 	}
@@ -37,6 +39,22 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
 	if !strings.Contains(jsonOut, "DE-Provider") {
 		t.Fatalf("GetJson missing external remark: %s", jsonOut)
 	}
+	var config map[string]any
+	if err := json.Unmarshal([]byte(jsonOut), &config); err != nil {
+		t.Fatalf("legacy GetJson must return an object for a single profile: %v; body=%s", err, jsonOut)
+	}
+
+	standardOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", true)
+	if err != nil {
+		t.Fatalf("standards-compliant GetJson err = %v", err)
+	}
+	var configs []map[string]any
+	if err := json.Unmarshal([]byte(standardOut), &configs); err != nil {
+		t.Fatalf("standards-compliant GetJson must return an array for a single profile: %v; body=%s", err, standardOut)
+	}
+	if len(configs) != 1 {
+		t.Fatalf("standards-compliant GetJson profile count = %d, want 1", len(configs))
+	}
 
 	clashOut, _, err := NewSubClashService(false, "", base).GetClash("ext-only", "sub.example.com")
 	if err != nil {

+ 2 - 2
internal/sub/host_sub_test.go

@@ -304,7 +304,7 @@ func TestSub_HostSockoptJSON(t *testing.T) {
 		SockoptParams: `{"tcpFastOpen":true}`,
 	})
 	js := NewSubJsonService("", "", "", NewSubService(""))
-	out, _, err := js.GetJson("s1", "req.example.com")
+	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 	}
@@ -322,7 +322,7 @@ func TestSub_HostMuxJSON(t *testing.T) {
 		MuxParams: `{"enabled":true,"concurrency":8}`,
 	})
 	js := NewSubJsonService("", "", "", NewSubService(""))
-	out, _, err := js.GetJson("s1", "req.example.com")
+	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 	}

+ 2 - 3
internal/sub/json_service.go

@@ -58,7 +58,7 @@ 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) {
+func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bool) (string, string, error) {
 	subReq := s.SubService.ForRequest(host)
 	subReq.subscriptionBody = true
 	inbounds, err := subReq.getInboundsBySubId(subId)
@@ -125,9 +125,8 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
 	}
 	traffic, _ := subReq.AggregateTrafficByEmails(emails)
 
-	// Combile outbounds
 	var finalJson []byte
-	if len(configArray) == 1 {
+	if len(configArray) == 1 && !alwaysReturnArray {
 		finalJson, _ = json.MarshalIndent(configArray[0], "", "  ")
 	} else {
 		finalJson, _ = json.MarshalIndent(configArray, "", "  ")

+ 54 - 5
internal/sub/sub.go

@@ -89,6 +89,31 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		return nil, err
 	}
 
+	subClashAutoDetect, err := s.settingService.GetSubClashAutoDetect()
+	if err != nil {
+		subClashAutoDetect = false
+	}
+
+	subJsonAutoDetect, err := s.settingService.GetSubJsonAutoDetect()
+	if err != nil {
+		subJsonAutoDetect = false
+	}
+
+	subJsonAlwaysArray, err := s.settingService.GetSubJsonAlwaysArray()
+	if err != nil {
+		subJsonAlwaysArray = false
+	}
+
+	subJsonUserAgentRegex, err := s.settingService.GetSubJsonUserAgentRegex()
+	if err != nil {
+		subJsonUserAgentRegex = service.DefaultSubJsonUserAgentRegex
+	}
+
+	subClashUserAgentRegex, err := s.settingService.GetSubClashUserAgentRegex()
+	if err != nil {
+		subClashUserAgentRegex = service.DefaultSubClashUserAgentRegex
+	}
+
 	// Set base_path based on LinksPath for template rendering
 	// Ensure LinksPath ends with "/" for proper asset URL generation
 	basePath := LinksPath
@@ -239,11 +264,35 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 
 	g := engine.Group("/")
 
-	s.sub = NewSUBController(
-		g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, RemarkTemplate, SubUpdates,
-		SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
-		SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules, SubHideSettings,
-		SubIncyEnableRouting, SubIncyRoutingRules)
+	s.sub = NewSUBController(g,
+		WithSUBPath(LinksPath),
+		WithSUBJsonPath(JsonPath),
+		WithSUBClashPath(ClashPath),
+		WithSUBClashAutoDetect(subClashAutoDetect),
+		WithSUBClashUserAgentRegex(subClashUserAgentRegex),
+		WithSUBJsonAutoDetect(subJsonAutoDetect),
+		WithSUBJsonUserAgentRegex(subJsonUserAgentRegex),
+		WithSUBJsonAlwaysArray(subJsonAlwaysArray),
+		WithSUBJsonEnabled(subJsonEnable),
+		WithSUBClashEnabled(subClashEnable),
+		WithSUBEncryption(Encrypt),
+		WithSUBRemarkTemplate(RemarkTemplate),
+		WithSUBUpdateInterval(SubUpdates),
+		WithSUBJsonMux(SubJsonMux),
+		WithSUBJsonRules(SubJsonRules),
+		WithSUBJsonFinalMask(SubJsonFinalMask),
+		WithSUBClashEnableRouting(SubClashEnableRouting),
+		WithSUBClashRules(SubClashRules),
+		WithSUBTitle(SubTitle),
+		WithSUBSupportURL(SubSupportUrl),
+		WithSUBProfileURL(SubProfileUrl),
+		WithSUBAnnounce(SubAnnounce),
+		WithSUBEnableRouting(SubEnableRouting),
+		WithSUBRoutingRules(SubRoutingRules),
+		WithSUBHideSettings(SubHideSettings),
+		WithSUBIncyEnableRouting(SubIncyEnableRouting),
+		WithSUBIncyRoutingRules(SubIncyRoutingRules),
+	)
 
 	return engine, nil
 }

+ 1 - 1
internal/sub/sub_scale_test.go

@@ -212,7 +212,7 @@ func TestGetSubsScale(t *testing.T) {
 			jsonSvc := NewSubJsonService("", "", "", &SubService{})
 			start = time.Now()
 			for range reps {
-				body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com")
+				body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com", false)
 				if err != nil {
 					t.Fatalf("GetJson: %v", err)
 				}

+ 1 - 1
internal/sub/vless_route_sub_test.go

@@ -52,7 +52,7 @@ func TestSub_HostVlessRoute_JSON(t *testing.T) {
 	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "J", Address: "j.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
 
 	js := NewSubJsonService("", "", "", NewSubService(""))
-	out, _, err := js.GetJson("s1", "req.example.com")
+	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 	}

+ 19 - 0
internal/web/controller/setting.go

@@ -2,6 +2,7 @@ package controller
 
 import (
 	"errors"
+	"net/http"
 	"strconv"
 	"time"
 
@@ -38,6 +39,10 @@ type updateSettingForm struct {
 	ClearSmtpPassword bool   `json:"clearSmtpPassword" form:"clearSmtpPassword"`
 }
 
+type validateRegexForm struct {
+	Regex string `json:"regex" form:"regex"`
+}
+
 // SettingController handles settings and user management operations.
 type SettingController struct {
 	settingService  service.SettingService
@@ -61,6 +66,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
 	g.POST("/all", a.getAllSetting)
 	g.POST("/defaultSettings", a.getDefaultSettings)
 	g.POST("/update", a.updateSetting)
+	g.POST("/validateRegex", a.validateRegex)
 	g.POST("/updateUser", a.updateUser)
 	g.POST("/restartPanel", a.restartPanel)
 	g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
@@ -72,6 +78,19 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
 	g.POST("/testTgBot", a.testTgBot)
 }
 
+func (a *SettingController) validateRegex(c *gin.Context) {
+	form := &validateRegexForm{}
+	if err := c.ShouldBind(form); err != nil {
+		pureJsonMsg(c, http.StatusOK, false, err.Error())
+		return
+	}
+	if err := service.ValidateRegex(form.Regex); err != nil {
+		pureJsonMsg(c, http.StatusOK, false, err.Error())
+		return
+	}
+	pureJsonMsg(c, http.StatusOK, true, "")
+}
+
 // 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) {

+ 46 - 0
internal/web/controller/setting_test.go

@@ -0,0 +1,46 @@
+package controller
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+)
+
+func TestValidateRegex(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	NewSettingController(router.Group("/panel/api"))
+
+	tests := []struct {
+		name    string
+		body    string
+		success bool
+	}{
+		{name: "Go RE2 inline flag", body: `{"regex":"(?m)^general-purpose$"}`, success: true},
+		{name: "invalid expression", body: `{"regex":"["}`, success: false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodPost, "/panel/api/setting/validateRegex", strings.NewReader(tt.body))
+			req.Header.Set("Content-Type", "application/json")
+			resp := httptest.NewRecorder()
+
+			router.ServeHTTP(resp, req)
+
+			if resp.Code != http.StatusOK {
+				t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+			}
+			needle := `"success":true`
+			if !tt.success {
+				needle = `"success":false`
+			}
+			if !strings.Contains(resp.Body.String(), needle) {
+				t.Fatalf("body = %s, want %s", resp.Body.String(), needle)
+			}
+		})
+	}
+}

+ 29 - 0
internal/web/entity/check_valid_test.go

@@ -0,0 +1,29 @@
+package entity
+
+import "testing"
+
+func TestCheckValidSmtpFrom(t *testing.T) {
+	base := func() *AllSetting {
+		return &AllSetting{WebPort: 2053, SubPort: 2096}
+	}
+
+	for _, v := range []string{"", "[email protected]"} {
+		s := base()
+		s.SmtpFrom = v
+		if err := s.CheckValid(); err != nil {
+			t.Errorf("CheckValid with smtpFrom=%q: unexpected error %v", v, err)
+		}
+	}
+
+	for _, v := range []string{
+		"not-an-address",
+		"[email protected]\r\nBcc: [email protected]",
+		"a@b\nSubject: injected",
+	} {
+		s := base()
+		s.SmtpFrom = v
+		if err := s.CheckValid(); err == nil {
+			t.Errorf("CheckValid with smtpFrom=%q: want error, got nil", v)
+		}
+	}
+}

+ 14 - 0
internal/web/entity/entity.go

@@ -4,6 +4,7 @@ import (
 	"crypto/tls"
 	"math"
 	"net"
+	"net/mail"
 	"strings"
 	"time"
 
@@ -50,6 +51,8 @@ type AllSetting struct {
 	SmtpPort           int    `json:"smtpPort" form:"smtpPort" validate:"gte=1,lte=65535"`
 	SmtpUsername       string `json:"smtpUsername" form:"smtpUsername"`
 	SmtpPassword       string `json:"smtpPassword" form:"smtpPassword"`
+	SmtpFrom           string `json:"smtpFrom" form:"smtpFrom"`
+	SmtpFromName       string `json:"smtpFromName" form:"smtpFromName"`
 	SmtpTo             string `json:"smtpTo" form:"smtpTo"`
 	SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"`
 	SmtpEnabledEvents  string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"`
@@ -62,6 +65,11 @@ type AllSetting struct {
 
 	SubEnable                   bool   `json:"subEnable" form:"subEnable"`
 	SubJsonEnable               bool   `json:"subJsonEnable" form:"subJsonEnable"`
+	SubJsonAutoDetect           bool   `json:"subJsonAutoDetect" form:"subJsonAutoDetect"`
+	SubJsonAlwaysArray          bool   `json:"subJsonAlwaysArray" form:"subJsonAlwaysArray"`
+	SubJsonUserAgentRegex       string `json:"subJsonUserAgentRegex" form:"subJsonUserAgentRegex"`
+	SubClashAutoDetect          bool   `json:"subClashAutoDetect" form:"subClashAutoDetect"`
+	SubClashUserAgentRegex      string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
 	SubTitle                    string `json:"subTitle" form:"subTitle"`
 	SubSupportUrl               string `json:"subSupportUrl" form:"subSupportUrl"`
 	SubProfileUrl               string `json:"subProfileUrl" form:"subProfileUrl"`
@@ -241,6 +249,12 @@ func (s *AllSetting) CheckValid() error {
 		return common.NewError("time location not exist:", s.TimeLocation)
 	}
 
+	if s.SmtpFrom != "" {
+		if _, err := mail.ParseAddress(s.SmtpFrom); err != nil {
+			return common.NewError("SMTP from address is not valid:", s.SmtpFrom)
+		}
+	}
+
 	return nil
 }
 

+ 48 - 15
internal/web/service/email/email.go

@@ -2,9 +2,13 @@ package email
 
 import (
 	"context"
+	"crypto/rand"
 	"crypto/tls"
+	"encoding/hex"
 	"fmt"
+	"mime"
 	"net"
+	"net/mail"
 	"net/smtp"
 	"strings"
 	"time"
@@ -41,10 +45,15 @@ func (s *EmailService) Send(subject, body string) error {
 	}
 	username, _ := s.settingService.GetSmtpUsername()
 	password, _ := s.settingService.GetSmtpPassword()
+	fromAddr, _ := s.settingService.GetSmtpFrom()
+	fromName, _ := s.settingService.GetSmtpFromName()
 	toStr, _ := s.settingService.GetSmtpTo()
 	encryptionType, _ := s.settingService.GetSmtpEncryptionType()
 
-	from := username
+	from := fromAddr
+	if from == "" {
+		from = username
+	}
 	if from == "" {
 		return fmt.Errorf("smtp from not configured")
 	}
@@ -55,7 +64,7 @@ func (s *EmailService) Send(subject, body string) error {
 	}
 
 	addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
-	msg := buildMessage(from, recipients, subject, body)
+	msg := buildMessage(from, fromName, 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.
@@ -98,10 +107,15 @@ func (s *EmailService) TestConnection() SMTPTestResult {
 	}
 	username, _ := s.settingService.GetSmtpUsername()
 	password, _ := s.settingService.GetSmtpPassword()
+	fromAddr, _ := s.settingService.GetSmtpFrom()
+	fromName, _ := s.settingService.GetSmtpFromName()
 	toStr, _ := s.settingService.GetSmtpTo()
 	encryptionType, _ := s.settingService.GetSmtpEncryptionType()
 
-	from := username
+	from := fromAddr
+	if from == "" {
+		from = username
+	}
 
 	recipients := parseRecipients(toStr)
 	if len(recipients) == 0 {
@@ -166,7 +180,7 @@ func (s *EmailService) TestConnection() SMTPTestResult {
 		}
 	}
 
-	msg := buildMessage(from, recipients, "[3x-ui] Test email",
+	msg := buildMessage(from, fromName, 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>
@@ -280,18 +294,37 @@ func parseRecipients(toStr string) []string {
 	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",
-	}
+// buildMessage assembles an RFC 5322 message. It emits the two mandatory
+// header fields (Date, From) plus Message-ID, so strict receivers such as Gmail
+// accept it and spam filters do not penalize a missing date or message id. The
+// From header is a proper name-addr ("Name" <addr>) via net/mail, and a
+// non-ASCII subject is RFC 2047 encoded.
+// headerSanitizer drops CR/LF so a crafted address or name cannot inject extra
+// header lines. Configured addresses are already validated at save time
+// (entity.AllSetting.CheckValid), this is defense in depth for buildMessage.
+var headerSanitizer = strings.NewReplacer("\r", "", "\n", "")
+
+func buildMessage(fromAddr, fromName string, to []string, subject, body string) []byte {
+	fromAddr = headerSanitizer.Replace(fromAddr)
+	fromName = headerSanitizer.Replace(fromName)
+	from := (&mail.Address{Name: fromName, Address: fromAddr}).String()
+
+	domain := "localhost"
+	if at := strings.LastIndex(fromAddr, "@"); at >= 0 && at+1 < len(fromAddr) {
+		domain = fromAddr[at+1:]
+	}
+	var token [16]byte
+	_, _ = rand.Read(token[:])
+	messageID := fmt.Sprintf("<%s@%s>", hex.EncodeToString(token[:]), domain)
+
 	var msg strings.Builder
-	for k, v := range headers {
-		fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
-	}
+	fmt.Fprintf(&msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
+	fmt.Fprintf(&msg, "From: %s\r\n", from)
+	fmt.Fprintf(&msg, "To: %s\r\n", strings.Join(to, ", "))
+	fmt.Fprintf(&msg, "Message-ID: %s\r\n", messageID)
+	fmt.Fprintf(&msg, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
+	msg.WriteString("MIME-Version: 1.0\r\n")
+	msg.WriteString("Content-Type: text/html; charset=utf-8\r\n")
 	msg.WriteString("\r\n")
 	msg.WriteString(body)
 	return []byte(msg.String())

+ 81 - 0
internal/web/service/email/email_test.go

@@ -0,0 +1,81 @@
+package email
+
+import (
+	"io"
+	"mime"
+	"net/mail"
+	"strings"
+	"testing"
+)
+
+func TestBuildMessageIsRFC5322(t *testing.T) {
+	raw := buildMessage("[email protected]", "3x-ui", []string{"[email protected]", "[email protected]"}, "Тест", "<b>hi</b>")
+
+	msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
+	if err != nil {
+		t.Fatalf("message does not parse as RFC 5322: %v", err)
+	}
+
+	from, err := mail.ParseAddress(msg.Header.Get("From"))
+	if err != nil {
+		t.Fatalf("From header does not parse: %v", err)
+	}
+	if from.Name != "3x-ui" || from.Address != "[email protected]" {
+		t.Errorf("From = %q <%q>, want name %q addr %q", from.Name, from.Address, "3x-ui", "[email protected]")
+	}
+
+	if _, err := msg.Header.Date(); err != nil {
+		t.Errorf("Date header missing or unparseable: %v", err)
+	}
+
+	id := msg.Header.Get("Message-ID")
+	if !strings.HasPrefix(id, "<") || !strings.HasSuffix(id, "@example.com>") {
+		t.Errorf("Message-ID = %q, want <[email protected]>", id)
+	}
+
+	subject, err := (&mime.WordDecoder{}).DecodeHeader(msg.Header.Get("Subject"))
+	if err != nil {
+		t.Fatalf("Subject does not decode: %v", err)
+	}
+	if subject != "Тест" {
+		t.Errorf("Subject = %q, want %q", subject, "Тест")
+	}
+
+	body, _ := io.ReadAll(msg.Body)
+	if string(body) != "<b>hi</b>" {
+		t.Errorf("body = %q, want %q", body, "<b>hi</b>")
+	}
+}
+
+func TestBuildMessageFromWithoutName(t *testing.T) {
+	raw := buildMessage("[email protected]", "", []string{"[email protected]"}, "s", "b")
+	msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	from, err := mail.ParseAddress(msg.Header.Get("From"))
+	if err != nil {
+		t.Fatalf("From header does not parse: %v", err)
+	}
+	if from.Name != "" || from.Address != "[email protected]" {
+		t.Errorf("From = %q <%q>, want bare addr", from.Name, from.Address)
+	}
+}
+
+func TestBuildMessageStripsHeaderInjection(t *testing.T) {
+	raw := buildMessage(
+		"[email protected]\r\nBcc: [email protected]",
+		"Name\r\nX-Evil: 1",
+		[]string{"[email protected]"}, "s", "b",
+	)
+	msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	if got := msg.Header.Get("Bcc"); got != "" {
+		t.Errorf("injected Bcc header leaked: %q", got)
+	}
+	if got := msg.Header.Get("X-Evil"); got != "" {
+		t.Errorf("injected X-Evil header leaked: %q", got)
+	}
+}

+ 97 - 1
internal/web/service/setting.go

@@ -9,6 +9,7 @@ import (
 	"net/http"
 	"os"
 	"reflect"
+	"regexp"
 	"strconv"
 	"strings"
 	"time"
@@ -32,6 +33,13 @@ import (
 //go:embed config.json
 var xrayTemplateConfig string
 
+const (
+	DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)`
+	DefaultSubJsonUserAgentRegex  = ``
+	DefaultRemarkTemplate         = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
+	maxRegexLength                = 2048
+)
+
 var defaultValueMap = map[string]string{
 	"xrayTemplateConfig": xrayTemplateConfig,
 	"webListen":          "",
@@ -57,7 +65,7 @@ var defaultValueMap = map[string]string{
 	"pageSize":                    "25",
 	"expireDiff":                  "0",
 	"trafficDiff":                 "0",
-	"remarkTemplate":              "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D",
+	"remarkTemplate":              DefaultRemarkTemplate,
 	"timeLocation":                "Local",
 	"tgBotEnable":                 "false",
 	"tgBotToken":                  "",
@@ -73,6 +81,11 @@ var defaultValueMap = map[string]string{
 	"twoFactorToken":              "",
 	"subEnable":                   "true",
 	"subJsonEnable":               "false",
+	"subJsonAutoDetect":           "false",
+	"subJsonAlwaysArray":          "false",
+	"subJsonUserAgentRegex":       "",
+	"subClashAutoDetect":          "false",
+	"subClashUserAgentRegex":      "",
 	"subTitle":                    "",
 	"subSupportUrl":               "",
 	"subProfileUrl":               "",
@@ -148,6 +161,8 @@ var defaultValueMap = map[string]string{
 	"smtpPort":           "587",
 	"smtpUsername":       "",
 	"smtpPassword":       "",
+	"smtpFrom":           "",
+	"smtpFromName":       "",
 	"smtpTo":             "",
 	"smtpEncryptionType": "starttls", // no, starttls, tls
 }
@@ -705,6 +720,26 @@ func (s *SettingService) GetSubJsonEnable() (bool, error) {
 	return s.getBool("subJsonEnable")
 }
 
+func (s *SettingService) GetSubJsonAutoDetect() (bool, error) {
+	return s.getBool("subJsonAutoDetect")
+}
+
+func (s *SettingService) GetSubJsonAlwaysArray() (bool, error) {
+	return s.getBool("subJsonAlwaysArray")
+}
+
+func (s *SettingService) GetSubJsonUserAgentRegex() (string, error) {
+	return s.getString("subJsonUserAgentRegex")
+}
+
+func (s *SettingService) GetSubClashAutoDetect() (bool, error) {
+	return s.getBool("subClashAutoDetect")
+}
+
+func (s *SettingService) GetSubClashUserAgentRegex() (string, error) {
+	return s.getString("subClashUserAgentRegex")
+}
+
 func (s *SettingService) GetSubTitle() (string, error) {
 	return s.getString("subTitle")
 }
@@ -1045,6 +1080,22 @@ func (s *SettingService) SetSmtpUsername(value string) error {
 	return s.setString("smtpUsername", value)
 }
 
+func (s *SettingService) GetSmtpFrom() (string, error) {
+	return s.getString("smtpFrom")
+}
+
+func (s *SettingService) SetSmtpFrom(value string) error {
+	return s.setString("smtpFrom", value)
+}
+
+func (s *SettingService) GetSmtpFromName() (string, error) {
+	return s.getString("smtpFromName")
+}
+
+func (s *SettingService) SetSmtpFromName(value string) error {
+	return s.setString("smtpFromName", value)
+}
+
 func (s *SettingService) GetSmtpPassword() (string, error) {
 	return s.getString("smtpPassword")
 }
@@ -1101,6 +1152,9 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears
 	if err := validateSettingsURLs(allSetting); err != nil {
 		return err
 	}
+	if err := validateSubUserAgentRegexes(allSetting); err != nil {
+		return err
+	}
 	if err := allSetting.CheckValid(); err != nil {
 		return err
 	}
@@ -1141,6 +1195,48 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears
 	})
 }
 
+func validateSubUserAgentRegexes(allSetting *entity.AllSetting) error {
+	jsonPattern, err := validateSubUserAgentRegex("Xray JSON", allSetting.SubJsonUserAgentRegex, DefaultSubJsonUserAgentRegex)
+	if err != nil {
+		return err
+	}
+	clashPattern, err := validateSubUserAgentRegex("Clash/Mihomo", allSetting.SubClashUserAgentRegex, DefaultSubClashUserAgentRegex)
+	if err != nil {
+		return err
+	}
+	allSetting.SubJsonUserAgentRegex = jsonPattern
+	allSetting.SubClashUserAgentRegex = clashPattern
+	return nil
+}
+
+func validateSubUserAgentRegex(name, pattern, defaultPattern string) (string, error) {
+	pattern = strings.TrimSpace(pattern)
+	effectivePattern := pattern
+	if effectivePattern == "" {
+		effectivePattern = defaultPattern
+	}
+	if len(effectivePattern) > maxRegexLength {
+		return "", common.NewErrorf("%s User-Agent regex must not exceed %d characters", name, maxRegexLength)
+	}
+	if _, err := regexp.Compile(effectivePattern); err != nil {
+		return "", common.NewErrorf("%s User-Agent regex is invalid: %v", name, err)
+	}
+	// Return the original pattern (empty string if cleared) so the caller
+	// can distinguish "user explicitly set empty" from "user set a value".
+	// The empty value is stored in the DB and inherited as runtime default.
+	return pattern, nil
+}
+
+func ValidateRegex(pattern string) error {
+	if len(pattern) > maxRegexLength {
+		return common.NewErrorf("Regular expression must not exceed %d characters", maxRegexLength)
+	}
+	if _, err := regexp.Compile(pattern); err != nil {
+		return common.NewError("Regular expression is invalid:", err)
+	}
+	return nil
+}
+
 func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, clears SecretClears) error {
 	if !clears.TgBotToken && strings.TrimSpace(allSetting.TgBotToken) == "" {
 		value, err := s.GetTgBotToken()

+ 207 - 0
internal/web/service/setting_clash_test.go

@@ -0,0 +1,207 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestValidateRegex(t *testing.T) {
+	for _, pattern := range []string{"", `(?i)^jsonclient([ /]|$)`, `(?m)^general-purpose$`} {
+		if err := ValidateRegex(pattern); err != nil {
+			t.Errorf("ValidateRegex(%q) returned %v", pattern, err)
+		}
+	}
+	for _, pattern := range []string{"[", strings.Repeat("a", 2049)} {
+		if err := ValidateRegex(pattern); err == nil {
+			t.Errorf("ValidateRegex(%q) accepted an invalid pattern", pattern)
+		}
+	}
+}
+
+func TestSubscriptionAutoDetectDefaultsWithoutStoredRows(t *testing.T) {
+	setupSettingTestDB(t)
+	keys := []string{"subClashAutoDetect", "subClashUserAgentRegex", "subJsonAutoDetect", "subJsonAlwaysArray", "subJsonUserAgentRegex"}
+	if err := database.GetDB().Where("key IN ?", keys).Delete(&model.Setting{}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	s := &SettingService{}
+	clashEnabled, err := s.GetSubClashAutoDetect()
+	if err != nil {
+		t.Fatal(err)
+	}
+	jsonEnabled, err := s.GetSubJsonAutoDetect()
+	if err != nil {
+		t.Fatal(err)
+	}
+	jsonAlwaysArray, err := s.GetSubJsonAlwaysArray()
+	if err != nil {
+		t.Fatal(err)
+	}
+	clashRegex, err := s.GetSubClashUserAgentRegex()
+	if err != nil {
+		t.Fatal(err)
+	}
+	jsonRegex, err := s.GetSubJsonUserAgentRegex()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if clashEnabled || jsonEnabled || jsonAlwaysArray {
+		t.Fatalf("missing subscription flags must default off: clashAuto=%v jsonAuto=%v jsonAlwaysArray=%v", clashEnabled, jsonEnabled, jsonAlwaysArray)
+	}
+	if clashRegex != "" {
+		t.Fatalf("missing Clash regex = %q, want empty inherited value", clashRegex)
+	}
+	if jsonRegex != "" {
+		t.Fatalf("missing JSON regex = %q, want empty inherited value", jsonRegex)
+	}
+
+	var count int64
+	if err := database.GetDB().Model(&model.Setting{}).Where("key IN ?", keys).Count(&count).Error; err != nil {
+		t.Fatal(err)
+	}
+	if count != 0 {
+		t.Fatalf("default lookup unexpectedly persisted %d setting rows", count)
+	}
+}
+
+func TestUpdateAllSettingPreservesEmptyUserAgentRegexes(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	settings.SubJsonUserAgentRegex = "   "
+	settings.SubClashUserAgentRegex = ""
+
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
+		t.Fatal(err)
+	}
+
+	for _, key := range []string{"subJsonUserAgentRegex", "subClashUserAgentRegex"} {
+		var stored model.Setting
+		if err := database.GetDB().Where("key = ?", key).First(&stored).Error; err != nil {
+			t.Fatal(err)
+		}
+		if stored.Value != "" {
+			t.Fatalf("%s stored value = %q, want empty inherited value", key, stored.Value)
+		}
+	}
+}
+
+func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if settings.SubClashAutoDetect {
+		t.Fatal("subClashAutoDetect default = true, want false")
+	}
+	if settings.SubJsonAutoDetect {
+		t.Fatal("subJsonAutoDetect default = true, want false")
+	}
+	if settings.SubJsonAlwaysArray {
+		t.Fatal("subJsonAlwaysArray default = true, want false")
+	}
+	if settings.SubJsonUserAgentRegex != "" {
+		t.Fatalf("subJsonUserAgentRegex = %q, want empty inherited value", settings.SubJsonUserAgentRegex)
+	}
+	if settings.SubClashUserAgentRegex != "" {
+		t.Fatalf("subClashUserAgentRegex = %q, want empty inherited value", settings.SubClashUserAgentRegex)
+	}
+	settings.SubClashAutoDetect = true
+	settings.SubClashUserAgentRegex = `(?i)^custom-clash/`
+	settings.SubJsonAutoDetect = true
+	settings.SubJsonAlwaysArray = true
+	settings.SubJsonUserAgentRegex = `(?i)^custom-json/`
+	settings.SubJsonEnable = true
+	settings.SubJsonPath = "/json-custom/"
+	settings.SubJsonURI = "https://subscriptions.example.com/json-custom/"
+	settings.SubClashEnable = true
+	settings.SubClashPath = "/clash-custom/"
+	settings.SubClashURI = "https://subscriptions.example.com/clash-custom/"
+	settings.SubClashEnableRouting = true
+	settings.SubClashRules = "GEOIP,private,DIRECT"
+
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !got.SubClashEnable {
+		t.Fatal("subClashEnable = false, want true")
+	}
+	if !got.SubClashAutoDetect {
+		t.Fatal("subClashAutoDetect = false, want true")
+	}
+	if !got.SubJsonAutoDetect {
+		t.Fatal("subJsonAutoDetect = false, want true")
+	}
+	if !got.SubJsonAlwaysArray {
+		t.Fatal("subJsonAlwaysArray = false, want true")
+	}
+	if !got.SubJsonEnable {
+		t.Fatal("subJsonEnable = false, want true")
+	}
+	if got.SubJsonPath != "/json-custom/" {
+		t.Fatalf("subJsonPath = %q, want %q", got.SubJsonPath, "/json-custom/")
+	}
+	if got.SubJsonURI != "https://subscriptions.example.com/json-custom/" {
+		t.Fatalf("subJsonURI = %q, want %q", got.SubJsonURI, "https://subscriptions.example.com/json-custom/")
+	}
+	if got.SubJsonUserAgentRegex != `(?i)^custom-json/` {
+		t.Fatalf("subJsonUserAgentRegex = %q, want %q", got.SubJsonUserAgentRegex, `(?i)^custom-json/`)
+	}
+	if got.SubClashUserAgentRegex != `(?i)^custom-clash/` {
+		t.Fatalf("subClashUserAgentRegex = %q, want %q", got.SubClashUserAgentRegex, `(?i)^custom-clash/`)
+	}
+	if got.SubClashPath != "/clash-custom/" {
+		t.Fatalf("subClashPath = %q, want %q", got.SubClashPath, "/clash-custom/")
+	}
+	if got.SubClashURI != "https://subscriptions.example.com/clash-custom/" {
+		t.Fatalf("subClashURI = %q, want %q", got.SubClashURI, "https://subscriptions.example.com/clash-custom/")
+	}
+	if !got.SubClashEnableRouting {
+		t.Fatal("subClashEnableRouting = false, want true")
+	}
+	if got.SubClashRules != "GEOIP,private,DIRECT" {
+		t.Fatalf("subClashRules = %q, want %q", got.SubClashRules, "GEOIP,private,DIRECT")
+	}
+}
+
+func TestUpdateAllSettingRejectsInvalidClashUserAgentRegex(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	settings.SubClashUserAgentRegex = "["
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil {
+		t.Fatal("UpdateAllSetting accepted an invalid Clash/Mihomo User-Agent regex")
+	}
+}
+
+func TestUpdateAllSettingRejectsInvalidJsonUserAgentRegex(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	settings.SubJsonUserAgentRegex = "["
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil {
+		t.Fatal("UpdateAllSetting accepted an invalid Xray JSON User-Agent regex")
+	}
+}

+ 18 - 1
internal/web/translation/ar-EG.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "تمكين/تعطيل نقطة نهاية اشتراك JSON بشكل مستقل.",
       "subJsonEnableTitle": "اشتراك JSON",
       "subClashEnableTitle": "اشتراك Clash / Mihomo",
+      "subFormatsTipTitle": "إعدادات الاشتراك الخاصة بالتنسيق",
+      "subFormatsTipDesc": "اضبط مسارات URL وعناوين URL العكسية والاكتشاف التلقائي للعملاء لكل من JSON وClash / Mihomo بشكل منفصل.",
+      "subFormatsTipAction": "فتح تنسيقات الاشتراك",
+      "subJsonAutoDetect": "اكتشاف عملاء Xray JSON تلقائيًا",
+      "subJsonAutoDetectDesc": "عند التفعيل، يتلقى العملاء المتوافقون المعروفون الذين يطلبون رابط الاشتراك القياسي مصفوفة إعدادات Xray JSON تلقائيًا. يحصل العملاء الآخرون على الاستجابة الخام/Base64. يتطلب تفعيل اشتراك JSON وإعادة تشغيل اللوحة.",
+      "subJsonAlwaysArray": "إرجاع مصفوفة JSON دائمًا",
+      "subJsonAlwaysArrayDesc": "يعيد مسار اشتراك JSON الصريح كمصفوفة حتى عند وجود ملف واحد، وفقًا لمعيار XTLS. تستخدم استجابات JSON المكتشفة تلقائيًا المصفوفات دائمًا. عطّله للاحتفاظ باستجابة الكائن الواحد القديمة.",
+      "subJsonUserAgentRegex": "تعبير User-Agent لعملاء Xray JSON",
+      "subJsonUserAgentRegexDesc": "تعبير Go RE2 منتظم يُطابَق مع وكيل المستخدم (User-Agent) لاختيار صيغة Xray JSON تلقائيًا على رابط الاشتراك القياسي. فارغ افتراضيًا، لذا يبقى الاكتشاف التلقائي معطلاً حتى تحدد نمطًا للعملاء الذين تريد خدمتهم. يحصل العملاء الآخرون على الاستجابة الخام/Base64. أعد تشغيل اللوحة بعد التغيير.",
+      "subClashAutoDetect": "اكتشاف عملاء Clash/Mihomo تلقائيًا",
+      "subClashAutoDetectDesc": "عند التفعيل، يتلقى عملاء Clash/Mihomo المعروفون الذين يطلبون رابط الاشتراك القياسي إعداد Clash بصيغة YAML تلقائيًا. تستمر المتصفحات في عرض صفحة الاشتراك، ويحصل العملاء الآخرون على الاستجابة الخام/Base64، وتبقى روابط JSON وClash الصريحة متاحة. يتطلب تفعيل اشتراك Clash/Mihomo وإعادة تشغيل اللوحة لتطبيق التغيير.",
+      "subClashUserAgentRegex": "تعبير User-Agent لعملاء Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "تعبير Go RE2 منتظم يُطابَق مع وكيل المستخدم (User-Agent) للتعرف على عملاء Clash/Mihomo في رابط الاشتراك القياسي. اتركه فارغًا لاستخدام النمط الافتراضي. أعد تشغيل اللوحة بعد التغيير.",
       "subTitle": "عنوان الاشتراك",
       "subTitleDesc": "العنوان اللي هيظهر في عميل VPN",
       "subSupportUrl": "رابط الدعم",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "أقنعة finalmask في xray (TCP/UDP) وضبط QUIC تُضاف إلى كل تدفق اشتراك JSON. يتطلب إصدار xray حديثًا على العميل.",
+        "finalMaskDesc": "يضيف أقنعة TCP/UDP الخاصة بـ Xray finalmask ومعلمات QUIC إلى كل ملف Xray JSON يتم إنشاؤه. يتطلب تطبيقًا يدعم اشتراكات Xray JSON وإصدارًا حديثًا من نواة Xray.",
         "packets": "الحزم",
         "length": "الطول",
         "interval": "الفاصل",
@@ -1358,6 +1371,10 @@
       "smtpPortDesc": "منفذ خادم SMTP (الافتراضي: 587)",
       "smtpUsername": "اسم مستخدم SMTP",
       "smtpUsernameDesc": "اسم المستخدم للمصادقة على SMTP",
+      "smtpFrom": "عنوان المُرسِل (From)",
+      "smtpFromDesc": "العنوان المستخدم في ترويسة From للبريد. اتركه فارغًا لاستخدام اسم المستخدم.",
+      "smtpFromName": "اسم المُرسِل (From)",
+      "smtpFromNameDesc": "اسم عرض اختياري يظهر قبل العنوان في ترويسة From.",
       "smtpPassword": "كلمة مرور SMTP",
       "smtpPasswordDesc": "كلمة المرور للمصادقة على SMTP",
       "smtpTo": "المستلمون",

+ 18 - 1
internal/web/translation/en-US.json

@@ -1310,6 +1310,19 @@
       "subJsonEnable": "Enable/Disable the JSON subscription endpoint independently.",
       "subJsonEnableTitle": "JSON subscription",
       "subClashEnableTitle": "Clash / Mihomo subscription",
+      "subFormatsTipTitle": "Format-specific subscription settings",
+      "subFormatsTipDesc": "Configure JSON and Clash / Mihomo URL paths, reverse URLs, and client auto-detection separately.",
+      "subFormatsTipAction": "Open Sub Formats",
+      "subJsonAutoDetect": "Auto-detect Xray JSON clients",
+      "subJsonAutoDetectDesc": "When enabled, recognized compatible clients requesting the standard subscription URL receive an Xray JSON configuration array automatically. Other clients keep the raw/base64 response. Requires JSON subscription to be enabled and a panel restart to apply.",
+      "subJsonAlwaysArray": "Always return a JSON array",
+      "subJsonAlwaysArrayDesc": "Return the explicit JSON subscription endpoint as an array even when it contains one profile, as required by the XTLS subscription standard. Auto-detected JSON responses always use arrays. Leave disabled to preserve the legacy single-object response.",
+      "subJsonUserAgentRegex": "Xray JSON User-Agent regex",
+      "subJsonUserAgentRegexDesc": "Go RE2 regular expression matched against the client's User-Agent to auto-select the Xray JSON format on the standard subscription URL. Empty by default, so auto-detection stays off until you set a pattern for the clients you want to serve. Other clients keep the raw/base64 response. Restart the panel after changes.",
+      "subClashAutoDetect": "Auto-detect Clash/Mihomo clients",
+      "subClashAutoDetectDesc": "When enabled, recognized Clash/Mihomo clients requesting the standard subscription URL receive Clash YAML automatically. Browsers still show the subscription page, other clients keep the raw/base64 response, and the explicit JSON and Clash URLs remain available. Requires Clash/Mihomo subscription to be enabled and a panel restart to apply.",
+      "subClashUserAgentRegex": "Clash/Mihomo User-Agent regex",
+      "subClashUserAgentRegexDesc": "Go RE2 regular expression matched against the client's User-Agent to recognize Clash/Mihomo clients on the standard subscription URL. Leave empty to use the default pattern. Restart the panel after changes.",
       "subTitle": "Subscription Title",
       "subTitleDesc": "Title shown in VPN client",
       "subSupportUrl": "Support URL",
@@ -1400,7 +1413,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "xray finalmask masks (TCP/UDP) and QUIC tuning injected into every JSON subscription stream. Requires a recent xray client.",
+        "finalMaskDesc": "Inject Xray finalmask TCP/UDP masks and QUIC parameters into every generated Xray JSON profile. Requires a client app that supports Xray JSON subscriptions and a recent Xray core.",
         "packets": "Packets",
         "length": "Length",
         "interval": "Interval",
@@ -1477,6 +1490,10 @@
       "smtpPortDesc": "SMTP server port (default: 587)",
       "smtpUsername": "SMTP Username",
       "smtpUsernameDesc": "SMTP authentication username",
+      "smtpFrom": "SMTP From Address",
+      "smtpFromDesc": "Sender address used in the email From header. Leave empty to use the username.",
+      "smtpFromName": "SMTP Sender Name",
+      "smtpFromNameDesc": "Optional display name shown before the sender address in the From header.",
       "smtpPassword": "SMTP Password",
       "smtpPasswordDesc": "SMTP authentication password",
       "smtpTo": "Recipients",

+ 18 - 1
internal/web/translation/es-ES.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "Habilitar/Deshabilitar el endpoint de suscripción JSON de forma independiente.",
       "subJsonEnableTitle": "Suscripción JSON",
       "subClashEnableTitle": "Suscripción Clash / Mihomo",
+      "subFormatsTipTitle": "Configuración de suscripción específica por formato",
+      "subFormatsTipDesc": "Configura por separado las rutas URL, las URL inversas y la detección automática de clientes para JSON y Clash / Mihomo.",
+      "subFormatsTipAction": "Abrir formatos de suscripción",
+      "subJsonAutoDetect": "Detectar automáticamente clientes Xray JSON",
+      "subJsonAutoDetectDesc": "Al activarlo, los clientes compatibles reconocidos que soliciten la URL de suscripción estándar recibirán automáticamente una matriz de configuraciones Xray JSON. Los demás clientes conservarán la respuesta sin procesar/Base64. Requiere activar la suscripción JSON y reiniciar el panel.",
+      "subJsonAlwaysArray": "Devolver siempre una matriz JSON",
+      "subJsonAlwaysArrayDesc": "Devuelve el endpoint JSON explícito como una matriz incluso con un solo perfil, según el estándar XTLS. Las respuestas JSON detectadas automáticamente siempre usan matrices. Desactívalo para conservar la respuesta heredada de un solo objeto.",
+      "subJsonUserAgentRegex": "Expresión User-Agent de Xray JSON",
+      "subJsonUserAgentRegexDesc": "Expresión regular Go RE2 que se compara con el User-Agent del cliente para seleccionar automáticamente el formato Xray JSON en la URL de suscripción estándar. Vacía de forma predeterminada, por lo que la detección automática permanece desactivada hasta que definas un patrón para los clientes que quieras atender. Los demás clientes conservan la respuesta sin procesar/Base64. Reinicia el panel después de cambiarla.",
+      "subClashAutoDetect": "Detectar automáticamente clientes Clash/Mihomo",
+      "subClashAutoDetectDesc": "Al activarlo, los clientes Clash/Mihomo reconocidos que soliciten la URL de suscripción estándar recibirán automáticamente YAML de Clash. Los navegadores seguirán mostrando la página de suscripción, los demás clientes conservarán la respuesta sin procesar/Base64 y las URL explícitas de JSON y Clash seguirán disponibles. Requiere activar la suscripción Clash/Mihomo y reiniciar el panel para aplicar el cambio.",
+      "subClashUserAgentRegex": "Expresión User-Agent de Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "Expresión regular Go RE2 que se compara con el User-Agent del cliente para reconocer clientes Clash/Mihomo en la URL de suscripción estándar. Déjala vacía para usar el patrón predeterminado. Reinicia el panel después de cambiarla.",
       "subTitle": "Título de la Suscripción",
       "subTitleDesc": "Título mostrado en el cliente VPN",
       "subSupportUrl": "URL de soporte",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "Máscaras finalmask de xray (TCP/UDP) y ajustes QUIC inyectados en cada flujo de suscripción JSON. Requiere un cliente xray reciente.",
+        "finalMaskDesc": "Inyecta máscaras TCP/UDP de finalmask de Xray y parámetros QUIC en cada perfil Xray JSON generado. Requiere una aplicación compatible con suscripciones Xray JSON y un núcleo Xray reciente.",
         "packets": "Paquetes",
         "length": "Longitud",
         "interval": "Intervalo",
@@ -1358,6 +1371,10 @@
       "smtpPortDesc": "Puerto del servidor SMTP (predeterminado: 587)",
       "smtpUsername": "Usuario SMTP",
       "smtpUsernameDesc": "Usuario de autenticación SMTP",
+      "smtpFrom": "Dirección de remitente (From)",
+      "smtpFromDesc": "Dirección usada en el encabezado From del correo. Déjelo vacío para usar el nombre de usuario.",
+      "smtpFromName": "Nombre del remitente (From)",
+      "smtpFromNameDesc": "Nombre para mostrar opcional antes de la dirección en el encabezado From.",
       "smtpPassword": "Contraseña SMTP",
       "smtpPasswordDesc": "Contraseña de autenticación SMTP",
       "smtpTo": "Destinatarios",

+ 18 - 1
internal/web/translation/fa-IR.json

@@ -1193,6 +1193,19 @@
       "subJsonEnable": "فعال/غیرفعال‌سازی مستقل نقطه دسترسی سابسکریپشن JSON.",
       "subJsonEnableTitle": "اشتراک JSON",
       "subClashEnableTitle": "اشتراک Clash / Mihomo",
+      "subFormatsTipTitle": "تنظیمات اشتراک ویژه هر قالب",
+      "subFormatsTipDesc": "مسیرهای URL، آدرس‌های معکوس و تشخیص خودکار کلاینت را برای JSON و Clash / Mihomo جداگانه پیکربندی کنید.",
+      "subFormatsTipAction": "باز کردن قالب‌های اشتراک",
+      "subJsonAutoDetect": "تشخیص خودکار کلاینت‌های Xray JSON",
+      "subJsonAutoDetectDesc": "با فعال‌سازی، کلاینت‌های سازگار و شناخته‌شده که آدرس استاندارد اشتراک را درخواست می‌کنند، به‌طور خودکار آرایه تنظیمات Xray JSON دریافت می‌کنند. سایر کلاینت‌ها پاسخ خام/Base64 را دریافت می‌کنند. برای اعمال تغییر، اشتراک JSON باید فعال باشد و پنل راه‌اندازی مجدد شود.",
+      "subJsonAlwaysArray": "همیشه آرایه JSON برگردان",
+      "subJsonAlwaysArrayDesc": "مسیر صریح اشتراک JSON را حتی برای یک پروفایل، مطابق استاندارد XTLS به‌صورت آرایه برمی‌گرداند. پاسخ‌های JSON تشخیص‌داده‌شده همیشه آرایه هستند. برای حفظ پاسخ قدیمی تک‌شیء غیرفعال بگذارید.",
+      "subJsonUserAgentRegex": "عبارت User-Agent برای Xray JSON",
+      "subJsonUserAgentRegexDesc": "عبارت منظم Go RE2 که با عامل کاربر (User-Agent) کلاینت مطابقت داده می‌شود تا صیغه Xray JSON روی آدرس استاندارد اشتراک به‌طور خودکار انتخاب شود. به‌طور پیش‌فرض خالی است، بنابراین تشخیص خودکار غیرفعال می‌ماند تا زمانی که الگویی برای کلاینت‌های موردنظر خود تعیین کنید. سایر کلاینت‌ها پاسخ خام/Base64 را دریافت می‌کنند. پس از تغییر، پنل را راه‌اندازی مجدد کنید.",
+      "subClashAutoDetect": "تشخیص خودکار کلاینت‌های Clash/Mihomo",
+      "subClashAutoDetectDesc": "با فعال‌سازی، کلاینت‌های شناخته‌شده Clash/Mihomo که آدرس استاندارد اشتراک را درخواست می‌کنند، به‌طور خودکار پیکربندی Clash با فرمت YAML دریافت می‌کنند. مرورگرها همچنان صفحه اشتراک را نمایش می‌دهند، سایر کلاینت‌ها پاسخ خام/Base64 را دریافت می‌کنند و آدرس‌های صریح JSON و Clash در دسترس می‌مانند. برای اعمال تغییر، اشتراک Clash/Mihomo باید فعال باشد و پنل راه‌اندازی مجدد شود.",
+      "subClashUserAgentRegex": "عبارت User-Agent برای Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "عبارت منظم Go RE2 که با عامل کاربر (User-Agent) کلاینت مطابقت داده می‌شود تا کلاینت‌های Clash/Mihomo در آدرس استاندارد اشتراک شناسایی شوند. برای استفاده از الگوی پیش‌فرض خالی بگذارید. پس از تغییر، پنل را راه‌اندازی مجدد کنید.",
       "subTitle": "عنوان اشتراک",
       "subTitleDesc": "عنوان نمایش داده شده در کلاینت VPN",
       "subSupportUrl": "آدرس پشتیبانی",
@@ -1283,7 +1296,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "ماسک‌های finalmask ایکس‌ری (TCP/UDP) و تنظیمات QUIC که داخل همه‌ی stream های اشتراک JSON تزریق می‌شوند. به نسخه‌ی جدید هسته‌ی xray در کلاینت نیاز دارد.",
+        "finalMaskDesc": "ماسک‌های TCP/UDP مربوط به Xray finalmask و پارامترهای QUIC را به هر پروفایل Xray JSON تولیدشده اضافه می‌کند. به برنامه‌ای با پشتیبانی از اشتراک Xray JSON و هسته جدید Xray نیاز دارد.",
         "packets": "بسته‌ها",
         "length": "طول",
         "interval": "بازه",
@@ -1360,6 +1373,10 @@
       "smtpPortDesc": "پورت سرور SMTP (پیش‌فرض: ۵۸۷)",
       "smtpUsername": "نام‌کاربری SMTP",
       "smtpUsernameDesc": "نام‌کاربری احراز هویت SMTP",
+      "smtpFrom": "آدرس فرستنده (From)",
+      "smtpFromDesc": "آدرس استفاده‌شده در سرآیند From ایمیل. برای استفاده از نام کاربری، خالی بگذارید.",
+      "smtpFromName": "نام فرستنده (From)",
+      "smtpFromNameDesc": "نام نمایشی اختیاری پیش از آدرس در سرآیند From.",
       "smtpPassword": "رمز عبور SMTP",
       "smtpPasswordDesc": "رمز عبور احراز هویت SMTP",
       "smtpTo": "گیرندگان",

+ 18 - 1
internal/web/translation/id-ID.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "Aktifkan/Nonaktifkan endpoint langganan JSON secara mandiri.",
       "subJsonEnableTitle": "Langganan JSON",
       "subClashEnableTitle": "Langganan Clash / Mihomo",
+      "subFormatsTipTitle": "Pengaturan langganan khusus format",
+      "subFormatsTipDesc": "Konfigurasikan jalur URL, URL proksi balik, dan deteksi otomatis klien untuk JSON serta Clash / Mihomo secara terpisah.",
+      "subFormatsTipAction": "Buka Format Langganan",
+      "subJsonAutoDetect": "Deteksi otomatis klien Xray JSON",
+      "subJsonAutoDetectDesc": "Jika diaktifkan, klien kompatibel yang dikenali dan meminta URL langganan standar akan otomatis menerima larik konfigurasi Xray JSON. Klien lain tetap menerima respons mentah/Base64. Langganan JSON harus diaktifkan dan panel harus dimulai ulang.",
+      "subJsonAlwaysArray": "Selalu kembalikan larik JSON",
+      "subJsonAlwaysArrayDesc": "Mengembalikan endpoint langganan JSON eksplisit sebagai larik meski hanya berisi satu profil, sesuai standar XTLS. Respons JSON yang terdeteksi otomatis selalu menggunakan larik. Nonaktifkan untuk mempertahankan respons objek tunggal lama.",
+      "subJsonUserAgentRegex": "Regex User-Agent Xray JSON",
+      "subJsonUserAgentRegexDesc": "Ekspresi reguler Go RE2 yang dicocokkan dengan User-Agent klien untuk memilih format Xray JSON secara otomatis pada URL langganan standar. Kosong secara bawaan, sehingga deteksi otomatis tetap nonaktif hingga Anda menetapkan pola untuk klien yang ingin dilayani. Klien lain tetap menerima respons mentah/Base64. Mulai ulang panel setelah mengubahnya.",
+      "subClashAutoDetect": "Deteksi otomatis klien Clash/Mihomo",
+      "subClashAutoDetectDesc": "Jika diaktifkan, klien Clash/Mihomo yang dikenali dan meminta URL langganan standar akan otomatis menerima YAML Clash. Browser tetap menampilkan halaman langganan, klien lain tetap menerima respons mentah/Base64, dan URL JSON serta Clash eksplisit tetap tersedia. Langganan Clash/Mihomo harus diaktifkan dan panel harus dimulai ulang agar perubahan diterapkan.",
+      "subClashUserAgentRegex": "Regex User-Agent Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "Ekspresi reguler Go RE2 yang dicocokkan dengan User-Agent klien untuk mengenali klien Clash/Mihomo pada URL langganan standar. Kosongkan untuk memakai pola bawaan. Mulai ulang panel setelah mengubahnya.",
       "subTitle": "Judul Langganan",
       "subTitleDesc": "Judul yang ditampilkan di klien VPN",
       "subSupportUrl": "URL Dukungan",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "Mask finalmask xray (TCP/UDP) dan penyetelan QUIC yang disuntikkan ke setiap stream langganan JSON. Membutuhkan klien xray terbaru.",
+        "finalMaskDesc": "Menyisipkan mask TCP/UDP Xray finalmask dan parameter QUIC ke setiap profil Xray JSON yang dibuat. Membutuhkan aplikasi klien yang mendukung langganan Xray JSON dan inti Xray terbaru.",
         "packets": "Paket",
         "length": "Panjang",
         "interval": "Interval",
@@ -1358,6 +1371,10 @@
       "smtpPortDesc": "Port server SMTP (bawaan: 587)",
       "smtpUsername": "Nama Pengguna SMTP",
       "smtpUsernameDesc": "Nama pengguna autentikasi SMTP",
+      "smtpFrom": "Alamat Pengirim (From)",
+      "smtpFromDesc": "Alamat yang dipakai pada header From email. Kosongkan untuk memakai nama pengguna.",
+      "smtpFromName": "Nama Pengirim (From)",
+      "smtpFromNameDesc": "Nama tampilan opsional sebelum alamat pada header From.",
       "smtpPassword": "Kata Sandi SMTP",
       "smtpPasswordDesc": "Kata sandi autentikasi SMTP",
       "smtpTo": "Penerima",

+ 18 - 1
internal/web/translation/ja-JP.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "JSON サブスクリプションのエンドポイントを個別に有効/無効にする。",
       "subJsonEnableTitle": "JSON サブスクリプション",
       "subClashEnableTitle": "Clash / Mihomo サブスクリプション",
+      "subFormatsTipTitle": "形式別サブスクリプション設定",
+      "subFormatsTipDesc": "JSON と Clash / Mihomo の URL パス、リバース URL、クライアント自動検出を個別に設定します。",
+      "subFormatsTipAction": "サブスクリプション形式を開く",
+      "subJsonAutoDetect": "Xray JSON クライアントを自動検出",
+      "subJsonAutoDetectDesc": "有効にすると、標準サブスクリプション URL を要求する既知の互換クライアントへ Xray JSON 設定配列を自動的に返します。その他のクライアントには従来の raw/Base64 応答を返します。JSON サブスクリプションを有効にし、適用のためにパネルを再起動する必要があります。",
+      "subJsonAlwaysArray": "常に JSON 配列を返す",
+      "subJsonAlwaysArrayDesc": "プロファイルが 1 件でも、明示的な JSON サブスクリプションを XTLS 標準に従う配列として返します。自動検出された JSON 応答は常に配列です。無効時は従来の単一オブジェクト応答を維持します。",
+      "subJsonUserAgentRegex": "Xray JSON User-Agent 正規表現",
+      "subJsonUserAgentRegexDesc": "標準サブスクリプション URL で Xray JSON 形式を自動選択するため、クライアントの User-Agent と照合する Go RE2 正規表現です。既定では空欄のため、対象とするクライアント向けのパターンを設定するまで自動判別は無効のままです。その他のクライアントには従来の raw/Base64 応答を返します。変更後にパネルを再起動してください。",
+      "subClashAutoDetect": "Clash/Mihomo クライアントを自動検出",
+      "subClashAutoDetectDesc": "有効にすると、標準サブスクリプション URL を要求する既知の Clash/Mihomo クライアントへ Clash YAML を自動的に返します。ブラウザには引き続きサブスクリプションページを表示し、その他のクライアントには従来の raw/Base64 応答を返します。明示的な JSON および Clash URL も引き続き利用できます。Clash/Mihomo サブスクリプションを有効にし、適用のためにパネルを再起動する必要があります。",
+      "subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表現",
+      "subClashUserAgentRegexDesc": "標準サブスクリプション URL で Clash/Mihomo クライアントを識別するため、クライアントの User-Agent と照合する Go RE2 正規表現です。空欄の場合は既定のパターンを使用します。変更後にパネルを再起動してください。",
       "subTitle": "サブスクリプションタイトル",
       "subTitleDesc": "VPNクライアントに表示されるタイトル",
       "subSupportUrl": "サポートURL",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "すべての JSON サブスクリプションストリームに注入される xray finalmask マスク(TCP/UDP)と QUIC チューニング。新しい xray クライアントが必要です。",
+        "finalMaskDesc": "生成される各 Xray JSON プロファイルへ Xray finalmask の TCP/UDP マスクと QUIC パラメーターを追加します。Xray JSON サブスクリプション対応アプリと新しい Xray コアが必要です。",
         "packets": "パケット",
         "length": "長さ",
         "interval": "間隔",
@@ -1358,6 +1371,10 @@
       "smtpPortDesc": "SMTPサーバーのポート(既定値: 587)",
       "smtpUsername": "SMTPユーザー名",
       "smtpUsernameDesc": "SMTP認証用のユーザー名",
+      "smtpFrom": "送信元アドレス (From)",
+      "smtpFromDesc": "メールの From ヘッダーに使用するアドレス。空欄の場合はユーザー名を使用します。",
+      "smtpFromName": "送信者名 (From)",
+      "smtpFromNameDesc": "From ヘッダーでアドレスの前に表示される任意の表示名。",
       "smtpPassword": "SMTPパスワード",
       "smtpPasswordDesc": "SMTP認証用のパスワード",
       "smtpTo": "受信者",

+ 18 - 1
internal/web/translation/pt-BR.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "Ativar/Desativar o endpoint de assinatura JSON de forma independente.",
       "subJsonEnableTitle": "Assinatura JSON",
       "subClashEnableTitle": "Assinatura Clash / Mihomo",
+      "subFormatsTipTitle": "Configurações de assinatura específicas por formato",
+      "subFormatsTipDesc": "Configure separadamente os caminhos de URL, URLs reversas e a detecção automática de clientes para JSON e Clash / Mihomo.",
+      "subFormatsTipAction": "Abrir formatos de assinatura",
+      "subJsonAutoDetect": "Detectar clientes Xray JSON automaticamente",
+      "subJsonAutoDetectDesc": "Quando ativado, clientes compatíveis reconhecidos que solicitarem a URL de assinatura padrão receberão automaticamente uma matriz de configurações Xray JSON. Os outros clientes manterão a resposta bruta/Base64. Requer a assinatura JSON ativada e a reinicialização do painel.",
+      "subJsonAlwaysArray": "Sempre retornar uma matriz JSON",
+      "subJsonAlwaysArrayDesc": "Retorna o endpoint JSON explícito como matriz mesmo com apenas um perfil, conforme o padrão XTLS. Respostas JSON detectadas automaticamente sempre usam matrizes. Desative para preservar a resposta legada de objeto único.",
+      "subJsonUserAgentRegex": "Expressão User-Agent do Xray JSON",
+      "subJsonUserAgentRegexDesc": "Expressão regular Go RE2 comparada com o User-Agent do cliente para selecionar automaticamente o formato Xray JSON na URL de assinatura padrão. Vazia por padrão, então a detecção automática permanece desativada até você definir um padrão para os clientes que deseja atender. Os outros clientes mantêm a resposta bruta/Base64. Reinicie o painel após alterá-la.",
+      "subClashAutoDetect": "Detectar clientes Clash/Mihomo automaticamente",
+      "subClashAutoDetectDesc": "Quando ativado, clientes Clash/Mihomo reconhecidos que solicitarem a URL de assinatura padrão receberão automaticamente YAML do Clash. Os navegadores continuarão exibindo a página de assinatura, os outros clientes manterão a resposta bruta/Base64 e as URLs explícitas de JSON e Clash continuarão disponíveis. Requer a assinatura Clash/Mihomo ativada e a reinicialização do painel para aplicar a alteração.",
+      "subClashUserAgentRegex": "Expressão User-Agent do Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "Expressão regular Go RE2 comparada com o User-Agent do cliente para reconhecer clientes Clash/Mihomo na URL de assinatura padrão. Deixe em branco para usar o padrão predefinido. Reinicie o painel após alterá-la.",
       "subTitle": "Título da Assinatura",
       "subTitleDesc": "Título exibido no cliente VPN",
       "subSupportUrl": "URL de Suporte",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "Máscaras finalmask do xray (TCP/UDP) e ajustes QUIC injetados em cada fluxo de assinatura JSON. Requer um cliente xray recente.",
+        "finalMaskDesc": "Injeta máscaras TCP/UDP do finalmask do Xray e parâmetros QUIC em cada perfil Xray JSON gerado. Requer um aplicativo compatível com assinaturas Xray JSON e um núcleo Xray recente.",
         "packets": "Pacotes",
         "length": "Comprimento",
         "interval": "Intervalo",
@@ -1358,6 +1371,10 @@
       "smtpPortDesc": "Porta do servidor SMTP (padrão: 587)",
       "smtpUsername": "Usuário SMTP",
       "smtpUsernameDesc": "Nome de usuário para autenticação SMTP",
+      "smtpFrom": "Endereço do remetente (From)",
+      "smtpFromDesc": "Endereço usado no cabeçalho From do e-mail. Deixe vazio para usar o nome de usuário.",
+      "smtpFromName": "Nome do remetente (From)",
+      "smtpFromNameDesc": "Nome de exibição opcional antes do endereço no cabeçalho From.",
       "smtpPassword": "Senha SMTP",
       "smtpPasswordDesc": "Senha para autenticação SMTP",
       "smtpTo": "Destinatários",

+ 18 - 1
internal/web/translation/ru-RU.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "Включить/отключить JSON-эндпоинт подписки независимо.",
       "subJsonEnableTitle": "JSON-подписка",
       "subClashEnableTitle": "Подписка Clash / Mihomo",
+      "subFormatsTipTitle": "Настройки подписки для отдельных форматов",
+      "subFormatsTipDesc": "Настройте отдельно URL-пути, обратные URL и автоматическое определение клиентов для JSON и Clash / Mihomo.",
+      "subFormatsTipAction": "Открыть форматы подписки",
+      "subJsonAutoDetect": "Автоматически определять клиентов Xray JSON",
+      "subJsonAutoDetectDesc": "Если параметр включён, распознанные совместимые клиенты при запросе стандартного URL подписки автоматически получают массив конфигураций Xray JSON. Остальные клиенты получают прежний ответ raw/Base64. Для применения требуется включить JSON-подписку и перезапустить панель.",
+      "subJsonAlwaysArray": "Всегда возвращать массив JSON",
+      "subJsonAlwaysArrayDesc": "Возвращает явную JSON-подписку как массив даже при одном профиле согласно стандарту XTLS. Автоматически определённые JSON-ответы всегда используют массив. Отключите, чтобы сохранить прежний ответ в виде одного объекта.",
+      "subJsonUserAgentRegex": "Регулярное выражение User-Agent Xray JSON",
+      "subJsonUserAgentRegexDesc": "Регулярное выражение Go RE2, сопоставляемое с User-Agent клиента для автоматического выбора формата Xray JSON на стандартном URL подписки. По умолчанию пустое, поэтому автоопределение отключено, пока вы не зададите шаблон для нужных клиентов. Остальные клиенты получают ответ raw/Base64. После изменения перезапустите панель.",
+      "subClashAutoDetect": "Автоматически определять клиентов Clash/Mihomo",
+      "subClashAutoDetectDesc": "Если параметр включён, распознанные клиенты Clash/Mihomo при запросе стандартного URL подписки автоматически получают Clash YAML. Браузеры по-прежнему показывают страницу подписки, остальные клиенты получают прежний ответ raw/Base64, а явные URL JSON и Clash остаются доступными. Для применения требуется включить подписку Clash/Mihomo и перезапустить панель.",
+      "subClashUserAgentRegex": "Регулярное выражение User-Agent Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "Регулярное выражение Go RE2, сопоставляемое с User-Agent клиента для распознавания клиентов Clash/Mihomo на стандартном URL подписки. Оставьте поле пустым, чтобы использовать шаблон по умолчанию. После изменения перезапустите панель.",
       "subTitle": "Заголовок подписки",
       "subTitleDesc": "Название подписки, которое видит клиент в VPN-клиенте",
       "subSupportUrl": "URL поддержки",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "Маски finalmask xray (TCP/UDP) и настройки QUIC, добавляемые в каждый поток JSON-подписки. Требуется свежая версия xray на клиенте.",
+        "finalMaskDesc": "Добавляет TCP/UDP-маски finalmask Xray и параметры QUIC в каждый созданный профиль Xray JSON. Требуется приложение с поддержкой подписок Xray JSON и свежая версия ядра Xray.",
         "packets": "Пакеты",
         "length": "Длина",
         "interval": "Интервал",
@@ -1358,6 +1371,10 @@
       "smtpPortDesc": "Порт SMTP сервера (по умолчанию: 587)",
       "smtpUsername": "SMTP логин",
       "smtpUsernameDesc": "Логин для аутентификации SMTP",
+      "smtpFrom": "Адрес отправителя (From)",
+      "smtpFromDesc": "Адрес в заголовке From письма. Оставьте пустым, чтобы использовать имя пользователя.",
+      "smtpFromName": "Имя отправителя (From)",
+      "smtpFromNameDesc": "Необязательное отображаемое имя перед адресом в заголовке From.",
       "smtpPassword": "SMTP пароль",
       "smtpPasswordDesc": "Пароль для аутентификации SMTP",
       "smtpTo": "Получатели",

+ 18 - 1
internal/web/translation/tr-TR.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "JSON abonelik uç noktasını bağımsız olarak etkinleştirir/devre dışı bırakır.",
       "subJsonEnableTitle": "JSON Aboneliği",
       "subClashEnableTitle": "Clash / Mihomo Aboneliği",
+      "subFormatsTipTitle": "Biçime özel abonelik ayarları",
+      "subFormatsTipDesc": "JSON ve Clash / Mihomo için URL yollarını, ters URL'leri ve otomatik istemci algılamayı ayrı ayrı yapılandırın.",
+      "subFormatsTipAction": "Abonelik Biçimlerini Aç",
+      "subJsonAutoDetect": "Xray JSON istemcilerini otomatik algıla",
+      "subJsonAutoDetectDesc": "Etkinleştirildiğinde, standart abonelik URL'sini isteyen tanınmış uyumlu istemciler otomatik olarak bir Xray JSON yapılandırma dizisi alır. Diğer istemciler ham/Base64 yanıtını almaya devam eder. JSON aboneliğinin etkinleştirilmesi ve panelin yeniden başlatılması gerekir.",
+      "subJsonAlwaysArray": "Her zaman JSON dizisi döndür",
+      "subJsonAlwaysArrayDesc": "Açık JSON abonelik uç noktasını tek profil olsa bile XTLS standardına uygun bir dizi olarak döndürür. Otomatik algılanan JSON yanıtları her zaman dizi kullanır. Eski tek nesne yanıtını korumak için devre dışı bırakın.",
+      "subJsonUserAgentRegex": "Xray JSON User-Agent düzenli ifadesi",
+      "subJsonUserAgentRegexDesc": "Standart abonelik URL'sinde Xray JSON biçimini otomatik seçmek için istemcinin User-Agent değeriyle eşleştirilen Go RE2 düzenli ifadesi. Varsayılan olarak boştur; bu nedenle hizmet vermek istediğiniz istemciler için bir desen belirleyene kadar otomatik algılama kapalı kalır. Diğer istemciler ham/Base64 yanıtını almaya devam eder. Değişiklikten sonra paneli yeniden başlatın.",
+      "subClashAutoDetect": "Clash/Mihomo istemcilerini otomatik algıla",
+      "subClashAutoDetectDesc": "Etkinleştirildiğinde, standart abonelik URL'sini isteyen tanınmış Clash/Mihomo istemcileri otomatik olarak Clash YAML alır. Tarayıcılar abonelik sayfasını göstermeye devam eder, diğer istemciler ham/Base64 yanıtını almaya devam eder ve açık JSON ile Clash URL'leri kullanılabilir kalır. Uygulanması için Clash/Mihomo aboneliğinin etkinleştirilmesi ve panelin yeniden başlatılması gerekir.",
+      "subClashUserAgentRegex": "Clash/Mihomo User-Agent düzenli ifadesi",
+      "subClashUserAgentRegexDesc": "Standart abonelik URL'sinde Clash/Mihomo istemcilerini tanımak için istemcinin User-Agent değeriyle eşleştirilen Go RE2 düzenli ifadesi. Varsayılan deseni kullanmak için boş bırakın. Değişiklikten sonra paneli yeniden başlatın.",
       "subTitle": "Abonelik Başlığı",
       "subTitleDesc": "VPN istemcisinde gösterilen başlık.",
       "subSupportUrl": "Destek URL'si",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "Her JSON abonelik akışına eklenen Xray finalmask maskeleri (TCP/UDP) ve QUIC ayarları. Güncel bir Xray istemcisi gerektirir.",
+        "finalMaskDesc": "Xray finalmask TCP/UDP maskelerini ve QUIC parametrelerini oluşturulan her Xray JSON profiline ekler. Xray JSON aboneliklerini destekleyen bir istemci uygulaması ve güncel bir Xray çekirdeği gerektirir.",
         "packets": "Paketler",
         "length": "Uzunluk",
         "interval": "Aralık",
@@ -1358,6 +1371,10 @@
       "smtpPortDesc": "SMTP sunucu bağlantı noktası (varsayılan: 587)",
       "smtpUsername": "SMTP Kullanıcı Adı",
       "smtpUsernameDesc": "SMTP kimlik doğrulama kullanıcı adı",
+      "smtpFrom": "Gönderen Adresi (From)",
+      "smtpFromDesc": "E-postanın From başlığında kullanılan adres. Boş bırakılırsa kullanıcı adı kullanılır.",
+      "smtpFromName": "Gönderen Adı (From)",
+      "smtpFromNameDesc": "From başlığında adresten önce görünen isteğe bağlı ad.",
       "smtpPassword": "SMTP Parolası",
       "smtpPasswordDesc": "SMTP kimlik doğrulama parolası",
       "smtpTo": "Alıcılar",

+ 18 - 1
internal/web/translation/uk-UA.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "Увімкнути/вимкнути JSON-кінець підписки незалежно.",
       "subJsonEnableTitle": "JSON-підписка",
       "subClashEnableTitle": "Підписка Clash / Mihomo",
+      "subFormatsTipTitle": "Налаштування підписки для окремих форматів",
+      "subFormatsTipDesc": "Окремо налаштуйте URL-шляхи, зворотні URL-адреси й автоматичне визначення клієнтів для JSON та Clash / Mihomo.",
+      "subFormatsTipAction": "Відкрити формати підписки",
+      "subJsonAutoDetect": "Автоматично визначати клієнтів Xray JSON",
+      "subJsonAutoDetectDesc": "Якщо параметр увімкнено, розпізнані сумісні клієнти під час запиту стандартної URL-адреси підписки автоматично отримують масив конфігурацій Xray JSON. Інші клієнти отримують звичайну відповідь raw/Base64. Для застосування потрібно ввімкнути JSON-підписку та перезапустити панель.",
+      "subJsonAlwaysArray": "Завжди повертати масив JSON",
+      "subJsonAlwaysArrayDesc": "Повертає явну JSON-підписку як масив навіть для одного профілю відповідно до стандарту XTLS. Автоматично визначені JSON-відповіді завжди використовують масив. Вимкніть, щоб зберегти попередню відповідь одним об’єктом.",
+      "subJsonUserAgentRegex": "Регулярний вираз User-Agent Xray JSON",
+      "subJsonUserAgentRegexDesc": "Регулярний вираз Go RE2, який зіставляється з User-Agent клієнта для автоматичного вибору формату Xray JSON на стандартній URL-адресі підписки. Типово порожнє, тому автоматичне визначення вимкнено, доки ви не задасте шаблон для потрібних клієнтів. Інші клієнти отримують відповідь raw/Base64. Після зміни перезапустіть панель.",
+      "subClashAutoDetect": "Автоматично визначати клієнтів Clash/Mihomo",
+      "subClashAutoDetectDesc": "Якщо параметр увімкнено, розпізнані клієнти Clash/Mihomo під час запиту стандартної URL-адреси підписки автоматично отримують Clash YAML. Браузери й надалі показують сторінку підписки, інші клієнти отримують звичайну відповідь raw/Base64, а явні URL-адреси JSON і Clash залишаються доступними. Для застосування потрібно ввімкнути підписку Clash/Mihomo та перезапустити панель.",
+      "subClashUserAgentRegex": "Регулярний вираз User-Agent Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "Регулярний вираз Go RE2, який зіставляється з User-Agent клієнта для розпізнавання клієнтів Clash/Mihomo на стандартній URL-адресі підписки. Залиште поле порожнім для стандартного шаблону. Після зміни перезапустіть панель.",
       "subTitle": "Назва Підписки",
       "subTitleDesc": "Назва, яка відображається у VPN-клієнті",
       "subSupportUrl": "URL підтримки",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "Маски finalmask xray (TCP/UDP) і налаштування QUIC, що додаються до кожного потоку JSON-підписки. Потрібна свіжа версія xray на клієнті.",
+        "finalMaskDesc": "Додає TCP/UDP-маски finalmask Xray і параметри QUIC до кожного створеного профілю Xray JSON. Потрібен застосунок із підтримкою підписок Xray JSON і нова версія ядра Xray.",
         "packets": "Пакети",
         "length": "Довжина",
         "interval": "Інтервал",
@@ -1358,6 +1371,10 @@
       "smtpPortDesc": "Порт сервера SMTP (типово: 587)",
       "smtpUsername": "Ім'я користувача SMTP",
       "smtpUsernameDesc": "Ім'я користувача для автентифікації SMTP",
+      "smtpFrom": "Адреса відправника (From)",
+      "smtpFromDesc": "Адреса в заголовку From листа. Залиште порожнім, щоб використати ім'я користувача.",
+      "smtpFromName": "Ім'я відправника (From)",
+      "smtpFromNameDesc": "Необов'язкове відображуване ім'я перед адресою в заголовку From.",
       "smtpPassword": "Пароль SMTP",
       "smtpPasswordDesc": "Пароль для автентифікації SMTP",
       "smtpTo": "Отримувачі",

+ 18 - 1
internal/web/translation/vi-VN.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "Bật/Tắt điểm cuối đăng ký JSON độc lập.",
       "subJsonEnableTitle": "Đăng ký JSON",
       "subClashEnableTitle": "Đăng ký Clash / Mihomo",
+      "subFormatsTipTitle": "Cài đặt đăng ký theo từng định dạng",
+      "subFormatsTipDesc": "Cấu hình riêng đường dẫn URL, URL proxy ngược và tính năng tự động nhận diện ứng dụng cho JSON và Clash / Mihomo.",
+      "subFormatsTipAction": "Mở định dạng đăng ký",
+      "subJsonAutoDetect": "Tự động nhận diện ứng dụng Xray JSON",
+      "subJsonAutoDetectDesc": "Khi bật, các ứng dụng tương thích được nhận diện khi yêu cầu URL đăng ký tiêu chuẩn sẽ tự động nhận một mảng cấu hình Xray JSON. Các ứng dụng khác tiếp tục nhận phản hồi thô/Base64. Cần bật đăng ký JSON và khởi động lại bảng điều khiển.",
+      "subJsonAlwaysArray": "Luôn trả về mảng JSON",
+      "subJsonAlwaysArrayDesc": "Trả endpoint đăng ký JSON tường minh dưới dạng mảng ngay cả khi chỉ có một hồ sơ, theo tiêu chuẩn XTLS. Phản hồi JSON được tự động nhận diện luôn dùng mảng. Tắt để giữ phản hồi một đối tượng kiểu cũ.",
+      "subJsonUserAgentRegex": "Biểu thức User-Agent Xray JSON",
+      "subJsonUserAgentRegexDesc": "Biểu thức chính quy Go RE2 được so khớp với User-Agent của ứng dụng để tự động chọn định dạng Xray JSON trên URL đăng ký tiêu chuẩn. Mặc định để trống, nên tính năng tự động nhận diện vẫn tắt cho đến khi bạn đặt mẫu cho các ứng dụng muốn phục vụ. Các ứng dụng khác tiếp tục nhận phản hồi thô/Base64. Khởi động lại bảng điều khiển sau khi thay đổi.",
+      "subClashAutoDetect": "Tự động nhận diện ứng dụng Clash/Mihomo",
+      "subClashAutoDetectDesc": "Khi bật, các ứng dụng Clash/Mihomo được nhận diện khi yêu cầu URL đăng ký tiêu chuẩn sẽ tự động nhận cấu hình Clash YAML. Trình duyệt vẫn hiển thị trang đăng ký, các ứng dụng khác tiếp tục nhận phản hồi thô/Base64, còn các URL JSON và Clash riêng vẫn khả dụng. Cần bật đăng ký Clash/Mihomo và khởi động lại bảng điều khiển để áp dụng thay đổi.",
+      "subClashUserAgentRegex": "Biểu thức User-Agent Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "Biểu thức chính quy Go RE2 được so khớp với User-Agent của ứng dụng để nhận diện ứng dụng Clash/Mihomo trên URL đăng ký tiêu chuẩn. Để trống để dùng mẫu mặc định. Khởi động lại bảng điều khiển sau khi thay đổi.",
       "subTitle": "Tiêu đề Đăng ký",
       "subTitleDesc": "Tiêu đề hiển thị trong ứng dụng VPN",
       "subSupportUrl": "URL Hỗ trợ",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "Mask finalmask của xray (TCP/UDP) và tinh chỉnh QUIC được thêm vào mọi luồng đăng ký JSON. Yêu cầu client xray mới hơn.",
+        "finalMaskDesc": "Thêm mask TCP/UDP của Xray finalmask và tham số QUIC vào mỗi hồ sơ Xray JSON được tạo. Yêu cầu ứng dụng hỗ trợ đăng ký Xray JSON và lõi Xray mới.",
         "packets": "Gói",
         "length": "Độ dài",
         "interval": "Khoảng",
@@ -1358,6 +1371,10 @@
       "smtpPortDesc": "Cổng máy chủ SMTP (mặc định: 587)",
       "smtpUsername": "Tên đăng nhập SMTP",
       "smtpUsernameDesc": "Tên đăng nhập xác thực SMTP",
+      "smtpFrom": "Địa chỉ người gửi (From)",
+      "smtpFromDesc": "Địa chỉ dùng trong tiêu đề From của email. Để trống để dùng tên người dùng.",
+      "smtpFromName": "Tên người gửi (From)",
+      "smtpFromNameDesc": "Tên hiển thị tùy chọn trước địa chỉ trong tiêu đề From.",
       "smtpPassword": "Mật khẩu SMTP",
       "smtpPasswordDesc": "Mật khẩu xác thực SMTP",
       "smtpTo": "Người nhận",

+ 18 - 1
internal/web/translation/zh-CN.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "单独启用/禁用 JSON 订阅端点。",
       "subJsonEnableTitle": "JSON 订阅",
       "subClashEnableTitle": "Clash / Mihomo 订阅",
+      "subFormatsTipTitle": "特定格式的订阅设置",
+      "subFormatsTipDesc": "分别配置 JSON 和 Clash / Mihomo 的 URL 路径、反向代理 URL 和客户端自动识别。",
+      "subFormatsTipAction": "打开订阅格式",
+      "subJsonAutoDetect": "自动识别 Xray JSON 客户端",
+      "subJsonAutoDetectDesc": "启用后,使用标准订阅 URL 的已识别兼容客户端将自动获得 Xray JSON 配置数组。其他客户端继续获得原始/Base64 响应。需要启用 JSON 订阅并重启面板才能生效。",
+      "subJsonAlwaysArray": "始终返回 JSON 数组",
+      "subJsonAlwaysArrayDesc": "即使只有一个配置,也将显式 JSON 订阅端点返回为数组,以符合 XTLS 订阅标准。自动识别的 JSON 响应始终使用数组。禁用时保留旧版单对象响应。",
+      "subJsonUserAgentRegex": "Xray JSON User-Agent 正则表达式",
+      "subJsonUserAgentRegexDesc": "用于与客户端 User-Agent 进行匹配,从而在标准订阅 URL 上自动选择 Xray JSON 格式的 Go RE2 正则表达式。默认留空,因此在为需要服务的客户端设置规则之前,自动识别保持关闭。其他客户端继续获得原始/Base64 响应。更改后请重启面板。",
+      "subClashAutoDetect": "自动识别 Clash/Mihomo 客户端",
+      "subClashAutoDetectDesc": "启用后,使用标准订阅 URL 的已识别 Clash/Mihomo 客户端将自动获得 Clash YAML。浏览器仍显示订阅页面,其他客户端继续获得原始/Base64 响应,独立的 JSON 和 Clash URL 仍然可用。需要启用 Clash/Mihomo 订阅并重启面板才能生效。",
+      "subClashUserAgentRegex": "Clash/Mihomo User-Agent 正则表达式",
+      "subClashUserAgentRegexDesc": "用于与客户端 User-Agent 进行匹配,从而在标准订阅 URL 上识别 Clash/Mihomo 客户端的 Go RE2 正则表达式。留空则使用默认规则。更改后请重启面板。",
       "subTitle": "订阅标题",
       "subTitleDesc": "在VPN客户端中显示的标题",
       "subSupportUrl": "支持链接",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "注入到每个 JSON 订阅流的 xray finalmask 掩码(TCP/UDP)和 QUIC 调优。需要较新的 xray 客户端。",
+        "finalMaskDesc": "将 Xray finalmask TCP/UDP 掩码和 QUIC 参数注入每个生成的 Xray JSON 配置。需要支持 Xray JSON 订阅的客户端应用和较新的 Xray 核心。",
         "packets": "数据包",
         "length": "长度",
         "interval": "间隔",
@@ -1358,6 +1371,10 @@
       "smtpPortDesc": "SMTP 服务器端口(默认:587)",
       "smtpUsername": "SMTP 用户名",
       "smtpUsernameDesc": "SMTP 认证用户名",
+      "smtpFrom": "发件人地址 (From)",
+      "smtpFromDesc": "邮件 From 头使用的地址。留空则使用用户名。",
+      "smtpFromName": "发件人名称 (From)",
+      "smtpFromNameDesc": "From 头中显示在地址前的可选显示名称。",
       "smtpPassword": "SMTP 密码",
       "smtpPasswordDesc": "SMTP 认证密码",
       "smtpTo": "收件人",

+ 18 - 1
internal/web/translation/zh-TW.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "獨立啟用/停用 JSON 訂閱端點。",
       "subJsonEnableTitle": "JSON 訂閱",
       "subClashEnableTitle": "Clash / Mihomo 訂閱",
+      "subFormatsTipTitle": "特定格式的訂閱設定",
+      "subFormatsTipDesc": "分別設定 JSON 和 Clash / Mihomo 的 URL 路徑、反向代理 URL 與用戶端自動識別。",
+      "subFormatsTipAction": "開啟訂閱格式",
+      "subJsonAutoDetect": "自動識別 Xray JSON 用戶端",
+      "subJsonAutoDetectDesc": "啟用後,使用標準訂閱 URL 的已識別相容用戶端將自動取得 Xray JSON 設定陣列。其他用戶端繼續取得原始/Base64 回應。需要啟用 JSON 訂閱並重新啟動面板才能生效。",
+      "subJsonAlwaysArray": "始終回傳 JSON 陣列",
+      "subJsonAlwaysArrayDesc": "即使只有一個設定,也將明確 JSON 訂閱端點回傳為陣列,以符合 XTLS 訂閱標準。自動識別的 JSON 回應始終使用陣列。停用時保留舊版單一物件回應。",
+      "subJsonUserAgentRegex": "Xray JSON User-Agent 正規表示式",
+      "subJsonUserAgentRegexDesc": "用於與用戶端 User-Agent 進行比對,以便在標準訂閱 URL 上自動選擇 Xray JSON 格式的 Go RE2 正規表示式。預設留空,因此在為要服務的用戶端設定規則之前,自動辨識會保持關閉。其他用戶端繼續取得原始/Base64 回應。變更後請重新啟動面板。",
+      "subClashAutoDetect": "自動識別 Clash/Mihomo 用戶端",
+      "subClashAutoDetectDesc": "啟用後,使用標準訂閱 URL 的已識別 Clash/Mihomo 用戶端將自動取得 Clash YAML。瀏覽器仍會顯示訂閱頁面,其他用戶端繼續取得原始/Base64 回應,獨立的 JSON 和 Clash URL 仍可使用。需要啟用 Clash/Mihomo 訂閱並重新啟動面板才能生效。",
+      "subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表示式",
+      "subClashUserAgentRegexDesc": "用於與用戶端 User-Agent 進行比對,以便在標準訂閱 URL 上識別 Clash/Mihomo 用戶端的 Go RE2 正規表示式。留空則使用預設規則。變更後請重新啟動面板。",
       "subTitle": "訂閱標題",
       "subTitleDesc": "在VPN客戶端中顯示的標題",
       "subSupportUrl": "支援連結",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "注入到每個 JSON 訂閱串流的 xray finalmask 遮罩(TCP/UDP)與 QUIC 調校。需要較新的 xray 用戶端。",
+        "finalMaskDesc": "將 Xray finalmask TCP/UDP 遮罩和 QUIC 參數注入每個產生的 Xray JSON 設定。需要支援 Xray JSON 訂閱的用戶端應用程式和較新的 Xray 核心。",
         "packets": "封包",
         "length": "長度",
         "interval": "間隔",
@@ -1358,6 +1371,10 @@
       "smtpPortDesc": "SMTP 伺服器連接埠(預設:587)",
       "smtpUsername": "SMTP 使用者名稱",
       "smtpUsernameDesc": "SMTP 驗證使用者名稱",
+      "smtpFrom": "寄件者位址 (From)",
+      "smtpFromDesc": "郵件 From 標頭使用的位址。留空則使用使用者名稱。",
+      "smtpFromName": "寄件者名稱 (From)",
+      "smtpFromNameDesc": "From 標頭中顯示在位址前的可選顯示名稱。",
       "smtpPassword": "SMTP 密碼",
       "smtpPasswordDesc": "SMTP 驗證密碼",
       "smtpTo": "收件人",

+ 3 - 2
update.sh

@@ -1039,9 +1039,10 @@ update_x-ui() {
         rm ${xui_folder}/x-ui.service.rhel -f > /dev/null 2>&1
         rm ${xui_folder}/x-ui -f > /dev/null 2>&1
         rm ${xui_folder}/x-ui.sh -f > /dev/null 2>&1
+        echo -e "${green}Removing old mtg version...${plain}"
+        rm ${xui_folder}/bin/mtg-linux-$(arch) -f > /dev/null 2>&1
         echo -e "${green}Removing old xray version...${plain}"
-        rm ${xui_folder}/bin/xray-linux-amd64 -f > /dev/null 2>&1
-        rm ${xui_folder}/bin/xray-linux-arm -f > /dev/null 2>&1
+        rm ${xui_folder}/bin/xray-linux-$(arch) -f > /dev/null 2>&1
         echo -e "${green}Removing old README and LICENSE file...${plain}"
         rm ${xui_folder}/bin/README.md -f > /dev/null 2>&1
         rm ${xui_folder}/bin/LICENSE -f > /dev/null 2>&1