Просмотр исходного кода

feat(notifications): event bus architecture with Telegram and SMTP subscribers (#5326)

* feat(notifications): event bus architecture with Telegram and SMTP subscribers

- Event bus core with buffered channel, fan-out, panic recovery
- Telegram subscriber with HTML formatting and rate limiting
- Email subscriber with SMTP/TLS/STARTTLS support and stage diagnostics
- 5 event types: outbound.down/up, xray.crash, cpu.high, login.attempt
- CPU threshold checks per subscriber (tgCpu for TG, smtpCpu for Email)
- SystemMetricData struct for raw metric values in events
- i18n keys for en-US, ru-RU, and English defaults for other locales

* fix

* fix(notifications): repair crash/CPU alerts, harden secrets, add node alerts

Bug fixes:
- Xray crash notifications were permanently suppressed after the first crash:
  XrayStateTracker latched state="down" with no reset and no recovery event,
  so only the first crash per process lifetime ever notified. Removed the
  tracker; the existing 1/min rate limiter already dedupes crash-loop spam.
- Email CPU alerts could never fire unless Telegram was also enabled, because
  the CPU job was registered only inside the tgbot block. Register it whenever
  either Telegram or SMTP wants cpu.high (new cpuAlarmWanted gate) and relax
  the cadence to @every 1m (cpu.Percent already samples over a full minute).
- SMTP password (and, pre-existing, all other secrets) were shipped to the
  browser in plaintext: GetAllSettingView was dead code and /setting/all
  returned the raw model. Wire getAllSetting -> GetAllSettingView, redact
  smtpPassword with a hasSmtpPassword presence flag, and preserve it on blank
  save. Closes the leak for tgBotToken/ldapPassword/2FA token too.

Polish:
- email Send: use nil SMTP auth when no credentials (Go refuses PlainAuth over
  the unencrypted "none" transport).
- Remove unused EventClientDepleted; fix inaccurate bus.go doc comments; drop
  stale tgBotLoginNotify from the frontend schema; gofmt alignment.

Feature - node online/offline alerts:
- Emit node.down/node.up from the heartbeat job on a real status transition
  (with a startup-spam guard), reusing NodeHealthData. Formatted by both the
  Telegram and email subscribers and selectable in the settings UI.

Regenerated frontend types (hasSmtpPassword). New i18n keys added to en-US;
other locales fall back to English (bundle default) until translated.

* fix(settings): use antd Space orientation instead of deprecated direction

Ant Design 6 deprecated Space's `direction` prop in favor of `orientation`,
which logged a console warning from the Telegram/Email notification tabs. Brings
these two tabs in line with the rest of the codebase, which already uses
`orientation`.

* i18n(notifications): translate the notification feature into all locales

The notifications PR shipped ~99 new strings (SMTP settings, event labels,
Telegram/email message templates) as English placeholders in every non-English
locale. Translate them — plus the node-alert keys added during this review —
into all 12 locales: Arabic, Spanish, Persian, Indonesian, Japanese,
Portuguese-BR, Russian, Turkish, Ukrainian, Vietnamese, and Simplified/
Traditional Chinese.

Go-template placeholders ({{ .Tag }}, {{ .Name }}, etc.) are preserved exactly;
tgbot message values carry no leading status emoji (the bot/email code adds
those, so an emoji in the value would duplicate it); product/protocol names
(SMTP, STARTTLS, TLS, CPU, Xray, Telegram) are kept as-is.

---------

Co-authored-by: Sanaei <[email protected]>
Sentiago 3 часов назад
Родитель
Сommit
eec030f86f
48 измененных файлов с 3852 добавлено и 139 удалено
  1. 181 10
      frontend/public/openapi.json
  2. 147 0
      frontend/src/components/ui/EventBusCheckboxes.tsx
  3. 1 0
      frontend/src/components/ui/index.ts
  4. 21 2
      frontend/src/generated/examples.ts
  5. 112 10
      frontend/src/generated/schemas.ts
  6. 21 2
      frontend/src/generated/types.ts
  7. 21 2
      frontend/src/generated/zod.ts
  8. 2 0
      frontend/src/layouts/AppSidebar.tsx
  9. 11 2
      frontend/src/models/setting.ts
  10. 12 0
      frontend/src/pages/api-docs/endpoints.ts
  11. 137 0
      frontend/src/pages/settings/EmailTab.tsx
  12. 3 1
      frontend/src/pages/settings/SettingsPage.tsx
  13. 45 12
      frontend/src/pages/settings/TelegramTab.tsx
  14. 1 1
      frontend/src/schemas/setting.ts
  15. 123 0
      internal/eventbus/bus.go
  16. 199 0
      internal/eventbus/bus_test.go
  17. 64 0
      internal/eventbus/events.go
  18. 33 0
      internal/eventbus/filter.go
  19. 61 2
      internal/web/controller/setting.go
  20. 22 10
      internal/web/entity/entity.go
  21. 12 16
      internal/web/job/check_cpu_usage.go
  22. 4 0
      internal/web/job/check_xray_running_job.go
  23. 38 0
      internal/web/job/node_heartbeat_job.go
  24. 297 0
      internal/web/service/email/email.go
  25. 52 0
      internal/web/service/email/ratelimiter_test.go
  26. 182 0
      internal/web/service/email/subscriber.go
  27. 107 5
      internal/web/service/setting.go
  28. 11 2
      internal/web/service/setting_security_test.go
  29. 4 0
      internal/web/service/tgbot/tgbot.go
  30. 150 0
      internal/web/service/tgbot/tgbot_event.go
  31. 18 22
      internal/web/service/tgbot/tgbot_report.go
  32. 17 0
      internal/web/service/tgbot/tgbot_send.go
  33. 39 0
      internal/web/service/xray_metrics.go
  34. 127 2
      internal/web/translation/ar-EG.json
  35. 108 3
      internal/web/translation/en-US.json
  36. 127 2
      internal/web/translation/es-ES.json
  37. 128 3
      internal/web/translation/fa-IR.json
  38. 127 2
      internal/web/translation/id-ID.json
  39. 127 2
      internal/web/translation/ja-JP.json
  40. 127 2
      internal/web/translation/pt-BR.json
  41. 107 2
      internal/web/translation/ru-RU.json
  42. 127 2
      internal/web/translation/tr-TR.json
  43. 127 2
      internal/web/translation/uk-UA.json
  44. 127 2
      internal/web/translation/vi-VN.json
  45. 127 2
      internal/web/translation/zh-CN.json
  46. 127 2
      internal/web/translation/zh-TW.json
  47. 86 12
      internal/web/web.go
  48. 5 0
      internal/xray/process.go

+ 181 - 10
frontend/public/openapi.json

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 123 - 0
internal/eventbus/bus.go

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

+ 199 - 0
internal/eventbus/bus_test.go

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

+ 64 - 0
internal/eventbus/events.go

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

+ 33 - 0
internal/eventbus/filter.go

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 18 - 22
internal/web/service/tgbot/tgbot_report.go

@@ -12,6 +12,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -153,38 +154,33 @@ func (t *Tgbot) prepareServerUsageInfo() string {
 	return info
 }
 
-// UserLoginNotify sends a notification about user login attempts to admins.
+// UserLoginNotify publishes a login event to the event bus.
 func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) {
-	if !t.IsRunning() {
-		return
-	}
-
 	if attempt.Username == "" || attempt.IP == "" || attempt.Time == "" {
 		logger.Warning("UserLoginNotify failed, invalid info!")
 		return
 	}
 
-	loginNotifyEnabled, err := t.settingService.GetTgBotLoginNotify()
-	if err != nil || !loginNotifyEnabled {
+	if EventBus == nil {
 		return
 	}
 
-	msg := ""
-	switch attempt.Status {
-	case LoginSuccess:
-		msg += t.I18nBot("tgbot.messages.loginSuccess")
-		msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
-	case LoginFail:
-		msg += t.I18nBot("tgbot.messages.loginFailed")
-		msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
-		if attempt.Reason != "" {
-			msg += t.I18nBot("tgbot.messages.reason", "Reason=="+attempt.Reason)
-		}
+	status := "fail"
+	if attempt.Status == LoginSuccess {
+		status = "success"
 	}
-	msg += t.I18nBot("tgbot.messages.username", "Username=="+attempt.Username)
-	msg += t.I18nBot("tgbot.messages.ip", "IP=="+attempt.IP)
-	msg += t.I18nBot("tgbot.messages.time", "Time=="+attempt.Time)
-	go t.SendMsgToTgbotAdmins(msg)
+
+	EventBus.Publish(eventbus.Event{
+		Type:   eventbus.EventLoginAttempt,
+		Source: attempt.IP,
+		Data: &eventbus.LoginEventData{
+			Username: attempt.Username,
+			IP:       attempt.IP,
+			Time:     attempt.Time,
+			Status:   status,
+			Reason:   attempt.Reason,
+		},
+	})
 }
 
 // getExhausted retrieves and sends information about exhausted clients.

+ 17 - 0
internal/web/service/tgbot/tgbot_send.go

@@ -2,6 +2,7 @@ package tgbot
 
 import (
 	"context"
+	"fmt"
 	"strings"
 	"time"
 
@@ -247,3 +248,19 @@ func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
 		logger.Info("Message deleted successfully")
 	}
 }
+
+// TestConnection verifies the bot token is valid and the API is reachable.
+func (t *Tgbot) TestConnection() error {
+	tgBotMutex.Lock()
+	b := bot
+	tgBotMutex.Unlock()
+	if b == nil {
+		return fmt.Errorf("bot not initialized")
+	}
+	me, err := b.GetMe(context.Background())
+	if err != nil {
+		return fmt.Errorf("API unreachable: %w", err)
+	}
+	_ = me
+	return nil
+}

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

@@ -11,6 +11,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
@@ -29,6 +30,15 @@ type ObsTagSnapshot struct {
 	UpdatedAt    int64  `json:"updatedAt"`
 }
 
+// eventBus is the shared bus for publishing observatory state-change events.
+// Set once during startup via SetEventBus; nil when no bus is configured.
+var eventBus *eventbus.Bus
+
+// SetEventBus assigns the global event bus used by applyObservatory to publish
+// outbound health transitions. Must be called once during startup before any
+// Sample tick runs.
+func SetEventBus(b *eventbus.Bus) { eventBus = b }
+
 type XrayMetricsService struct {
 	settingService SettingService
 
@@ -205,6 +215,35 @@ func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]ra
 	}
 
 	s.mu.Lock()
+	// Detect transitions and publish events
+	if eventBus != nil {
+		// Check existing tags for state changes
+		for tag, old := range s.obsByTag {
+			cur, exists := next[tag]
+			if !exists {
+				// Tag disappeared from observatory — skip, not a real failure
+				continue
+			}
+			if old.Alive && !cur.Alive {
+				errMsg := ""
+				if cur.Delay < 0 {
+					errMsg = "probe failed"
+				}
+				eventBus.Publish(eventbus.Event{
+					Type:   eventbus.EventOutboundDown,
+					Source: tag,
+					Data:   &eventbus.OutboundHealthData{Delay: cur.Delay, Error: errMsg},
+				})
+			} else if !old.Alive && cur.Alive {
+				eventBus.Publish(eventbus.Event{
+					Type:   eventbus.EventOutboundUp,
+					Source: tag,
+					Data:   &eventbus.OutboundHealthData{Delay: cur.Delay},
+				})
+			}
+		}
+	}
+
 	for tag := range s.obsByTag {
 		if _, kept := next[tag]; !kept {
 			xrayMetrics.drop(obsHistoryKey(tag))

+ 127 - 2
internal/web/translation/ar-EG.json

@@ -1200,7 +1200,69 @@
         "userPassMustBeNotEmpty": "اسم المستخدم والباسورد الجديدين فاضيين",
         "getOutboundTrafficError": "خطأ في الحصول على حركات المرور الصادرة",
         "resetOutboundTrafficError": "خطأ في إعادة تعيين حركات المرور الصادرة"
-      }
+      },
+      "emailNotifications": "الإشعارات",
+      "emailSettings": "البريد الإلكتروني",
+      "eventCPUHigh": "ارتفاع استخدام المعالج (%)",
+      "eventGroupOutbound": "الصادر",
+      "eventGroupSecurity": "الأمان",
+      "eventGroupSystem": "النظام",
+      "eventGroupXray": "نواة Xray",
+      "eventLoginAttempt": "محاولة تسجيل دخول",
+      "eventOutboundDown": "غير متصل",
+      "eventOutboundUp": "متصل",
+      "eventXrayCrash": "تعطّل",
+      "requestFailed": "فشل الطلب",
+      "smtpEnable": "تفعيل إشعارات البريد الإلكتروني",
+      "smtpEnableDesc": "تفعيل إشعارات البريد الإلكتروني عبر SMTP",
+      "smtpEncryption": "التشفير",
+      "smtpEncryptionDesc": "طريقة تشفير اتصال SMTP",
+      "smtpEncryptionNone": "بدون (نص عادي)",
+      "smtpEncryptionStartTLS": "STARTTLS",
+      "smtpEncryptionTLS": "TLS (ضمني)",
+      "smtpEventBusNotify": "إشعارات الأحداث بالبريد الإلكتروني",
+      "smtpEventBusNotifyDesc": "اختر الأحداث التي تُطلق إشعارات البريد الإلكتروني",
+      "smtpHost": "خادم SMTP",
+      "smtpHostDesc": "اسم مضيف خادم SMTP (مثال: smtp.gmail.com)",
+      "smtpHostNotConfigured": "خادم SMTP غير مهيأ",
+      "smtpNoRecipients": "لا يوجد مستلمون مهيؤون",
+      "smtpNotInitialized": "لم تتم تهيئة SMTP",
+      "smtpPassword": "كلمة مرور SMTP",
+      "smtpPasswordDesc": "كلمة المرور للمصادقة على SMTP",
+      "smtpPort": "منفذ SMTP",
+      "smtpPortDesc": "منفذ خادم SMTP (الافتراضي: 587)",
+      "smtpSettings": "إعدادات SMTP",
+      "smtpStageAuth": "المصادقة",
+      "smtpStageConnect": "الاتصال",
+      "smtpStageSend": "الإرسال",
+      "smtpTestSuccess": "تم إرسال البريد التجريبي بنجاح",
+      "smtpTo": "المستلمون",
+      "smtpToDesc": "عناوين البريد الإلكتروني للمستلمين مفصولة بفواصل",
+      "smtpUsername": "اسم مستخدم SMTP",
+      "smtpUsernameDesc": "اسم المستخدم للمصادقة على SMTP",
+      "telegramTokenConfigured": "مهيأ؛ اتركه فارغاً للاحتفاظ بالتوكن الحالي.",
+      "telegramTokenPlaceholder": "مهيأ — أدخل توكن جديد لاستبداله",
+      "testSmtp": "إرسال بريد تجريبي",
+      "testTgBot": "إرسال رسالة تجريبية",
+      "tgBotNotEnabled": "بوت Telegram غير مفعّل",
+      "tgBotNotRunning": "بوت Telegram لا يعمل",
+      "tgEventBusNotify": "إشعارات الأحداث عبر Telegram",
+      "tgEventBusNotifyDesc": "اختر الأحداث التي تُطلق إشعارات Telegram",
+      "tgTestFailed": "فشل اختبار Telegram",
+      "tgTestSuccess": "تم إرسال رسالة تجريبية إلى Telegram",
+      "smtpErrorAuth": "فشلت المصادقة — تحقق من اسم المستخدم وكلمة المرور",
+      "smtpErrorStarttls": "الخادم يتطلب STARTTLS — غيّر نوع التشفير",
+      "smtpErrorTls": "الخادم يتطلب TLS — غيّر نوع التشفير",
+      "smtpErrorRefused": "تم رفض الاتصال — تحقق من الخادم والمنفذ",
+      "smtpErrorTimeout": "انتهت مهلة الاتصال — تعذّر الوصول إلى الخادم",
+      "smtpErrorRelay": "الخادم يرفض الإرسال من هذا العنوان",
+      "smtpErrorEof": "تم إغلاق الاتصال من قبل الخادم",
+      "smtpErrorUnknown": "خطأ SMTP: {{ .Error }}",
+      "eventGroupNode": "العقد",
+      "eventNodeDown": "غير متصلة",
+      "eventNodeUp": "متصلة",
+      "smtpPasswordConfigured": "مهيأة؛ اتركها فارغة للاحتفاظ بكلمة المرور الحالية.",
+      "smtpPasswordPlaceholder": "مهيأة — أدخل كلمة مرور جديدة لاستبدالها"
     },
     "xray": {
       "title": "إعدادات Xray",
@@ -1703,7 +1765,18 @@
       "AreYouSure": "إنت متأكد؟ 🤔",
       "SuccessResetTraffic": "📧 البريد الإلكتروني: {{ .ClientEmail }}\n🏁 النتيجة: ✅ تم بنجاح",
       "FailedResetTraffic": "📧 البريد الإلكتروني: {{ .ClientEmail }}\n🏁 النتيجة: ❌ فشل \n\n🛠️ الخطأ: [ {{ .ErrorMessage }} ]",
-      "FinishProcess": "🔚 عملية إعادة ضبط الترافيك خلصت لكل العملاء."
+      "FinishProcess": "🔚 عملية إعادة ضبط الترافيك خلصت لكل العملاء.",
+      "eventCPUHigh": "ارتفاع استخدام المعالج",
+      "eventCPUHighDetail": "المعالج: {{ .Detail }}",
+      "eventDelayDetail": "التأخير: {{ .Delay }} مللي ثانية",
+      "eventErrorDetail": "الخطأ: {{ .Error }}",
+      "eventLoginFallback": "فشل تسجيل الدخول من {{ .Source }}",
+      "eventOutboundDown": "الصادر {{ .Tag }} غير متصل",
+      "eventOutboundUp": "الصادر {{ .Tag }} متصل",
+      "eventXrayCrash": "تعطّل Xray",
+      "eventXrayCrashError": "الخطأ: {{ .Error }}",
+      "eventNodeDown": "العقدة {{ .Name }} غير متصلة",
+      "eventNodeUp": "العقدة {{ .Name }} متصلة"
     },
     "buttons": {
       "closeKeyboard": "❌ اقفل الكيبورد",
@@ -1773,5 +1846,57 @@
       "chooseClient": "اختار عميل للإدخال {{ .Inbound }}",
       "chooseInbound": "اختار الإدخال"
     }
+  },
+  "email": {
+    "labelDelay": "التأخير",
+    "labelDetail": "التفاصيل",
+    "labelError": "الخطأ",
+    "labelIP": "IP",
+    "labelOutbound": "الصادر",
+    "labelReason": "السبب",
+    "labelSource": "المصدر",
+    "labelStatus": "الحالة",
+    "labelTime": "الوقت",
+    "labelUsername": "اسم المستخدم",
+    "statusBanned": "BANNED",
+    "statusCrashed": "متعطّل",
+    "statusDown": "غير متصل",
+    "statusFailed": "فشل",
+    "statusFull": "FULL",
+    "statusHigh": "مرتفع",
+    "statusOffline": "OFFLINE",
+    "statusOnline": "ONLINE",
+    "statusRunning": "يعمل",
+    "statusSuccess": "نجاح",
+    "statusUp": "متصل",
+    "statusXrayDown": "Xray DOWN",
+    "statusXrayUp": "Xray UP",
+    "subjectCPUHigh": "ارتفاع استخدام المعالج",
+    "subjectDiskFull": "Disk full",
+    "subjectIPBanned": "IP banned: {{ .IP }}",
+    "subjectLoginFailed": "فشل تسجيل الدخول",
+    "subjectLoginSuccess": "نجح تسجيل الدخول",
+    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
+    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
+    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
+    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
+    "subjectOutboundDown": "الصادر {{ .Tag }} غير متصل",
+    "subjectOutboundUp": "الصادر {{ .Tag }} متصل",
+    "subjectXrayCrash": "تعطّل Xray",
+    "subjectXrayUp": "Xray is UP",
+    "titleCPUHigh": "ارتفاع استخدام المعالج",
+    "titleDiskFull": "Disk full",
+    "titleIPBanned": "IP banned",
+    "titleLoginFailed": "فشل تسجيل الدخول",
+    "titleLoginSuccess": "نجح تسجيل الدخول",
+    "titleNodeOffline": "Node OFFLINE",
+    "titleNodeOnline": "Node ONLINE",
+    "titleNodeXrayDown": "Node Xray DOWN",
+    "titleNodeXrayUp": "Node Xray UP",
+    "titleOutboundDown": "الصادر غير متصل",
+    "titleOutboundUp": "الصادر متصل",
+    "titleXrayCrash": "تعطّل Xray",
+    "titleXrayUp": "Xray UP",
+    "labelNode": "العقدة"
   }
 }

+ 108 - 3
internal/web/translation/en-US.json

@@ -1201,7 +1201,69 @@
         "userPassMustBeNotEmpty": "The new username and password are empty",
         "getOutboundTrafficError": "Error getting traffic",
         "resetOutboundTrafficError": "Error resetting outbound traffic"
-      }
+      },
+      "smtpSettings": "SMTP Settings",
+      "smtpEnable": "Enable Email Notifications",
+      "smtpEnableDesc": "Enable email notifications via SMTP",
+      "smtpHost": "SMTP Host",
+      "smtpHostDesc": "SMTP server hostname (e.g. smtp.gmail.com)",
+      "smtpPort": "SMTP Port",
+      "smtpPortDesc": "SMTP server port (default: 587)",
+      "smtpUsername": "SMTP Username",
+      "smtpUsernameDesc": "SMTP authentication username",
+      "smtpPassword": "SMTP Password",
+      "smtpPasswordDesc": "SMTP authentication password",
+      "smtpTo": "Recipients",
+      "smtpToDesc": "Comma-separated recipient email addresses",
+      "emailSettings": "Email",
+      "emailNotifications": "Notifications",
+      "smtpEventBusNotify": "Email Event Notifications",
+      "smtpEventBusNotifyDesc": "Select which events trigger email notifications",
+      "tgEventBusNotify": "Telegram Event Notifications",
+      "tgEventBusNotifyDesc": "Select which events trigger Telegram notifications",
+      "testSmtp": "Send Test Email",
+      "testTgBot": "Send Test Message",
+      "eventGroupOutbound": "Outbound",
+      "eventGroupXray": "Xray Core",
+      "eventGroupSystem": "System",
+      "eventGroupSecurity": "Security",
+      "eventGroupNode": "Nodes",
+      "eventOutboundDown": "Down",
+      "eventOutboundUp": "Up",
+      "eventXrayCrash": "Crash",
+      "eventNodeDown": "Down",
+      "eventNodeUp": "Up",
+      "eventCPUHigh": "CPU high (%)",
+      "requestFailed": "Request failed",
+      "smtpEncryption": "Encryption",
+      "smtpEncryptionDesc": "SMTP connection encryption method",
+      "smtpEncryptionNone": "None (plain text)",
+      "smtpEncryptionStartTLS": "STARTTLS",
+      "smtpEncryptionTLS": "TLS (implicit)",
+      "smtpStageConnect": "Connection",
+      "smtpStageAuth": "Authentication",
+      "smtpStageSend": "Send",
+      "smtpTestSuccess": "Test email sent successfully",
+      "smtpHostNotConfigured": "SMTP host not configured",
+      "smtpNoRecipients": "No recipients configured",
+      "eventLoginAttempt": "Login attempt",
+      "telegramTokenConfigured": "Configured; leave blank to keep current token.",
+      "telegramTokenPlaceholder": "Configured - enter a new token to replace",
+      "smtpPasswordConfigured": "Configured; leave blank to keep current password.",
+      "smtpPasswordPlaceholder": "Configured - enter a new password to replace",
+      "smtpNotInitialized": "SMTP not initialized",
+      "tgBotNotEnabled": "Telegram bot is not enabled",
+      "tgTestFailed": "Telegram test failed",
+      "tgTestSuccess": "Test message sent to Telegram",
+      "tgBotNotRunning": "Telegram bot not running",
+      "smtpErrorAuth": "Authentication failed — check username and password",
+      "smtpErrorStarttls": "Server requires STARTTLS — change encryption type",
+      "smtpErrorTls": "Server requires TLS — change encryption type",
+      "smtpErrorRefused": "Connection refused — check host and port",
+      "smtpErrorTimeout": "Connection timeout — host unreachable",
+      "smtpErrorRelay": "Server rejects sending from this address",
+      "smtpErrorEof": "Connection closed by server",
+      "smtpErrorUnknown": "SMTP error: {{ .Error }}"
     },
     "xray": {
       "title": "Xray Configs",
@@ -1704,7 +1766,18 @@
       "AreYouSure": "Are you sure? 🤔",
       "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Result: ✅ Success",
       "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Result: ❌ Failed \n\n🛠️ Error: [ {{ .ErrorMessage }} ]",
-      "FinishProcess": "🔚 Traffic reset process finished for all clients."
+      "FinishProcess": "🔚 Traffic reset process finished for all clients.",
+      "eventOutboundDown": "Outbound {{ .Tag }} is DOWN",
+      "eventOutboundUp": "Outbound {{ .Tag }} is UP",
+      "eventErrorDetail": "Error: {{ .Error }}",
+      "eventDelayDetail": "Delay: {{ .Delay }}ms",
+      "eventXrayCrash": "Xray CRASHED",
+      "eventXrayCrashError": "Error: {{ .Error }}",
+      "eventNodeDown": "Node {{ .Name }} is DOWN",
+      "eventNodeUp": "Node {{ .Name }} is UP",
+      "eventCPUHigh": "CPU high",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventLoginFallback": "Login failed from {{ .Source }}"
     },
     "buttons": {
       "closeKeyboard": "❌ Close Keyboard",
@@ -1774,5 +1847,37 @@
       "chooseClient": "Choose a Client for Inbound {{ .Inbound }}",
       "chooseInbound": "Choose an Inbound"
     }
+  },
+  "email": {
+    "subjectOutboundDown": "Outbound {{ .Tag }} is DOWN",
+    "subjectOutboundUp": "Outbound {{ .Tag }} is UP",
+    "subjectXrayCrash": "Xray CRASHED",
+    "subjectCPUHigh": "CPU high",
+    "subjectLoginSuccess": "Login successful",
+    "subjectLoginFailed": "Login failed",
+    "titleOutboundDown": "Outbound DOWN",
+    "titleOutboundUp": "Outbound UP",
+    "titleXrayCrash": "Xray CRASHED",
+    "titleCPUHigh": "CPU high",
+    "titleLoginSuccess": "Login successful",
+    "titleLoginFailed": "Login failed",
+    "labelStatus": "Status",
+    "labelOutbound": "Outbound",
+    "labelNode": "Node",
+    "labelError": "Error",
+    "labelDelay": "Delay",
+    "labelDetail": "Detail",
+    "labelUsername": "Username",
+    "labelIP": "IP",
+    "labelReason": "Reason",
+    "labelSource": "Source",
+    "labelTime": "Time",
+    "statusCrashed": "CRASHED",
+    "statusRunning": "Running",
+    "statusHigh": "HIGH",
+    "statusSuccess": "SUCCESS",
+    "statusFailed": "FAILED",
+    "statusDown": "DOWN",
+    "statusUp": "UP"
   }
-}
+}

+ 127 - 2
internal/web/translation/es-ES.json

@@ -1200,7 +1200,69 @@
         "userPassMustBeNotEmpty": "El nuevo nombre de usuario y la nueva contraseña no pueden estar vacíos",
         "getOutboundTrafficError": "Error al obtener el tráfico saliente",
         "resetOutboundTrafficError": "Error al reiniciar el tráfico saliente"
-      }
+      },
+      "emailNotifications": "Notificaciones",
+      "emailSettings": "Correo",
+      "eventCPUHigh": "CPU alta (%)",
+      "eventGroupOutbound": "Saliente",
+      "eventGroupSecurity": "Seguridad",
+      "eventGroupSystem": "Sistema",
+      "eventGroupXray": "Núcleo de Xray",
+      "eventLoginAttempt": "Intento de inicio de sesión",
+      "eventOutboundDown": "Caído",
+      "eventOutboundUp": "Activo",
+      "eventXrayCrash": "Caída",
+      "requestFailed": "La solicitud falló",
+      "smtpEnable": "Activar notificaciones por correo",
+      "smtpEnableDesc": "Activar notificaciones por correo mediante SMTP",
+      "smtpEncryption": "Cifrado",
+      "smtpEncryptionDesc": "Método de cifrado de la conexión SMTP",
+      "smtpEncryptionNone": "Ninguno (texto sin cifrar)",
+      "smtpEncryptionStartTLS": "STARTTLS",
+      "smtpEncryptionTLS": "TLS (implícito)",
+      "smtpEventBusNotify": "Notificaciones por correo de eventos",
+      "smtpEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones por correo",
+      "smtpHost": "Servidor SMTP",
+      "smtpHostDesc": "Nombre del servidor SMTP (p. ej. smtp.gmail.com)",
+      "smtpHostNotConfigured": "Servidor SMTP no configurado",
+      "smtpNoRecipients": "No hay destinatarios configurados",
+      "smtpNotInitialized": "SMTP no inicializado",
+      "smtpPassword": "Contraseña SMTP",
+      "smtpPasswordDesc": "Contraseña de autenticación SMTP",
+      "smtpPort": "Puerto SMTP",
+      "smtpPortDesc": "Puerto del servidor SMTP (predeterminado: 587)",
+      "smtpSettings": "Configuración de SMTP",
+      "smtpStageAuth": "Autenticación",
+      "smtpStageConnect": "Conexión",
+      "smtpStageSend": "Envío",
+      "smtpTestSuccess": "Correo de prueba enviado correctamente",
+      "smtpTo": "Destinatarios",
+      "smtpToDesc": "Direcciones de correo de los destinatarios separadas por comas",
+      "smtpUsername": "Usuario SMTP",
+      "smtpUsernameDesc": "Usuario de autenticación SMTP",
+      "telegramTokenConfigured": "Configurado; deje en blanco para mantener el token actual.",
+      "telegramTokenPlaceholder": "Configurado: introduzca un nuevo token para reemplazarlo",
+      "testSmtp": "Enviar correo de prueba",
+      "testTgBot": "Enviar mensaje de prueba",
+      "tgBotNotEnabled": "El bot de Telegram no está activado",
+      "tgBotNotRunning": "El bot de Telegram no está en ejecución",
+      "tgEventBusNotify": "Notificaciones de Telegram de eventos",
+      "tgEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones de Telegram",
+      "tgTestFailed": "La prueba de Telegram falló",
+      "tgTestSuccess": "Mensaje de prueba enviado a Telegram",
+      "smtpErrorAuth": "Error de autenticación: compruebe el usuario y la contraseña",
+      "smtpErrorStarttls": "El servidor requiere STARTTLS: cambie el tipo de cifrado",
+      "smtpErrorTls": "El servidor requiere TLS: cambie el tipo de cifrado",
+      "smtpErrorRefused": "Conexión rechazada: compruebe el servidor y el puerto",
+      "smtpErrorTimeout": "Tiempo de conexión agotado: servidor inaccesible",
+      "smtpErrorRelay": "El servidor rechaza el envío desde esta dirección",
+      "smtpErrorEof": "Conexión cerrada por el servidor",
+      "smtpErrorUnknown": "Error de SMTP: {{ .Error }}",
+      "eventGroupNode": "Nodos",
+      "eventNodeDown": "Caído",
+      "eventNodeUp": "Activo",
+      "smtpPasswordConfigured": "Configurada; deje en blanco para mantener la contraseña actual.",
+      "smtpPasswordPlaceholder": "Configurada: introduzca una nueva contraseña para reemplazarla"
     },
     "xray": {
       "title": "Xray Configuración",
@@ -1703,7 +1765,18 @@
       "AreYouSure": "¿Estás seguro? 🤔",
       "SuccessResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ✅ Éxito",
       "FailedResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ❌ Fallido \n\n🛠️ Error: [ {{ .ErrorMessage }} ]",
-      "FinishProcess": "🔚 Proceso de reinicio de tráfico finalizado para todos los clientes."
+      "FinishProcess": "🔚 Proceso de reinicio de tráfico finalizado para todos los clientes.",
+      "eventCPUHigh": "CPU alta",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventDelayDetail": "Retardo: {{ .Delay }} ms",
+      "eventErrorDetail": "Error: {{ .Error }}",
+      "eventLoginFallback": "Inicio de sesión fallido desde {{ .Source }}",
+      "eventOutboundDown": "El saliente {{ .Tag }} está CAÍDO",
+      "eventOutboundUp": "El saliente {{ .Tag }} está ACTIVO",
+      "eventXrayCrash": "Xray se ha BLOQUEADO",
+      "eventXrayCrashError": "Error: {{ .Error }}",
+      "eventNodeDown": "El nodo {{ .Name }} está CAÍDO",
+      "eventNodeUp": "El nodo {{ .Name }} está ACTIVO"
     },
     "buttons": {
       "closeKeyboard": "❌ Cerrar Teclado",
@@ -1773,5 +1846,57 @@
       "chooseClient": "Elige un Cliente para Inbound {{ .Inbound }}",
       "chooseInbound": "Elige un Inbound"
     }
+  },
+  "email": {
+    "labelDelay": "Retardo",
+    "labelDetail": "Detalle",
+    "labelError": "Error",
+    "labelIP": "IP",
+    "labelOutbound": "Saliente",
+    "labelReason": "Motivo",
+    "labelSource": "Origen",
+    "labelStatus": "Estado",
+    "labelTime": "Hora",
+    "labelUsername": "Usuario",
+    "statusBanned": "BANNED",
+    "statusCrashed": "BLOQUEADO",
+    "statusDown": "CAÍDO",
+    "statusFailed": "FALLIDO",
+    "statusFull": "FULL",
+    "statusHigh": "ALTA",
+    "statusOffline": "OFFLINE",
+    "statusOnline": "ONLINE",
+    "statusRunning": "En ejecución",
+    "statusSuccess": "CORRECTO",
+    "statusUp": "ACTIVO",
+    "statusXrayDown": "Xray DOWN",
+    "statusXrayUp": "Xray UP",
+    "subjectCPUHigh": "CPU alta",
+    "subjectDiskFull": "Disk full",
+    "subjectIPBanned": "IP banned: {{ .IP }}",
+    "subjectLoginFailed": "Inicio de sesión fallido",
+    "subjectLoginSuccess": "Inicio de sesión correcto",
+    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
+    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
+    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
+    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
+    "subjectOutboundDown": "El saliente {{ .Tag }} está CAÍDO",
+    "subjectOutboundUp": "El saliente {{ .Tag }} está ACTIVO",
+    "subjectXrayCrash": "Xray se ha BLOQUEADO",
+    "subjectXrayUp": "Xray is UP",
+    "titleCPUHigh": "CPU alta",
+    "titleDiskFull": "Disk full",
+    "titleIPBanned": "IP banned",
+    "titleLoginFailed": "Inicio de sesión fallido",
+    "titleLoginSuccess": "Inicio de sesión correcto",
+    "titleNodeOffline": "Node OFFLINE",
+    "titleNodeOnline": "Node ONLINE",
+    "titleNodeXrayDown": "Node Xray DOWN",
+    "titleNodeXrayUp": "Node Xray UP",
+    "titleOutboundDown": "Saliente CAÍDO",
+    "titleOutboundUp": "Saliente ACTIVO",
+    "titleXrayCrash": "Xray se ha BLOQUEADO",
+    "titleXrayUp": "Xray UP",
+    "labelNode": "Nodo"
   }
 }

+ 128 - 3
internal/web/translation/fa-IR.json

@@ -1200,7 +1200,69 @@
         "userPassMustBeNotEmpty": "نام‌کاربری یا رمزعبور جدید خالی‌است",
         "getOutboundTrafficError": "خطا در دریافت ترافیک خروجی",
         "resetOutboundTrafficError": "خطا در بازنشانی ترافیک خروجی"
-      }
+      },
+      "emailNotifications": "اعلان‌ها",
+      "emailSettings": "ایمیل",
+      "eventCPUHigh": "بالا بودن CPU (٪)",
+      "eventGroupOutbound": "خروجی",
+      "eventGroupSecurity": "امنیت",
+      "eventGroupSystem": "سیستم",
+      "eventGroupXray": "هسته Xray",
+      "eventLoginAttempt": "تلاش برای ورود",
+      "eventOutboundDown": "قطع",
+      "eventOutboundUp": "وصل",
+      "eventXrayCrash": "کرش",
+      "requestFailed": "درخواست ناموفق بود",
+      "smtpEnable": "فعال‌سازی اعلان‌های ایمیلی",
+      "smtpEnableDesc": "فعال‌سازی اعلان‌های ایمیلی از طریق SMTP",
+      "smtpEncryption": "رمزنگاری",
+      "smtpEncryptionDesc": "روش رمزنگاری اتصال SMTP",
+      "smtpEncryptionNone": "هیچ‌کدام (متن ساده)",
+      "smtpEncryptionStartTLS": "STARTTLS",
+      "smtpEncryptionTLS": "TLS (ضمنی)",
+      "smtpEventBusNotify": "اعلان‌های رویداد ایمیلی",
+      "smtpEventBusNotifyDesc": "انتخاب کنید کدام رویدادها اعلان ایمیلی را فعال می‌کنند",
+      "smtpHost": "میزبان SMTP",
+      "smtpHostDesc": "نام میزبان سرور SMTP (مثلاً smtp.gmail.com)",
+      "smtpHostNotConfigured": "میزبان SMTP پیکربندی نشده است",
+      "smtpNoRecipients": "هیچ گیرنده‌ای پیکربندی نشده است",
+      "smtpNotInitialized": "SMTP مقداردهی اولیه نشده است",
+      "smtpPassword": "رمز عبور SMTP",
+      "smtpPasswordDesc": "رمز عبور احراز هویت SMTP",
+      "smtpPort": "پورت SMTP",
+      "smtpPortDesc": "پورت سرور SMTP (پیش‌فرض: ۵۸۷)",
+      "smtpSettings": "تنظیمات SMTP",
+      "smtpStageAuth": "احراز هویت",
+      "smtpStageConnect": "اتصال",
+      "smtpStageSend": "ارسال",
+      "smtpTestSuccess": "ایمیل آزمایشی با موفقیت ارسال شد",
+      "smtpTo": "گیرندگان",
+      "smtpToDesc": "آدرس‌های ایمیل گیرندگان، جداشده با کاما",
+      "smtpUsername": "نام‌کاربری SMTP",
+      "smtpUsernameDesc": "نام‌کاربری احراز هویت SMTP",
+      "telegramTokenConfigured": "پیکربندی شده؛ برای حفظ توکن فعلی خالی بگذارید.",
+      "telegramTokenPlaceholder": "پیکربندی شده - برای جایگزینی، توکن جدید وارد کنید",
+      "testSmtp": "ارسال ایمیل آزمایشی",
+      "testTgBot": "ارسال پیام آزمایشی",
+      "tgBotNotEnabled": "ربات تلگرام فعال نیست",
+      "tgBotNotRunning": "ربات تلگرام در حال اجرا نیست",
+      "tgEventBusNotify": "اعلان‌های رویداد تلگرام",
+      "tgEventBusNotifyDesc": "انتخاب کنید کدام رویدادها اعلان تلگرام را فعال می‌کنند",
+      "tgTestFailed": "آزمایش تلگرام ناموفق بود",
+      "tgTestSuccess": "پیام آزمایشی به تلگرام ارسال شد",
+      "smtpErrorAuth": "احراز هویت ناموفق بود — نام‌کاربری و رمز عبور را بررسی کنید",
+      "smtpErrorStarttls": "سرور به STARTTLS نیاز دارد — نوع رمزنگاری را تغییر دهید",
+      "smtpErrorTls": "سرور به TLS نیاز دارد — نوع رمزنگاری را تغییر دهید",
+      "smtpErrorRefused": "اتصال رد شد — میزبان و پورت را بررسی کنید",
+      "smtpErrorTimeout": "مهلت اتصال به پایان رسید — میزبان در دسترس نیست",
+      "smtpErrorRelay": "سرور ارسال از این آدرس را رد می‌کند",
+      "smtpErrorEof": "اتصال توسط سرور بسته شد",
+      "smtpErrorUnknown": "خطای SMTP: {{ .Error }}",
+      "eventGroupNode": "نودها",
+      "eventNodeDown": "قطع",
+      "eventNodeUp": "وصل",
+      "smtpPasswordConfigured": "پیکربندی شده؛ برای حفظ رمز عبور فعلی خالی بگذارید.",
+      "smtpPasswordPlaceholder": "پیکربندی شده - برای جایگزینی، رمز عبور جدید وارد کنید"
     },
     "xray": {
       "title": "پیکربندی ایکس‌ری",
@@ -1703,7 +1765,18 @@
       "AreYouSure": "مطمئنی؟ 🤔",
       "SuccessResetTraffic": "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ✅ موفقیت‌آمیز",
       "FailedResetTraffic": "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ❌ ناموفق \n\n🛠️ خطا: [ {{ .ErrorMessage }} ]",
-      "FinishProcess": "🔚 فرآیند بازنشانی ترافیک برای همه مشتریان به پایان رسید."
+      "FinishProcess": "🔚 فرآیند بازنشانی ترافیک برای همه مشتریان به پایان رسید.",
+      "eventCPUHigh": "بالا بودن CPU",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventDelayDetail": "تأخیر: {{ .Delay }} میلی‌ثانیه",
+      "eventErrorDetail": "خطا: {{ .Error }}",
+      "eventLoginFallback": "ورود ناموفق از {{ .Source }}",
+      "eventOutboundDown": "خروجی {{ .Tag }} قطع است",
+      "eventOutboundUp": "خروجی {{ .Tag }} وصل است",
+      "eventXrayCrash": "Xray کرش کرد",
+      "eventXrayCrashError": "خطا: {{ .Error }}",
+      "eventNodeDown": "نود {{ .Name }} قطع است",
+      "eventNodeUp": "نود {{ .Name }} وصل است"
     },
     "buttons": {
       "closeKeyboard": "❌ بستن کیبورد",
@@ -1773,5 +1846,57 @@
       "chooseClient": "یک مشتری برای ورودی {{ .Inbound }} انتخاب کنید",
       "chooseInbound": "یک ورودی انتخاب کنید"
     }
+  },
+  "email": {
+    "labelDelay": "تأخیر",
+    "labelDetail": "جزئیات",
+    "labelError": "خطا",
+    "labelIP": "IP",
+    "labelOutbound": "خروجی",
+    "labelReason": "دلیل",
+    "labelSource": "مبدأ",
+    "labelStatus": "وضعیت",
+    "labelTime": "زمان",
+    "labelUsername": "نام‌کاربری",
+    "statusBanned": "BANNED",
+    "statusCrashed": "کرش کرد",
+    "statusDown": "قطع",
+    "statusFailed": "ناموفق",
+    "statusFull": "FULL",
+    "statusHigh": "بالا",
+    "statusOffline": "OFFLINE",
+    "statusOnline": "ONLINE",
+    "statusRunning": "در حال اجرا",
+    "statusSuccess": "موفق",
+    "statusUp": "وصل",
+    "statusXrayDown": "Xray DOWN",
+    "statusXrayUp": "Xray UP",
+    "subjectCPUHigh": "بالا بودن CPU",
+    "subjectDiskFull": "Disk full",
+    "subjectIPBanned": "IP banned: {{ .IP }}",
+    "subjectLoginFailed": "ورود ناموفق",
+    "subjectLoginSuccess": "ورود موفق",
+    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
+    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
+    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
+    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
+    "subjectOutboundDown": "خروجی {{ .Tag }} قطع است",
+    "subjectOutboundUp": "خروجی {{ .Tag }} وصل است",
+    "subjectXrayCrash": "Xray کرش کرد",
+    "subjectXrayUp": "Xray is UP",
+    "titleCPUHigh": "بالا بودن CPU",
+    "titleDiskFull": "Disk full",
+    "titleIPBanned": "IP banned",
+    "titleLoginFailed": "ورود ناموفق",
+    "titleLoginSuccess": "ورود موفق",
+    "titleNodeOffline": "Node OFFLINE",
+    "titleNodeOnline": "Node ONLINE",
+    "titleNodeXrayDown": "Node Xray DOWN",
+    "titleNodeXrayUp": "Node Xray UP",
+    "titleOutboundDown": "خروجی قطع شد",
+    "titleOutboundUp": "خروجی وصل شد",
+    "titleXrayCrash": "Xray کرش کرد",
+    "titleXrayUp": "Xray UP",
+    "labelNode": "نود"
   }
-}
+}

+ 127 - 2
internal/web/translation/id-ID.json

@@ -1200,7 +1200,69 @@
         "userPassMustBeNotEmpty": "Username dan password baru tidak boleh kosong",
         "getOutboundTrafficError": "Gagal mendapatkan lalu lintas keluar",
         "resetOutboundTrafficError": "Gagal mereset lalu lintas keluar"
-      }
+      },
+      "emailNotifications": "Notifikasi",
+      "emailSettings": "Email",
+      "eventCPUHigh": "CPU tinggi (%)",
+      "eventGroupOutbound": "Outbound",
+      "eventGroupSecurity": "Keamanan",
+      "eventGroupSystem": "Sistem",
+      "eventGroupXray": "Xray Core",
+      "eventLoginAttempt": "Percobaan masuk",
+      "eventOutboundDown": "Mati",
+      "eventOutboundUp": "Aktif",
+      "eventXrayCrash": "Crash",
+      "requestFailed": "Permintaan gagal",
+      "smtpEnable": "Aktifkan Notifikasi Email",
+      "smtpEnableDesc": "Aktifkan notifikasi email melalui SMTP",
+      "smtpEncryption": "Enkripsi",
+      "smtpEncryptionDesc": "Metode enkripsi koneksi SMTP",
+      "smtpEncryptionNone": "Tidak ada (teks biasa)",
+      "smtpEncryptionStartTLS": "STARTTLS",
+      "smtpEncryptionTLS": "TLS (implisit)",
+      "smtpEventBusNotify": "Notifikasi Peristiwa Email",
+      "smtpEventBusNotifyDesc": "Pilih peristiwa yang memicu notifikasi email",
+      "smtpHost": "Host SMTP",
+      "smtpHostDesc": "Nama host server SMTP (mis. smtp.gmail.com)",
+      "smtpHostNotConfigured": "Host SMTP belum dikonfigurasi",
+      "smtpNoRecipients": "Tidak ada penerima yang dikonfigurasi",
+      "smtpNotInitialized": "SMTP belum diinisialisasi",
+      "smtpPassword": "Kata Sandi SMTP",
+      "smtpPasswordDesc": "Kata sandi autentikasi SMTP",
+      "smtpPort": "Port SMTP",
+      "smtpPortDesc": "Port server SMTP (bawaan: 587)",
+      "smtpSettings": "Pengaturan SMTP",
+      "smtpStageAuth": "Autentikasi",
+      "smtpStageConnect": "Koneksi",
+      "smtpStageSend": "Kirim",
+      "smtpTestSuccess": "Email uji berhasil dikirim",
+      "smtpTo": "Penerima",
+      "smtpToDesc": "Alamat email penerima dipisahkan dengan koma",
+      "smtpUsername": "Nama Pengguna SMTP",
+      "smtpUsernameDesc": "Nama pengguna autentikasi SMTP",
+      "telegramTokenConfigured": "Terkonfigurasi; kosongkan untuk mempertahankan token saat ini.",
+      "telegramTokenPlaceholder": "Terkonfigurasi - masukkan token baru untuk mengganti",
+      "testSmtp": "Kirim Email Uji",
+      "testTgBot": "Kirim Pesan Uji",
+      "tgBotNotEnabled": "Bot Telegram tidak aktif",
+      "tgBotNotRunning": "Bot Telegram tidak berjalan",
+      "tgEventBusNotify": "Notifikasi Peristiwa Telegram",
+      "tgEventBusNotifyDesc": "Pilih peristiwa yang memicu notifikasi Telegram",
+      "tgTestFailed": "Uji Telegram gagal",
+      "tgTestSuccess": "Pesan uji terkirim ke Telegram",
+      "smtpErrorAuth": "Autentikasi gagal — periksa nama pengguna dan kata sandi",
+      "smtpErrorStarttls": "Server memerlukan STARTTLS — ubah jenis enkripsi",
+      "smtpErrorTls": "Server memerlukan TLS — ubah jenis enkripsi",
+      "smtpErrorRefused": "Koneksi ditolak — periksa host dan port",
+      "smtpErrorTimeout": "Koneksi waktu habis — host tidak dapat dijangkau",
+      "smtpErrorRelay": "Server menolak pengiriman dari alamat ini",
+      "smtpErrorEof": "Koneksi ditutup oleh server",
+      "smtpErrorUnknown": "Kesalahan SMTP: {{ .Error }}",
+      "eventGroupNode": "Node",
+      "eventNodeDown": "Mati",
+      "eventNodeUp": "Aktif",
+      "smtpPasswordConfigured": "Terkonfigurasi; kosongkan untuk mempertahankan kata sandi saat ini.",
+      "smtpPasswordPlaceholder": "Terkonfigurasi - masukkan kata sandi baru untuk mengganti"
     },
     "xray": {
       "title": "Konfigurasi Xray",
@@ -1703,7 +1765,18 @@
       "AreYouSure": "Apakah kamu yakin? 🤔",
       "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ✅ Berhasil",
       "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ❌ Gagal \n\n🛠️ Kesalahan: [ {{ .ErrorMessage }} ]",
-      "FinishProcess": "🔚 Proses reset traffic selesai untuk semua klien."
+      "FinishProcess": "🔚 Proses reset traffic selesai untuk semua klien.",
+      "eventCPUHigh": "CPU tinggi",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventDelayDetail": "Penundaan: {{ .Delay }}ms",
+      "eventErrorDetail": "Kesalahan: {{ .Error }}",
+      "eventLoginFallback": "Gagal masuk dari {{ .Source }}",
+      "eventOutboundDown": "Outbound {{ .Tag }} MATI",
+      "eventOutboundUp": "Outbound {{ .Tag }} AKTIF",
+      "eventXrayCrash": "Xray CRASH",
+      "eventXrayCrashError": "Kesalahan: {{ .Error }}",
+      "eventNodeDown": "Node {{ .Name }} MATI",
+      "eventNodeUp": "Node {{ .Name }} AKTIF"
     },
     "buttons": {
       "closeKeyboard": "❌ Tutup Papan Ketik",
@@ -1773,5 +1846,57 @@
       "chooseClient": "Pilih Klien untuk Inbound {{ .Inbound }}",
       "chooseInbound": "Pilih Inbound"
     }
+  },
+  "email": {
+    "labelDelay": "Penundaan",
+    "labelDetail": "Detail",
+    "labelError": "Kesalahan",
+    "labelIP": "IP",
+    "labelOutbound": "Outbound",
+    "labelReason": "Alasan",
+    "labelSource": "Sumber",
+    "labelStatus": "Status",
+    "labelTime": "Waktu",
+    "labelUsername": "Nama Pengguna",
+    "statusBanned": "BANNED",
+    "statusCrashed": "CRASH",
+    "statusDown": "MATI",
+    "statusFailed": "GAGAL",
+    "statusFull": "FULL",
+    "statusHigh": "TINGGI",
+    "statusOffline": "OFFLINE",
+    "statusOnline": "ONLINE",
+    "statusRunning": "Berjalan",
+    "statusSuccess": "BERHASIL",
+    "statusUp": "AKTIF",
+    "statusXrayDown": "Xray DOWN",
+    "statusXrayUp": "Xray UP",
+    "subjectCPUHigh": "CPU tinggi",
+    "subjectDiskFull": "Disk full",
+    "subjectIPBanned": "IP banned: {{ .IP }}",
+    "subjectLoginFailed": "Gagal masuk",
+    "subjectLoginSuccess": "Berhasil masuk",
+    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
+    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
+    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
+    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
+    "subjectOutboundDown": "Outbound {{ .Tag }} MATI",
+    "subjectOutboundUp": "Outbound {{ .Tag }} AKTIF",
+    "subjectXrayCrash": "Xray CRASH",
+    "subjectXrayUp": "Xray is UP",
+    "titleCPUHigh": "CPU tinggi",
+    "titleDiskFull": "Disk full",
+    "titleIPBanned": "IP banned",
+    "titleLoginFailed": "Gagal masuk",
+    "titleLoginSuccess": "Berhasil masuk",
+    "titleNodeOffline": "Node OFFLINE",
+    "titleNodeOnline": "Node ONLINE",
+    "titleNodeXrayDown": "Node Xray DOWN",
+    "titleNodeXrayUp": "Node Xray UP",
+    "titleOutboundDown": "Outbound MATI",
+    "titleOutboundUp": "Outbound AKTIF",
+    "titleXrayCrash": "Xray CRASH",
+    "titleXrayUp": "Xray UP",
+    "labelNode": "Node"
   }
 }

+ 127 - 2
internal/web/translation/ja-JP.json

@@ -1200,7 +1200,69 @@
         "userPassMustBeNotEmpty": "新しいユーザー名と新しいパスワードは空にできません",
         "getOutboundTrafficError": "送信トラフィックの取得エラー",
         "resetOutboundTrafficError": "送信トラフィックのリセットエラー"
-      }
+      },
+      "emailNotifications": "通知",
+      "emailSettings": "メール",
+      "eventCPUHigh": "CPU高負荷(%)",
+      "eventGroupOutbound": "アウトバウンド",
+      "eventGroupSecurity": "セキュリティ",
+      "eventGroupSystem": "システム",
+      "eventGroupXray": "Xrayコア",
+      "eventLoginAttempt": "ログイン試行",
+      "eventOutboundDown": "ダウン",
+      "eventOutboundUp": "アップ",
+      "eventXrayCrash": "クラッシュ",
+      "requestFailed": "リクエストに失敗しました",
+      "smtpEnable": "メール通知を有効化",
+      "smtpEnableDesc": "SMTP経由のメール通知を有効にします",
+      "smtpEncryption": "暗号化",
+      "smtpEncryptionDesc": "SMTP接続の暗号化方式",
+      "smtpEncryptionNone": "なし(平文)",
+      "smtpEncryptionStartTLS": "STARTTLS",
+      "smtpEncryptionTLS": "TLS(暗黙的)",
+      "smtpEventBusNotify": "メールイベント通知",
+      "smtpEventBusNotifyDesc": "メール通知をトリガーするイベントを選択してください",
+      "smtpHost": "SMTPホスト",
+      "smtpHostDesc": "SMTPサーバーのホスト名(例: smtp.gmail.com)",
+      "smtpHostNotConfigured": "SMTPホストが設定されていません",
+      "smtpNoRecipients": "受信者が設定されていません",
+      "smtpNotInitialized": "SMTPが初期化されていません",
+      "smtpPassword": "SMTPパスワード",
+      "smtpPasswordDesc": "SMTP認証用のパスワード",
+      "smtpPort": "SMTPポート",
+      "smtpPortDesc": "SMTPサーバーのポート(既定値: 587)",
+      "smtpSettings": "SMTP設定",
+      "smtpStageAuth": "認証",
+      "smtpStageConnect": "接続",
+      "smtpStageSend": "送信",
+      "smtpTestSuccess": "テストメールを正常に送信しました",
+      "smtpTo": "受信者",
+      "smtpToDesc": "受信者のメールアドレス(カンマ区切り)",
+      "smtpUsername": "SMTPユーザー名",
+      "smtpUsernameDesc": "SMTP認証用のユーザー名",
+      "telegramTokenConfigured": "設定済み。現在のトークンを維持する場合は空欄のままにしてください。",
+      "telegramTokenPlaceholder": "設定済み - 置き換えるには新しいトークンを入力してください",
+      "testSmtp": "テストメールを送信",
+      "testTgBot": "テストメッセージを送信",
+      "tgBotNotEnabled": "Telegramボットが有効になっていません",
+      "tgBotNotRunning": "Telegramボットが実行されていません",
+      "tgEventBusNotify": "Telegramイベント通知",
+      "tgEventBusNotifyDesc": "Telegram通知をトリガーするイベントを選択してください",
+      "tgTestFailed": "Telegramのテストに失敗しました",
+      "tgTestSuccess": "Telegramにテストメッセージを送信しました",
+      "smtpErrorAuth": "認証に失敗しました — ユーザー名とパスワードを確認してください",
+      "smtpErrorStarttls": "サーバーはSTARTTLSを要求しています — 暗号化方式を変更してください",
+      "smtpErrorTls": "サーバーはTLSを要求しています — 暗号化方式を変更してください",
+      "smtpErrorRefused": "接続が拒否されました — ホストとポートを確認してください",
+      "smtpErrorTimeout": "接続がタイムアウトしました — ホストに到達できません",
+      "smtpErrorRelay": "サーバーはこのアドレスからの送信を拒否しています",
+      "smtpErrorEof": "サーバーによって接続が閉じられました",
+      "smtpErrorUnknown": "SMTPエラー: {{ .Error }}",
+      "eventGroupNode": "ノード",
+      "eventNodeDown": "ダウン",
+      "eventNodeUp": "アップ",
+      "smtpPasswordConfigured": "設定済み。現在のパスワードを維持する場合は空欄のままにしてください。",
+      "smtpPasswordPlaceholder": "設定済み - 置き換えるには新しいパスワードを入力してください"
     },
     "xray": {
       "title": "Xray 設定",
@@ -1703,7 +1765,18 @@
       "AreYouSure": "本当にいいですか?🤔",
       "SuccessResetTraffic": "📧 メール: {{ .ClientEmail }}\n🏁 結果: ✅ 成功",
       "FailedResetTraffic": "📧 メール: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ エラー: [ {{ .ErrorMessage }} ]",
-      "FinishProcess": "🔚 すべてのクライアントのトラフィックリセットが完了しました。"
+      "FinishProcess": "🔚 すべてのクライアントのトラフィックリセットが完了しました。",
+      "eventCPUHigh": "CPU高負荷",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventDelayDetail": "遅延: {{ .Delay }}ms",
+      "eventErrorDetail": "エラー: {{ .Error }}",
+      "eventLoginFallback": "{{ .Source }} からのログインに失敗しました",
+      "eventOutboundDown": "アウトバウンド {{ .Tag }} がダウンしています",
+      "eventOutboundUp": "アウトバウンド {{ .Tag }} が復旧しました",
+      "eventXrayCrash": "Xrayがクラッシュしました",
+      "eventXrayCrashError": "エラー: {{ .Error }}",
+      "eventNodeDown": "ノード {{ .Name }} がダウンしています",
+      "eventNodeUp": "ノード {{ .Name }} が復旧しました"
     },
     "buttons": {
       "closeKeyboard": "❌ キーボードを閉じる",
@@ -1773,5 +1846,57 @@
       "chooseClient": "インバウンド {{ .Inbound }} のクライアントを選択",
       "chooseInbound": "インバウンドを選択"
     }
+  },
+  "email": {
+    "labelDelay": "遅延",
+    "labelDetail": "詳細",
+    "labelError": "エラー",
+    "labelIP": "IP",
+    "labelOutbound": "アウトバウンド",
+    "labelReason": "理由",
+    "labelSource": "送信元",
+    "labelStatus": "ステータス",
+    "labelTime": "時刻",
+    "labelUsername": "ユーザー名",
+    "statusBanned": "BANNED",
+    "statusCrashed": "クラッシュ",
+    "statusDown": "ダウン",
+    "statusFailed": "失敗",
+    "statusFull": "FULL",
+    "statusHigh": "高負荷",
+    "statusOffline": "OFFLINE",
+    "statusOnline": "ONLINE",
+    "statusRunning": "実行中",
+    "statusSuccess": "成功",
+    "statusUp": "アップ",
+    "statusXrayDown": "Xray DOWN",
+    "statusXrayUp": "Xray UP",
+    "subjectCPUHigh": "CPU高負荷",
+    "subjectDiskFull": "Disk full",
+    "subjectIPBanned": "IP banned: {{ .IP }}",
+    "subjectLoginFailed": "ログイン失敗",
+    "subjectLoginSuccess": "ログイン成功",
+    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
+    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
+    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
+    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
+    "subjectOutboundDown": "アウトバウンド {{ .Tag }} がダウンしています",
+    "subjectOutboundUp": "アウトバウンド {{ .Tag }} が復旧しました",
+    "subjectXrayCrash": "Xrayがクラッシュしました",
+    "subjectXrayUp": "Xray is UP",
+    "titleCPUHigh": "CPU高負荷",
+    "titleDiskFull": "Disk full",
+    "titleIPBanned": "IP banned",
+    "titleLoginFailed": "ログイン失敗",
+    "titleLoginSuccess": "ログイン成功",
+    "titleNodeOffline": "Node OFFLINE",
+    "titleNodeOnline": "Node ONLINE",
+    "titleNodeXrayDown": "Node Xray DOWN",
+    "titleNodeXrayUp": "Node Xray UP",
+    "titleOutboundDown": "アウトバウンド ダウン",
+    "titleOutboundUp": "アウトバウンド 復旧",
+    "titleXrayCrash": "Xrayがクラッシュしました",
+    "titleXrayUp": "Xray UP",
+    "labelNode": "ノード"
   }
 }

+ 127 - 2
internal/web/translation/pt-BR.json

@@ -1200,7 +1200,69 @@
         "userPassMustBeNotEmpty": "O novo nome de usuário e senha não podem estar vazios",
         "getOutboundTrafficError": "Erro ao obter tráfego de saída",
         "resetOutboundTrafficError": "Erro ao redefinir tráfego de saída"
-      }
+      },
+      "emailNotifications": "Notificações",
+      "emailSettings": "E-mail",
+      "eventCPUHigh": "CPU alta (%)",
+      "eventGroupOutbound": "Outbound",
+      "eventGroupSecurity": "Segurança",
+      "eventGroupSystem": "Sistema",
+      "eventGroupXray": "Núcleo Xray",
+      "eventLoginAttempt": "Tentativa de login",
+      "eventOutboundDown": "Inativo",
+      "eventOutboundUp": "Ativo",
+      "eventXrayCrash": "Falha",
+      "requestFailed": "Falha na requisição",
+      "smtpEnable": "Ativar notificações por e-mail",
+      "smtpEnableDesc": "Ativar notificações por e-mail via SMTP",
+      "smtpEncryption": "Criptografia",
+      "smtpEncryptionDesc": "Método de criptografia da conexão SMTP",
+      "smtpEncryptionNone": "Nenhuma (texto puro)",
+      "smtpEncryptionStartTLS": "STARTTLS",
+      "smtpEncryptionTLS": "TLS (implícito)",
+      "smtpEventBusNotify": "Notificações de eventos por e-mail",
+      "smtpEventBusNotifyDesc": "Selecione quais eventos disparam notificações por e-mail",
+      "smtpHost": "Servidor SMTP",
+      "smtpHostDesc": "Nome do host do servidor SMTP (ex.: smtp.gmail.com)",
+      "smtpHostNotConfigured": "Servidor SMTP não configurado",
+      "smtpNoRecipients": "Nenhum destinatário configurado",
+      "smtpNotInitialized": "SMTP não inicializado",
+      "smtpPassword": "Senha SMTP",
+      "smtpPasswordDesc": "Senha para autenticação SMTP",
+      "smtpPort": "Porta SMTP",
+      "smtpPortDesc": "Porta do servidor SMTP (padrão: 587)",
+      "smtpSettings": "Configurações SMTP",
+      "smtpStageAuth": "Autenticação",
+      "smtpStageConnect": "Conexão",
+      "smtpStageSend": "Envio",
+      "smtpTestSuccess": "E-mail de teste enviado com sucesso",
+      "smtpTo": "Destinatários",
+      "smtpToDesc": "Endereços de e-mail dos destinatários separados por vírgula",
+      "smtpUsername": "Usuário SMTP",
+      "smtpUsernameDesc": "Nome de usuário para autenticação SMTP",
+      "telegramTokenConfigured": "Configurado; deixe em branco para manter o token atual.",
+      "telegramTokenPlaceholder": "Configurado - insira um novo token para substituir",
+      "testSmtp": "Enviar e-mail de teste",
+      "testTgBot": "Enviar mensagem de teste",
+      "tgBotNotEnabled": "O bot do Telegram não está ativado",
+      "tgBotNotRunning": "O bot do Telegram não está em execução",
+      "tgEventBusNotify": "Notificações de eventos no Telegram",
+      "tgEventBusNotifyDesc": "Selecione quais eventos disparam notificações no Telegram",
+      "tgTestFailed": "Falha no teste do Telegram",
+      "tgTestSuccess": "Mensagem de teste enviada ao Telegram",
+      "smtpErrorAuth": "Falha na autenticação — verifique o nome de usuário e a senha",
+      "smtpErrorStarttls": "O servidor requer STARTTLS — altere o tipo de criptografia",
+      "smtpErrorTls": "O servidor requer TLS — altere o tipo de criptografia",
+      "smtpErrorRefused": "Conexão recusada — verifique o host e a porta",
+      "smtpErrorTimeout": "Tempo de conexão esgotado — host inacessível",
+      "smtpErrorRelay": "O servidor rejeita o envio a partir deste endereço",
+      "smtpErrorEof": "Conexão encerrada pelo servidor",
+      "smtpErrorUnknown": "Erro de SMTP: {{ .Error }}",
+      "eventGroupNode": "Nós",
+      "eventNodeDown": "Inativo",
+      "eventNodeUp": "Ativo",
+      "smtpPasswordConfigured": "Configurada; deixe em branco para manter a senha atual.",
+      "smtpPasswordPlaceholder": "Configurada - insira uma nova senha para substituir"
     },
     "xray": {
       "title": "Configurações Xray",
@@ -1703,7 +1765,18 @@
       "AreYouSure": "Você tem certeza? 🤔",
       "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ✅ Sucesso",
       "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ❌ Falhou \n\n🛠️ Erro: [ {{ .ErrorMessage }} ]",
-      "FinishProcess": "🔚 Processo de redefinição de tráfego concluído para todos os clientes."
+      "FinishProcess": "🔚 Processo de redefinição de tráfego concluído para todos os clientes.",
+      "eventCPUHigh": "CPU alta",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventDelayDetail": "Latência: {{ .Delay }}ms",
+      "eventErrorDetail": "Erro: {{ .Error }}",
+      "eventLoginFallback": "Falha de login a partir de {{ .Source }}",
+      "eventOutboundDown": "O outbound {{ .Tag }} está INATIVO",
+      "eventOutboundUp": "O outbound {{ .Tag }} está ATIVO",
+      "eventXrayCrash": "O Xray FALHOU",
+      "eventXrayCrashError": "Erro: {{ .Error }}",
+      "eventNodeDown": "O nó {{ .Name }} está INATIVO",
+      "eventNodeUp": "O nó {{ .Name }} está ATIVO"
     },
     "buttons": {
       "closeKeyboard": "❌ Fechar teclado",
@@ -1773,5 +1846,57 @@
       "chooseClient": "Escolha um cliente para Inbound {{ .Inbound }}",
       "chooseInbound": "Escolha um Inbound"
     }
+  },
+  "email": {
+    "labelDelay": "Latência",
+    "labelDetail": "Detalhe",
+    "labelError": "Erro",
+    "labelIP": "IP",
+    "labelOutbound": "Outbound",
+    "labelReason": "Motivo",
+    "labelSource": "Origem",
+    "labelStatus": "Status",
+    "labelTime": "Horário",
+    "labelUsername": "Nome de usuário",
+    "statusBanned": "BANNED",
+    "statusCrashed": "FALHOU",
+    "statusDown": "INATIVO",
+    "statusFailed": "FALHOU",
+    "statusFull": "FULL",
+    "statusHigh": "ALTA",
+    "statusOffline": "OFFLINE",
+    "statusOnline": "ONLINE",
+    "statusRunning": "Em execução",
+    "statusSuccess": "SUCESSO",
+    "statusUp": "ATIVO",
+    "statusXrayDown": "Xray DOWN",
+    "statusXrayUp": "Xray UP",
+    "subjectCPUHigh": "CPU alta",
+    "subjectDiskFull": "Disk full",
+    "subjectIPBanned": "IP banned: {{ .IP }}",
+    "subjectLoginFailed": "Falha de login",
+    "subjectLoginSuccess": "Login bem-sucedido",
+    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
+    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
+    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
+    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
+    "subjectOutboundDown": "O outbound {{ .Tag }} está INATIVO",
+    "subjectOutboundUp": "O outbound {{ .Tag }} está ATIVO",
+    "subjectXrayCrash": "O Xray FALHOU",
+    "subjectXrayUp": "Xray is UP",
+    "titleCPUHigh": "CPU alta",
+    "titleDiskFull": "Disk full",
+    "titleIPBanned": "IP banned",
+    "titleLoginFailed": "Falha de login",
+    "titleLoginSuccess": "Login bem-sucedido",
+    "titleNodeOffline": "Node OFFLINE",
+    "titleNodeOnline": "Node ONLINE",
+    "titleNodeXrayDown": "Node Xray DOWN",
+    "titleNodeXrayUp": "Node Xray UP",
+    "titleOutboundDown": "Outbound INATIVO",
+    "titleOutboundUp": "Outbound ATIVO",
+    "titleXrayCrash": "O Xray FALHOU",
+    "titleXrayUp": "Xray UP",
+    "labelNode": "Nó"
   }
 }

+ 107 - 2
internal/web/translation/ru-RU.json

@@ -1200,7 +1200,69 @@
         "userPassMustBeNotEmpty": "Новое имя пользователя и новый пароль должны быть заполнены",
         "getOutboundTrafficError": "Ошибка получения трафика исходящего подключения",
         "resetOutboundTrafficError": "Ошибка сброса трафика исходящего подключения"
-      }
+      },
+      "smtpSettings": "Настройки SMTP",
+      "smtpEnable": "Включить уведомления по Email",
+      "smtpEnableDesc": "Включить уведомления по email через SMTP",
+      "smtpHost": "SMTP хост",
+      "smtpHostDesc": "Имя хоста SMTP сервера (например smtp.gmail.com)",
+      "smtpPort": "SMTP порт",
+      "smtpPortDesc": "Порт SMTP сервера (по умолчанию: 587)",
+      "smtpUsername": "SMTP логин",
+      "smtpUsernameDesc": "Логин для аутентификации SMTP",
+      "smtpPassword": "SMTP пароль",
+      "smtpPasswordDesc": "Пароль для аутентификации SMTP",
+      "smtpTo": "Получатели",
+      "smtpToDesc": "Адреса получателей через запятую",
+      "emailSettings": "Email",
+      "emailNotifications": "Уведомления",
+      "smtpEventBusNotify": "Email уведомления о событиях",
+      "smtpEventBusNotifyDesc": "Выберите события для email уведомлений",
+      "tgEventBusNotify": "Telegram уведомления о событиях",
+      "tgEventBusNotifyDesc": "Выберите события для Telegram уведомлений",
+      "testSmtp": "Отправить тестовое письмо",
+      "testTgBot": "Отправить тестовое сообщение",
+      "eventGroupOutbound": "Исходящие",
+      "eventGroupXray": "Ядро Xray",
+      "eventGroupSystem": "Система",
+      "eventGroupSecurity": "Безопасность",
+      "eventOutboundDown": "Недоступен",
+      "eventOutboundUp": "Работает",
+      "eventXrayCrash": "Сбой",
+      "eventCPUHigh": "Превышение порога CPU (%)",
+      "requestFailed": "Запрос не удался",
+      "smtpEncryption": "Шифрование",
+      "smtpEncryptionDesc": "Метод шифрования SMTP соединения",
+      "smtpEncryptionNone": "Нет (открытый текст)",
+      "smtpEncryptionStartTLS": "STARTTLS",
+      "smtpEncryptionTLS": "TLS (неявное)",
+      "smtpStageConnect": "Подключение",
+      "smtpStageAuth": "Аутентификация",
+      "smtpStageSend": "Отправка",
+      "smtpTestSuccess": "Тестовое письмо отправлено успешно",
+      "smtpHostNotConfigured": "SMTP хост не настроен",
+      "smtpNoRecipients": "Получатели не настроены",
+      "eventLoginAttempt": "Попытка входа",
+      "telegramTokenConfigured": "Настроен; оставьте пустым для сохранения текущего токена.",
+      "telegramTokenPlaceholder": "Настроен - введите новый токен для замены",
+      "smtpNotInitialized": "SMTP не инициализирован",
+      "tgBotNotEnabled": "Telegram бот не включен",
+      "tgTestFailed": "Тест Telegram не удался",
+      "tgTestSuccess": "Тестовое сообщение отправлено в Telegram",
+      "tgBotNotRunning": "Telegram бот не запущен",
+      "smtpErrorAuth": "Ошибка аутентификации — проверьте логин и пароль",
+      "smtpErrorStarttls": "Сервер требует STARTTLS — измените тип шифрования",
+      "smtpErrorTls": "Сервер требует TLS — измените тип шифрования",
+      "smtpErrorRefused": "Соединение отклонено — проверьте хост и порт",
+      "smtpErrorTimeout": "Таймаут соединения — хост недоступен",
+      "smtpErrorRelay": "Сервер отклоняет отправку с этого адреса",
+      "smtpErrorEof": "Соединение закрыто сервером",
+      "smtpErrorUnknown": "Ошибка SMTP: {{ .Error }}",
+      "eventGroupNode": "Узлы",
+      "eventNodeDown": "Недоступен",
+      "eventNodeUp": "В сети",
+      "smtpPasswordConfigured": "Настроен; оставьте пустым для сохранения текущего пароля.",
+      "smtpPasswordPlaceholder": "Настроен - введите новый пароль для замены"
     },
     "xray": {
       "title": "Настройки Xray",
@@ -1703,7 +1765,18 @@
       "AreYouSure": "Вы уверены? 🤔",
       "SuccessResetTraffic": "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успешно",
       "FailedResetTraffic": "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ❌ Неудача \n\n🛠️ Ошибка: [ {{ .ErrorMessage }} ]",
-      "FinishProcess": "🔚 Сброс трафика завершён для всех клиентов."
+      "FinishProcess": "🔚 Сброс трафика завершён для всех клиентов.",
+      "eventOutboundDown": "Исходящее подключение {{ .Tag }} НЕДОСТУПНО",
+      "eventOutboundUp": "Исходящее подключение {{ .Tag }} РАБОТАЕТ",
+      "eventXrayCrash": "Сбой Xray",
+      "eventXrayCrashError": "Ошибка: {{ .Error }}",
+      "eventCPUHigh": "Высокая загрузка CPU",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventLoginFallback": "Неудачный вход с {{ .Source }}",
+      "eventDelayDetail": "Задержка: {{ .Delay }} мс",
+      "eventErrorDetail": "Ошибка: {{ .Error }}",
+      "eventNodeDown": "Узел {{ .Name }} НЕДОСТУПЕН",
+      "eventNodeUp": "Узел {{ .Name }} В СЕТИ"
     },
     "buttons": {
       "closeKeyboard": "❌ Закрыть клавиатуру",
@@ -1773,5 +1846,37 @@
       "chooseClient": "Выберите клиента для входящего подключения {{ .Inbound }}",
       "chooseInbound": "Выберите входящее подключение"
     }
+  },
+  "email": {
+    "subjectOutboundDown": "Исходящее подключение {{ .Tag }} НЕДОСТУПНО",
+    "subjectOutboundUp": "Исходящее подключение {{ .Tag }} РАБОТАЕТ",
+    "subjectXrayCrash": "Сбой Xray",
+    "subjectCPUHigh": "Высокая загрузка CPU",
+    "subjectLoginSuccess": "Успешный вход",
+    "subjectLoginFailed": "Неудачный вход",
+    "titleOutboundDown": "Исходящее подключение НЕДОСТУПНО",
+    "titleOutboundUp": "Исходящее подключение РАБОТАЕТ",
+    "titleXrayCrash": "Сбой Xray",
+    "titleCPUHigh": "Высокая загрузка CPU",
+    "titleLoginSuccess": "Успешный вход",
+    "titleLoginFailed": "Неудачный вход",
+    "labelStatus": "Статус",
+    "labelOutbound": "Исходящее подключение",
+    "labelError": "Ошибка",
+    "labelDelay": "Задержка",
+    "labelDetail": "Подробности",
+    "labelUsername": "Имя пользователя",
+    "labelIP": "IP",
+    "labelReason": "Причина",
+    "labelSource": "Источник",
+    "labelTime": "Время",
+    "statusCrashed": "СБОЙ",
+    "statusRunning": "Работает",
+    "statusHigh": "ВЫСОКАЯ",
+    "statusSuccess": "УСПЕШНО",
+    "statusFailed": "НЕУДАЧНО",
+    "statusDown": "НЕДОСТУПЕН",
+    "statusUp": "РАБОТАЕТ",
+    "labelNode": "Узел"
   }
 }

+ 127 - 2
internal/web/translation/tr-TR.json

@@ -1199,7 +1199,69 @@
         "userPassMustBeNotEmpty": "Yeni kullanıcı adı ve şifre boş olamaz.",
         "getOutboundTrafficError": "Giden trafik alınırken hata oluştu.",
         "resetOutboundTrafficError": "Giden trafik sıfırlanırken hata oluştu."
-      }
+      },
+      "emailNotifications": "Bildirimler",
+      "emailSettings": "E-posta",
+      "eventCPUHigh": "Yüksek CPU (%)",
+      "eventGroupOutbound": "Giden Bağlantı",
+      "eventGroupSecurity": "Güvenlik",
+      "eventGroupSystem": "Sistem",
+      "eventGroupXray": "Xray Çekirdeği",
+      "eventLoginAttempt": "Oturum açma denemesi",
+      "eventOutboundDown": "Çevrimdışı",
+      "eventOutboundUp": "Çevrimiçi",
+      "eventXrayCrash": "Çökme",
+      "requestFailed": "İstek başarısız oldu",
+      "smtpEnable": "E-posta Bildirimlerini Etkinleştir",
+      "smtpEnableDesc": "SMTP üzerinden e-posta bildirimlerini etkinleştirin",
+      "smtpEncryption": "Şifreleme",
+      "smtpEncryptionDesc": "SMTP bağlantı şifreleme yöntemi",
+      "smtpEncryptionNone": "Yok (düz metin)",
+      "smtpEncryptionStartTLS": "STARTTLS",
+      "smtpEncryptionTLS": "TLS (örtük)",
+      "smtpEventBusNotify": "E-posta Olay Bildirimleri",
+      "smtpEventBusNotifyDesc": "Hangi olayların e-posta bildirimi tetikleyeceğini seçin",
+      "smtpHost": "SMTP Sunucusu",
+      "smtpHostDesc": "SMTP sunucu ana bilgisayar adı (örn. smtp.gmail.com)",
+      "smtpHostNotConfigured": "SMTP sunucusu yapılandırılmamış",
+      "smtpNoRecipients": "Yapılandırılmış alıcı yok",
+      "smtpNotInitialized": "SMTP başlatılmadı",
+      "smtpPassword": "SMTP Parolası",
+      "smtpPasswordDesc": "SMTP kimlik doğrulama parolası",
+      "smtpPort": "SMTP Bağlantı Noktası",
+      "smtpPortDesc": "SMTP sunucu bağlantı noktası (varsayılan: 587)",
+      "smtpSettings": "SMTP Ayarları",
+      "smtpStageAuth": "Kimlik Doğrulama",
+      "smtpStageConnect": "Bağlantı",
+      "smtpStageSend": "Gönderim",
+      "smtpTestSuccess": "Test e-postası başarıyla gönderildi",
+      "smtpTo": "Alıcılar",
+      "smtpToDesc": "Virgülle ayrılmış alıcı e-posta adresleri",
+      "smtpUsername": "SMTP Kullanıcı Adı",
+      "smtpUsernameDesc": "SMTP kimlik doğrulama kullanıcı adı",
+      "telegramTokenConfigured": "Yapılandırıldı; mevcut belirteci korumak için boş bırakın.",
+      "telegramTokenPlaceholder": "Yapılandırıldı - değiştirmek için yeni bir belirteç girin",
+      "testSmtp": "Test E-postası Gönder",
+      "testTgBot": "Test Mesajı Gönder",
+      "tgBotNotEnabled": "Telegram botu etkin değil",
+      "tgBotNotRunning": "Telegram botu çalışmıyor",
+      "tgEventBusNotify": "Telegram Olay Bildirimleri",
+      "tgEventBusNotifyDesc": "Hangi olayların Telegram bildirimi tetikleyeceğini seçin",
+      "tgTestFailed": "Telegram testi başarısız oldu",
+      "tgTestSuccess": "Test mesajı Telegram'a gönderildi",
+      "smtpErrorAuth": "Kimlik doğrulama başarısız — kullanıcı adını ve parolayı kontrol edin",
+      "smtpErrorStarttls": "Sunucu STARTTLS gerektiriyor — şifreleme türünü değiştirin",
+      "smtpErrorTls": "Sunucu TLS gerektiriyor — şifreleme türünü değiştirin",
+      "smtpErrorRefused": "Bağlantı reddedildi — sunucuyu ve bağlantı noktasını kontrol edin",
+      "smtpErrorTimeout": "Bağlantı zaman aşımına uğradı — sunucuya ulaşılamıyor",
+      "smtpErrorRelay": "Sunucu bu adresten gönderimi reddediyor",
+      "smtpErrorEof": "Bağlantı sunucu tarafından kapatıldı",
+      "smtpErrorUnknown": "SMTP hatası: {{ .Error }}",
+      "eventGroupNode": "Düğümler",
+      "eventNodeDown": "Çevrimdışı",
+      "eventNodeUp": "Çevrimiçi",
+      "smtpPasswordConfigured": "Yapılandırıldı; mevcut parolayı korumak için boş bırakın.",
+      "smtpPasswordPlaceholder": "Yapılandırıldı - değiştirmek için yeni bir parola girin"
     },
     "xray": {
       "title": "Xray Yapılandırmaları",
@@ -1702,7 +1764,18 @@
       "AreYouSure": "Emin misiniz? 🤔",
       "SuccessResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ✅ Başarılı",
       "FailedResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ❌ Başarısız \n\n🛠️ Hata: [ {{ .ErrorMessage }} ]",
-      "FinishProcess": "🔚 Tüm kullanıcılar için trafik sıfırlama işlemi tamamlandı."
+      "FinishProcess": "🔚 Tüm kullanıcılar için trafik sıfırlama işlemi tamamlandı.",
+      "eventCPUHigh": "Yüksek CPU",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventDelayDetail": "Gecikme: {{ .Delay }}ms",
+      "eventErrorDetail": "Hata: {{ .Error }}",
+      "eventLoginFallback": "{{ .Source }} adresinden oturum açma başarısız",
+      "eventOutboundDown": "{{ .Tag }} giden bağlantısı ÇEVRİMDIŞI",
+      "eventOutboundUp": "{{ .Tag }} giden bağlantısı ÇEVRİMİÇİ",
+      "eventXrayCrash": "Xray ÇÖKTÜ",
+      "eventXrayCrashError": "Hata: {{ .Error }}",
+      "eventNodeDown": "{{ .Name }} düğümü ÇEVRİMDIŞI",
+      "eventNodeUp": "{{ .Name }} düğümü ÇEVRİMİÇİ"
     },
     "buttons": {
       "closeKeyboard": "❌ Klavyeyi Kapat",
@@ -1772,5 +1845,57 @@
       "chooseClient": "Gelen Bağlantı {{ .Inbound }} için bir Kullanıcı Seçin",
       "chooseInbound": "Bir Gelen Bağlantı Seçin"
     }
+  },
+  "email": {
+    "labelDelay": "Gecikme",
+    "labelDetail": "Ayrıntı",
+    "labelError": "Hata",
+    "labelIP": "IP",
+    "labelOutbound": "Giden Bağlantı",
+    "labelReason": "Neden",
+    "labelSource": "Kaynak",
+    "labelStatus": "Durum",
+    "labelTime": "Zaman",
+    "labelUsername": "Kullanıcı Adı",
+    "statusBanned": "BANNED",
+    "statusCrashed": "ÇÖKTÜ",
+    "statusDown": "ÇEVRİMDIŞI",
+    "statusFailed": "BAŞARISIZ",
+    "statusFull": "FULL",
+    "statusHigh": "YÜKSEK",
+    "statusOffline": "OFFLINE",
+    "statusOnline": "ONLINE",
+    "statusRunning": "Çalışıyor",
+    "statusSuccess": "BAŞARILI",
+    "statusUp": "ÇEVRİMİÇİ",
+    "statusXrayDown": "Xray DOWN",
+    "statusXrayUp": "Xray UP",
+    "subjectCPUHigh": "Yüksek CPU",
+    "subjectDiskFull": "Disk full",
+    "subjectIPBanned": "IP banned: {{ .IP }}",
+    "subjectLoginFailed": "Oturum açma başarısız",
+    "subjectLoginSuccess": "Oturum açma başarılı",
+    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
+    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
+    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
+    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
+    "subjectOutboundDown": "{{ .Tag }} giden bağlantısı ÇEVRİMDIŞI",
+    "subjectOutboundUp": "{{ .Tag }} giden bağlantısı ÇEVRİMİÇİ",
+    "subjectXrayCrash": "Xray ÇÖKTÜ",
+    "subjectXrayUp": "Xray is UP",
+    "titleCPUHigh": "Yüksek CPU",
+    "titleDiskFull": "Disk full",
+    "titleIPBanned": "IP banned",
+    "titleLoginFailed": "Oturum açma başarısız",
+    "titleLoginSuccess": "Oturum açma başarılı",
+    "titleNodeOffline": "Node OFFLINE",
+    "titleNodeOnline": "Node ONLINE",
+    "titleNodeXrayDown": "Node Xray DOWN",
+    "titleNodeXrayUp": "Node Xray UP",
+    "titleOutboundDown": "Giden Bağlantı ÇEVRİMDIŞI",
+    "titleOutboundUp": "Giden Bağlantı ÇEVRİMİÇİ",
+    "titleXrayCrash": "Xray ÇÖKTÜ",
+    "titleXrayUp": "Xray UP",
+    "labelNode": "Düğüm"
   }
 }

+ 127 - 2
internal/web/translation/uk-UA.json

@@ -1200,7 +1200,69 @@
         "userPassMustBeNotEmpty": "Нове ім'я користувача та пароль порожні",
         "getOutboundTrafficError": "Помилка отримання вихідного трафіку",
         "resetOutboundTrafficError": "Помилка скидання вихідного трафіку"
-      }
+      },
+      "emailNotifications": "Сповіщення",
+      "emailSettings": "Електронна пошта",
+      "eventCPUHigh": "Високе навантаження на CPU (%)",
+      "eventGroupOutbound": "Вихідні з'єднання",
+      "eventGroupSecurity": "Безпека",
+      "eventGroupSystem": "Система",
+      "eventGroupXray": "Ядро Xray",
+      "eventLoginAttempt": "Спроба входу",
+      "eventOutboundDown": "Недоступне",
+      "eventOutboundUp": "Доступне",
+      "eventXrayCrash": "Збій",
+      "requestFailed": "Запит не вдалося виконати",
+      "smtpEnable": "Увімкнути сповіщення електронною поштою",
+      "smtpEnableDesc": "Увімкнути сповіщення електронною поштою через SMTP",
+      "smtpEncryption": "Шифрування",
+      "smtpEncryptionDesc": "Метод шифрування з'єднання SMTP",
+      "smtpEncryptionNone": "Немає (відкритий текст)",
+      "smtpEncryptionStartTLS": "STARTTLS",
+      "smtpEncryptionTLS": "TLS (неявне)",
+      "smtpEventBusNotify": "Сповіщення про події електронною поштою",
+      "smtpEventBusNotifyDesc": "Виберіть, які події спричиняють сповіщення електронною поштою",
+      "smtpHost": "Хост SMTP",
+      "smtpHostDesc": "Ім'я хоста сервера SMTP (наприклад, smtp.gmail.com)",
+      "smtpHostNotConfigured": "Хост SMTP не налаштовано",
+      "smtpNoRecipients": "Отримувачів не налаштовано",
+      "smtpNotInitialized": "SMTP не ініціалізовано",
+      "smtpPassword": "Пароль SMTP",
+      "smtpPasswordDesc": "Пароль для автентифікації SMTP",
+      "smtpPort": "Порт SMTP",
+      "smtpPortDesc": "Порт сервера SMTP (типово: 587)",
+      "smtpSettings": "Налаштування SMTP",
+      "smtpStageAuth": "Автентифікація",
+      "smtpStageConnect": "З'єднання",
+      "smtpStageSend": "Надсилання",
+      "smtpTestSuccess": "Тестовий лист успішно надіслано",
+      "smtpTo": "Отримувачі",
+      "smtpToDesc": "Адреси електронної пошти отримувачів, розділені комами",
+      "smtpUsername": "Ім'я користувача SMTP",
+      "smtpUsernameDesc": "Ім'я користувача для автентифікації SMTP",
+      "telegramTokenConfigured": "Налаштовано; залиште порожнім, щоб зберегти поточний токен.",
+      "telegramTokenPlaceholder": "Налаштовано — введіть новий токен для заміни",
+      "testSmtp": "Надіслати тестовий лист",
+      "testTgBot": "Надіслати тестове повідомлення",
+      "tgBotNotEnabled": "Бот Telegram не увімкнено",
+      "tgBotNotRunning": "Бот Telegram не запущено",
+      "tgEventBusNotify": "Сповіщення про події в Telegram",
+      "tgEventBusNotifyDesc": "Виберіть, які події спричиняють сповіщення в Telegram",
+      "tgTestFailed": "Тест Telegram не вдався",
+      "tgTestSuccess": "Тестове повідомлення надіслано в Telegram",
+      "smtpErrorAuth": "Помилка автентифікації — перевірте ім'я користувача та пароль",
+      "smtpErrorStarttls": "Сервер вимагає STARTTLS — змініть тип шифрування",
+      "smtpErrorTls": "Сервер вимагає TLS — змініть тип шифрування",
+      "smtpErrorRefused": "У з'єднанні відмовлено — перевірте хост і порт",
+      "smtpErrorTimeout": "Час очікування з'єднання вичерпано — хост недоступний",
+      "smtpErrorRelay": "Сервер відхиляє надсилання з цієї адреси",
+      "smtpErrorEof": "З'єднання закрито сервером",
+      "smtpErrorUnknown": "Помилка SMTP: {{ .Error }}",
+      "eventGroupNode": "Вузли",
+      "eventNodeDown": "Недоступний",
+      "eventNodeUp": "Доступний",
+      "smtpPasswordConfigured": "Налаштовано; залиште порожнім, щоб зберегти поточний пароль.",
+      "smtpPasswordPlaceholder": "Налаштовано — введіть новий пароль для заміни"
     },
     "xray": {
       "title": "Xray конфігурації",
@@ -1703,7 +1765,18 @@
       "AreYouSure": "Ви впевнені? 🤔",
       "SuccessResetTraffic": "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успішно",
       "FailedResetTraffic": "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ❌ Невдача \n\n🛠️ Помилка: [ {{ .ErrorMessage }} ]",
-      "FinishProcess": "🔚 Процес скидання трафіку завершено для всіх клієнтів."
+      "FinishProcess": "🔚 Процес скидання трафіку завершено для всіх клієнтів.",
+      "eventCPUHigh": "Високе навантаження на CPU",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventDelayDetail": "Затримка: {{ .Delay }} мс",
+      "eventErrorDetail": "Помилка: {{ .Error }}",
+      "eventLoginFallback": "Невдала спроба входу з {{ .Source }}",
+      "eventOutboundDown": "Вихідне з'єднання {{ .Tag }} НЕДОСТУПНЕ",
+      "eventOutboundUp": "Вихідне з'єднання {{ .Tag }} ДОСТУПНЕ",
+      "eventXrayCrash": "Стався збій Xray",
+      "eventXrayCrashError": "Помилка: {{ .Error }}",
+      "eventNodeDown": "Вузол {{ .Name }} НЕДОСТУПНИЙ",
+      "eventNodeUp": "Вузол {{ .Name }} ДОСТУПНИЙ"
     },
     "buttons": {
       "closeKeyboard": "❌ Закрити клавіатуру",
@@ -1773,5 +1846,57 @@
       "chooseClient": "Виберіть клієнта для Вхідного {{ .Inbound }}",
       "chooseInbound": "Виберіть Вхідний"
     }
+  },
+  "email": {
+    "labelDelay": "Затримка",
+    "labelDetail": "Деталі",
+    "labelError": "Помилка",
+    "labelIP": "IP",
+    "labelOutbound": "Вихідне з'єднання",
+    "labelReason": "Причина",
+    "labelSource": "Джерело",
+    "labelStatus": "Статус",
+    "labelTime": "Час",
+    "labelUsername": "Ім'я користувача",
+    "statusBanned": "BANNED",
+    "statusCrashed": "ЗБІЙ",
+    "statusDown": "НЕДОСТУПНО",
+    "statusFailed": "НЕВДАЛО",
+    "statusFull": "FULL",
+    "statusHigh": "ВИСОКЕ",
+    "statusOffline": "OFFLINE",
+    "statusOnline": "ONLINE",
+    "statusRunning": "Працює",
+    "statusSuccess": "УСПІШНО",
+    "statusUp": "ДОСТУПНО",
+    "statusXrayDown": "Xray DOWN",
+    "statusXrayUp": "Xray UP",
+    "subjectCPUHigh": "Високе навантаження на CPU",
+    "subjectDiskFull": "Disk full",
+    "subjectIPBanned": "IP banned: {{ .IP }}",
+    "subjectLoginFailed": "Невдалий вхід",
+    "subjectLoginSuccess": "Успішний вхід",
+    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
+    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
+    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
+    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
+    "subjectOutboundDown": "Вихідне з'єднання {{ .Tag }} НЕДОСТУПНЕ",
+    "subjectOutboundUp": "Вихідне з'єднання {{ .Tag }} ДОСТУПНЕ",
+    "subjectXrayCrash": "Стався збій Xray",
+    "subjectXrayUp": "Xray is UP",
+    "titleCPUHigh": "Високе навантаження на CPU",
+    "titleDiskFull": "Disk full",
+    "titleIPBanned": "IP banned",
+    "titleLoginFailed": "Невдалий вхід",
+    "titleLoginSuccess": "Успішний вхід",
+    "titleNodeOffline": "Node OFFLINE",
+    "titleNodeOnline": "Node ONLINE",
+    "titleNodeXrayDown": "Node Xray DOWN",
+    "titleNodeXrayUp": "Node Xray UP",
+    "titleOutboundDown": "Вихідне з'єднання НЕДОСТУПНЕ",
+    "titleOutboundUp": "Вихідне з'єднання ДОСТУПНЕ",
+    "titleXrayCrash": "Стався збій Xray",
+    "titleXrayUp": "Xray UP",
+    "labelNode": "Вузол"
   }
 }

+ 127 - 2
internal/web/translation/vi-VN.json

@@ -1200,7 +1200,69 @@
         "userPassMustBeNotEmpty": "Tên người dùng mới và mật khẩu mới không thể để trống",
         "getOutboundTrafficError": "Lỗi khi lấy lưu lượng truy cập đi",
         "resetOutboundTrafficError": "Lỗi khi đặt lại lưu lượng truy cập đi"
-      }
+      },
+      "emailNotifications": "Thông báo",
+      "emailSettings": "Email",
+      "eventCPUHigh": "CPU cao (%)",
+      "eventGroupOutbound": "Outbound",
+      "eventGroupSecurity": "Bảo mật",
+      "eventGroupSystem": "Hệ thống",
+      "eventGroupXray": "Xray Core",
+      "eventLoginAttempt": "Lần thử đăng nhập",
+      "eventOutboundDown": "Ngừng hoạt động",
+      "eventOutboundUp": "Hoạt động",
+      "eventXrayCrash": "Sự cố",
+      "requestFailed": "Yêu cầu thất bại",
+      "smtpEnable": "Bật thông báo qua email",
+      "smtpEnableDesc": "Bật thông báo qua email bằng SMTP",
+      "smtpEncryption": "Mã hóa",
+      "smtpEncryptionDesc": "Phương thức mã hóa kết nối SMTP",
+      "smtpEncryptionNone": "Không (văn bản thuần)",
+      "smtpEncryptionStartTLS": "STARTTLS",
+      "smtpEncryptionTLS": "TLS (ngầm định)",
+      "smtpEventBusNotify": "Thông báo sự kiện qua email",
+      "smtpEventBusNotifyDesc": "Chọn những sự kiện nào sẽ kích hoạt thông báo qua email",
+      "smtpHost": "Máy chủ SMTP",
+      "smtpHostDesc": "Tên máy chủ SMTP (ví dụ: smtp.gmail.com)",
+      "smtpHostNotConfigured": "Chưa cấu hình máy chủ SMTP",
+      "smtpNoRecipients": "Chưa cấu hình người nhận",
+      "smtpNotInitialized": "SMTP chưa được khởi tạo",
+      "smtpPassword": "Mật khẩu SMTP",
+      "smtpPasswordDesc": "Mật khẩu xác thực SMTP",
+      "smtpPort": "Cổng SMTP",
+      "smtpPortDesc": "Cổng máy chủ SMTP (mặc định: 587)",
+      "smtpSettings": "Cài đặt SMTP",
+      "smtpStageAuth": "Xác thực",
+      "smtpStageConnect": "Kết nối",
+      "smtpStageSend": "Gửi",
+      "smtpTestSuccess": "Đã gửi email thử nghiệm thành công",
+      "smtpTo": "Người nhận",
+      "smtpToDesc": "Các địa chỉ email người nhận, phân cách bằng dấu phẩy",
+      "smtpUsername": "Tên đăng nhập SMTP",
+      "smtpUsernameDesc": "Tên đăng nhập xác thực SMTP",
+      "telegramTokenConfigured": "Đã cấu hình; để trống để giữ token hiện tại.",
+      "telegramTokenPlaceholder": "Đã cấu hình - nhập token mới để thay thế",
+      "testSmtp": "Gửi email thử nghiệm",
+      "testTgBot": "Gửi tin nhắn thử nghiệm",
+      "tgBotNotEnabled": "Bot Telegram chưa được bật",
+      "tgBotNotRunning": "Bot Telegram không hoạt động",
+      "tgEventBusNotify": "Thông báo sự kiện qua Telegram",
+      "tgEventBusNotifyDesc": "Chọn những sự kiện nào sẽ kích hoạt thông báo qua Telegram",
+      "tgTestFailed": "Thử nghiệm Telegram thất bại",
+      "tgTestSuccess": "Đã gửi tin nhắn thử nghiệm tới Telegram",
+      "smtpErrorAuth": "Xác thực thất bại — kiểm tra tên đăng nhập và mật khẩu",
+      "smtpErrorStarttls": "Máy chủ yêu cầu STARTTLS — thay đổi kiểu mã hóa",
+      "smtpErrorTls": "Máy chủ yêu cầu TLS — thay đổi kiểu mã hóa",
+      "smtpErrorRefused": "Kết nối bị từ chối — kiểm tra máy chủ và cổng",
+      "smtpErrorTimeout": "Hết thời gian kết nối — không thể truy cập máy chủ",
+      "smtpErrorRelay": "Máy chủ từ chối gửi từ địa chỉ này",
+      "smtpErrorEof": "Kết nối đã bị máy chủ đóng",
+      "smtpErrorUnknown": "Lỗi SMTP: {{ .Error }}",
+      "eventGroupNode": "Node",
+      "eventNodeDown": "Ngừng hoạt động",
+      "eventNodeUp": "Hoạt động",
+      "smtpPasswordConfigured": "Đã cấu hình; để trống để giữ mật khẩu hiện tại.",
+      "smtpPasswordPlaceholder": "Đã cấu hình - nhập mật khẩu mới để thay thế"
     },
     "xray": {
       "title": "Cài đặt Xray",
@@ -1703,7 +1765,18 @@
       "AreYouSure": "Bạn có chắc không? 🤔",
       "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ✅ Thành công",
       "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ❌ Thất bại \n\n🛠️ Lỗi: [ {{ .ErrorMessage }} ]",
-      "FinishProcess": "🔚 Quá trình đặt lại lưu lượng đã hoàn tất cho tất cả khách hàng."
+      "FinishProcess": "🔚 Quá trình đặt lại lưu lượng đã hoàn tất cho tất cả khách hàng.",
+      "eventCPUHigh": "CPU cao",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventDelayDetail": "Độ trễ: {{ .Delay }}ms",
+      "eventErrorDetail": "Lỗi: {{ .Error }}",
+      "eventLoginFallback": "Đăng nhập thất bại từ {{ .Source }}",
+      "eventOutboundDown": "Outbound {{ .Tag }} đã NGỪNG HOẠT ĐỘNG",
+      "eventOutboundUp": "Outbound {{ .Tag }} đã HOẠT ĐỘNG",
+      "eventXrayCrash": "Xray GẶP SỰ CỐ",
+      "eventXrayCrashError": "Lỗi: {{ .Error }}",
+      "eventNodeDown": "Node {{ .Name }} đã NGỪNG HOẠT ĐỘNG",
+      "eventNodeUp": "Node {{ .Name }} đã HOẠT ĐỘNG"
     },
     "buttons": {
       "closeKeyboard": "❌ Đóng Bàn Phím",
@@ -1773,5 +1846,57 @@
       "chooseClient": "Chọn một Khách hàng cho Inbound {{ .Inbound }}",
       "chooseInbound": "Chọn một Inbound"
     }
+  },
+  "email": {
+    "labelDelay": "Độ trễ",
+    "labelDetail": "Chi tiết",
+    "labelError": "Lỗi",
+    "labelIP": "IP",
+    "labelOutbound": "Outbound",
+    "labelReason": "Lý do",
+    "labelSource": "Nguồn",
+    "labelStatus": "Trạng thái",
+    "labelTime": "Thời gian",
+    "labelUsername": "Tên đăng nhập",
+    "statusBanned": "BANNED",
+    "statusCrashed": "GẶP SỰ CỐ",
+    "statusDown": "NGỪNG HOẠT ĐỘNG",
+    "statusFailed": "THẤT BẠI",
+    "statusFull": "FULL",
+    "statusHigh": "CAO",
+    "statusOffline": "OFFLINE",
+    "statusOnline": "ONLINE",
+    "statusRunning": "Đang chạy",
+    "statusSuccess": "THÀNH CÔNG",
+    "statusUp": "HOẠT ĐỘNG",
+    "statusXrayDown": "Xray DOWN",
+    "statusXrayUp": "Xray UP",
+    "subjectCPUHigh": "CPU cao",
+    "subjectDiskFull": "Disk full",
+    "subjectIPBanned": "IP banned: {{ .IP }}",
+    "subjectLoginFailed": "Đăng nhập thất bại",
+    "subjectLoginSuccess": "Đăng nhập thành công",
+    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
+    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
+    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
+    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
+    "subjectOutboundDown": "Outbound {{ .Tag }} đã NGỪNG HOẠT ĐỘNG",
+    "subjectOutboundUp": "Outbound {{ .Tag }} đã HOẠT ĐỘNG",
+    "subjectXrayCrash": "Xray GẶP SỰ CỐ",
+    "subjectXrayUp": "Xray is UP",
+    "titleCPUHigh": "CPU cao",
+    "titleDiskFull": "Disk full",
+    "titleIPBanned": "IP banned",
+    "titleLoginFailed": "Đăng nhập thất bại",
+    "titleLoginSuccess": "Đăng nhập thành công",
+    "titleNodeOffline": "Node OFFLINE",
+    "titleNodeOnline": "Node ONLINE",
+    "titleNodeXrayDown": "Node Xray DOWN",
+    "titleNodeXrayUp": "Node Xray UP",
+    "titleOutboundDown": "Outbound NGỪNG HOẠT ĐỘNG",
+    "titleOutboundUp": "Outbound HOẠT ĐỘNG",
+    "titleXrayCrash": "Xray GẶP SỰ CỐ",
+    "titleXrayUp": "Xray UP",
+    "labelNode": "Node"
   }
 }

+ 127 - 2
internal/web/translation/zh-CN.json

@@ -1200,7 +1200,69 @@
         "userPassMustBeNotEmpty": "新用户名和新密码不能为空",
         "getOutboundTrafficError": "获取出站流量错误",
         "resetOutboundTrafficError": "重置出站流量错误"
-      }
+      },
+      "emailNotifications": "通知",
+      "emailSettings": "邮件",
+      "eventCPUHigh": "CPU 占用过高(%)",
+      "eventGroupOutbound": "出站",
+      "eventGroupSecurity": "安全",
+      "eventGroupSystem": "系统",
+      "eventGroupXray": "Xray 核心",
+      "eventLoginAttempt": "登录尝试",
+      "eventOutboundDown": "断开",
+      "eventOutboundUp": "恢复",
+      "eventXrayCrash": "崩溃",
+      "requestFailed": "请求失败",
+      "smtpEnable": "启用邮件通知",
+      "smtpEnableDesc": "通过 SMTP 启用邮件通知",
+      "smtpEncryption": "加密",
+      "smtpEncryptionDesc": "SMTP 连接加密方式",
+      "smtpEncryptionNone": "无(明文)",
+      "smtpEncryptionStartTLS": "STARTTLS",
+      "smtpEncryptionTLS": "TLS(隐式)",
+      "smtpEventBusNotify": "邮件事件通知",
+      "smtpEventBusNotifyDesc": "选择触发邮件通知的事件",
+      "smtpHost": "SMTP 主机",
+      "smtpHostDesc": "SMTP 服务器主机名(例如 smtp.gmail.com)",
+      "smtpHostNotConfigured": "尚未配置 SMTP 主机",
+      "smtpNoRecipients": "尚未配置收件人",
+      "smtpNotInitialized": "SMTP 尚未初始化",
+      "smtpPassword": "SMTP 密码",
+      "smtpPasswordDesc": "SMTP 认证密码",
+      "smtpPort": "SMTP 端口",
+      "smtpPortDesc": "SMTP 服务器端口(默认:587)",
+      "smtpSettings": "SMTP 设置",
+      "smtpStageAuth": "认证",
+      "smtpStageConnect": "连接",
+      "smtpStageSend": "发送",
+      "smtpTestSuccess": "测试邮件发送成功",
+      "smtpTo": "收件人",
+      "smtpToDesc": "以逗号分隔的收件人邮箱地址",
+      "smtpUsername": "SMTP 用户名",
+      "smtpUsernameDesc": "SMTP 认证用户名",
+      "telegramTokenConfigured": "已配置;留空则保留当前令牌。",
+      "telegramTokenPlaceholder": "已配置——输入新令牌以替换",
+      "testSmtp": "发送测试邮件",
+      "testTgBot": "发送测试消息",
+      "tgBotNotEnabled": "Telegram 机器人未启用",
+      "tgBotNotRunning": "Telegram 机器人未运行",
+      "tgEventBusNotify": "Telegram 事件通知",
+      "tgEventBusNotifyDesc": "选择触发 Telegram 通知的事件",
+      "tgTestFailed": "Telegram 测试失败",
+      "tgTestSuccess": "测试消息已发送至 Telegram",
+      "smtpErrorAuth": "认证失败——请检查用户名和密码",
+      "smtpErrorStarttls": "服务器要求 STARTTLS——请更改加密类型",
+      "smtpErrorTls": "服务器要求 TLS——请更改加密类型",
+      "smtpErrorRefused": "连接被拒绝——请检查主机和端口",
+      "smtpErrorTimeout": "连接超时——主机无法访问",
+      "smtpErrorRelay": "服务器拒绝从此地址发送",
+      "smtpErrorEof": "连接被服务器关闭",
+      "smtpErrorUnknown": "SMTP 错误:{{ .Error }}",
+      "eventGroupNode": "节点",
+      "eventNodeDown": "离线",
+      "eventNodeUp": "上线",
+      "smtpPasswordConfigured": "已配置;留空则保留当前密码。",
+      "smtpPasswordPlaceholder": "已配置——输入新密码以替换"
     },
     "xray": {
       "title": "Xray 配置",
@@ -1703,7 +1765,18 @@
       "AreYouSure": "你确定吗?🤔",
       "SuccessResetTraffic": "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ✅ 成功",
       "FailedResetTraffic": "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ❌ 失败 \n\n🛠️ 错误: [ {{ .ErrorMessage }} ]",
-      "FinishProcess": "🔚 所有客户的流量重置已完成。"
+      "FinishProcess": "🔚 所有客户的流量重置已完成。",
+      "eventCPUHigh": "CPU 占用过高",
+      "eventCPUHighDetail": "CPU:{{ .Detail }}",
+      "eventDelayDetail": "延迟:{{ .Delay }} 毫秒",
+      "eventErrorDetail": "错误:{{ .Error }}",
+      "eventLoginFallback": "来自 {{ .Source }} 的登录失败",
+      "eventOutboundDown": "出站 {{ .Tag }} 已断开",
+      "eventOutboundUp": "出站 {{ .Tag }} 已恢复",
+      "eventXrayCrash": "Xray 已崩溃",
+      "eventXrayCrashError": "错误:{{ .Error }}",
+      "eventNodeDown": "节点 {{ .Name }} 已离线",
+      "eventNodeUp": "节点 {{ .Name }} 已上线"
     },
     "buttons": {
       "closeKeyboard": "❌ 关闭键盘",
@@ -1773,5 +1846,57 @@
       "chooseClient": "为入站 {{ .Inbound }} 选择一个客户",
       "chooseInbound": "选择一个入站"
     }
+  },
+  "email": {
+    "labelDelay": "延迟",
+    "labelDetail": "详情",
+    "labelError": "错误",
+    "labelIP": "IP",
+    "labelOutbound": "出站",
+    "labelReason": "原因",
+    "labelSource": "来源",
+    "labelStatus": "状态",
+    "labelTime": "时间",
+    "labelUsername": "用户名",
+    "statusBanned": "BANNED",
+    "statusCrashed": "已崩溃",
+    "statusDown": "断开",
+    "statusFailed": "失败",
+    "statusFull": "FULL",
+    "statusHigh": "过高",
+    "statusOffline": "OFFLINE",
+    "statusOnline": "ONLINE",
+    "statusRunning": "运行中",
+    "statusSuccess": "成功",
+    "statusUp": "恢复",
+    "statusXrayDown": "Xray DOWN",
+    "statusXrayUp": "Xray UP",
+    "subjectCPUHigh": "CPU 占用过高",
+    "subjectDiskFull": "Disk full",
+    "subjectIPBanned": "IP banned: {{ .IP }}",
+    "subjectLoginFailed": "登录失败",
+    "subjectLoginSuccess": "登录成功",
+    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
+    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
+    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
+    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
+    "subjectOutboundDown": "出站 {{ .Tag }} 已断开",
+    "subjectOutboundUp": "出站 {{ .Tag }} 已恢复",
+    "subjectXrayCrash": "Xray 已崩溃",
+    "subjectXrayUp": "Xray is UP",
+    "titleCPUHigh": "CPU 占用过高",
+    "titleDiskFull": "Disk full",
+    "titleIPBanned": "IP banned",
+    "titleLoginFailed": "登录失败",
+    "titleLoginSuccess": "登录成功",
+    "titleNodeOffline": "Node OFFLINE",
+    "titleNodeOnline": "Node ONLINE",
+    "titleNodeXrayDown": "Node Xray DOWN",
+    "titleNodeXrayUp": "Node Xray UP",
+    "titleOutboundDown": "出站断开",
+    "titleOutboundUp": "出站恢复",
+    "titleXrayCrash": "Xray 已崩溃",
+    "titleXrayUp": "Xray UP",
+    "labelNode": "节点"
   }
 }

+ 127 - 2
internal/web/translation/zh-TW.json

@@ -1200,7 +1200,69 @@
         "userPassMustBeNotEmpty": "新使用者名稱和新密碼不能為空",
         "getOutboundTrafficError": "取得出站流量錯誤",
         "resetOutboundTrafficError": "重設出站流量錯誤"
-      }
+      },
+      "emailNotifications": "通知",
+      "emailSettings": "電子郵件",
+      "eventCPUHigh": "CPU 偏高(%)",
+      "eventGroupOutbound": "出站",
+      "eventGroupSecurity": "安全性",
+      "eventGroupSystem": "系統",
+      "eventGroupXray": "Xray 核心",
+      "eventLoginAttempt": "登入嘗試",
+      "eventOutboundDown": "中斷",
+      "eventOutboundUp": "恢復",
+      "eventXrayCrash": "當機",
+      "requestFailed": "請求失敗",
+      "smtpEnable": "啟用電子郵件通知",
+      "smtpEnableDesc": "透過 SMTP 啟用電子郵件通知",
+      "smtpEncryption": "加密",
+      "smtpEncryptionDesc": "SMTP 連線加密方式",
+      "smtpEncryptionNone": "無(純文字)",
+      "smtpEncryptionStartTLS": "STARTTLS",
+      "smtpEncryptionTLS": "TLS(隱含)",
+      "smtpEventBusNotify": "電子郵件事件通知",
+      "smtpEventBusNotifyDesc": "選擇觸發電子郵件通知的事件",
+      "smtpHost": "SMTP 主機",
+      "smtpHostDesc": "SMTP 伺服器主機名稱(例如 smtp.gmail.com)",
+      "smtpHostNotConfigured": "尚未設定 SMTP 主機",
+      "smtpNoRecipients": "尚未設定收件人",
+      "smtpNotInitialized": "SMTP 尚未初始化",
+      "smtpPassword": "SMTP 密碼",
+      "smtpPasswordDesc": "SMTP 驗證密碼",
+      "smtpPort": "SMTP 連接埠",
+      "smtpPortDesc": "SMTP 伺服器連接埠(預設:587)",
+      "smtpSettings": "SMTP 設定",
+      "smtpStageAuth": "驗證",
+      "smtpStageConnect": "連線",
+      "smtpStageSend": "傳送",
+      "smtpTestSuccess": "測試郵件已成功傳送",
+      "smtpTo": "收件人",
+      "smtpToDesc": "以逗號分隔的收件人電子郵件地址",
+      "smtpUsername": "SMTP 使用者名稱",
+      "smtpUsernameDesc": "SMTP 驗證使用者名稱",
+      "telegramTokenConfigured": "已設定;留空以保留目前的權杖。",
+      "telegramTokenPlaceholder": "已設定 - 輸入新權杖以取代",
+      "testSmtp": "傳送測試郵件",
+      "testTgBot": "傳送測試訊息",
+      "tgBotNotEnabled": "Telegram 機器人未啟用",
+      "tgBotNotRunning": "Telegram 機器人未執行",
+      "tgEventBusNotify": "Telegram 事件通知",
+      "tgEventBusNotifyDesc": "選擇觸發 Telegram 通知的事件",
+      "tgTestFailed": "Telegram 測試失敗",
+      "tgTestSuccess": "測試訊息已傳送至 Telegram",
+      "smtpErrorAuth": "驗證失敗 — 請檢查使用者名稱和密碼",
+      "smtpErrorStarttls": "伺服器需要 STARTTLS — 請變更加密類型",
+      "smtpErrorTls": "伺服器需要 TLS — 請變更加密類型",
+      "smtpErrorRefused": "連線遭拒 — 請檢查主機和連接埠",
+      "smtpErrorTimeout": "連線逾時 — 無法連線至主機",
+      "smtpErrorRelay": "伺服器拒絕從此地址傳送",
+      "smtpErrorEof": "連線已被伺服器關閉",
+      "smtpErrorUnknown": "SMTP 錯誤:{{ .Error }}",
+      "eventGroupNode": "節點",
+      "eventNodeDown": "離線",
+      "eventNodeUp": "上線",
+      "smtpPasswordConfigured": "已設定;留空以保留目前的密碼。",
+      "smtpPasswordPlaceholder": "已設定 - 輸入新密碼以取代"
     },
     "xray": {
       "title": "Xray 配置",
@@ -1703,7 +1765,18 @@
       "AreYouSure": "你確定嗎?🤔",
       "SuccessResetTraffic": "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ✅ 成功",
       "FailedResetTraffic": "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ 錯誤: [ {{ .ErrorMessage }} ]",
-      "FinishProcess": "🔚 所有客戶的流量重置已完成。"
+      "FinishProcess": "🔚 所有客戶的流量重置已完成。",
+      "eventCPUHigh": "CPU 偏高",
+      "eventCPUHighDetail": "CPU:{{ .Detail }}",
+      "eventDelayDetail": "延遲:{{ .Delay }} 毫秒",
+      "eventErrorDetail": "錯誤:{{ .Error }}",
+      "eventLoginFallback": "來自 {{ .Source }} 的登入失敗",
+      "eventOutboundDown": "出站 {{ .Tag }} 已中斷",
+      "eventOutboundUp": "出站 {{ .Tag }} 已恢復",
+      "eventXrayCrash": "Xray 已當機",
+      "eventXrayCrashError": "錯誤:{{ .Error }}",
+      "eventNodeDown": "節點 {{ .Name }} 已離線",
+      "eventNodeUp": "節點 {{ .Name }} 已上線"
     },
     "buttons": {
       "closeKeyboard": "❌ 關閉鍵盤",
@@ -1773,5 +1846,57 @@
       "chooseClient": "為入站 {{ .Inbound }} 選擇一個客戶",
       "chooseInbound": "選擇一個入站"
     }
+  },
+  "email": {
+    "labelDelay": "延遲",
+    "labelDetail": "詳細資訊",
+    "labelError": "錯誤",
+    "labelIP": "IP",
+    "labelOutbound": "出站",
+    "labelReason": "原因",
+    "labelSource": "來源",
+    "labelStatus": "狀態",
+    "labelTime": "時間",
+    "labelUsername": "使用者名稱",
+    "statusBanned": "BANNED",
+    "statusCrashed": "已當機",
+    "statusDown": "中斷",
+    "statusFailed": "失敗",
+    "statusFull": "FULL",
+    "statusHigh": "偏高",
+    "statusOffline": "OFFLINE",
+    "statusOnline": "ONLINE",
+    "statusRunning": "執行中",
+    "statusSuccess": "成功",
+    "statusUp": "恢復",
+    "statusXrayDown": "Xray DOWN",
+    "statusXrayUp": "Xray UP",
+    "subjectCPUHigh": "CPU 偏高",
+    "subjectDiskFull": "Disk full",
+    "subjectIPBanned": "IP banned: {{ .IP }}",
+    "subjectLoginFailed": "登入失敗",
+    "subjectLoginSuccess": "登入成功",
+    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
+    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
+    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
+    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
+    "subjectOutboundDown": "出站 {{ .Tag }} 已中斷",
+    "subjectOutboundUp": "出站 {{ .Tag }} 已恢復",
+    "subjectXrayCrash": "Xray 已當機",
+    "subjectXrayUp": "Xray is UP",
+    "titleCPUHigh": "CPU 偏高",
+    "titleDiskFull": "Disk full",
+    "titleIPBanned": "IP banned",
+    "titleLoginFailed": "登入失敗",
+    "titleLoginSuccess": "登入成功",
+    "titleNodeOffline": "Node OFFLINE",
+    "titleNodeOnline": "Node ONLINE",
+    "titleNodeXrayDown": "Node Xray DOWN",
+    "titleNodeXrayUp": "Node Xray UP",
+    "titleOutboundDown": "出站中斷",
+    "titleOutboundUp": "出站恢復",
+    "titleXrayCrash": "Xray 已當機",
+    "titleXrayUp": "Xray UP",
+    "labelNode": "節點"
   }
 }

+ 86 - 12
internal/web/web.go

@@ -6,6 +6,7 @@ import (
 	"context"
 	"crypto/tls"
 	"embed"
+	"fmt"
 	"io"
 	"io/fs"
 	"net"
@@ -16,6 +17,7 @@ import (
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
@@ -26,9 +28,11 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/web/network"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service/email"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"github.com/gin-contrib/gzip"
 	"github.com/gin-contrib/sessions"
@@ -112,6 +116,7 @@ type Server struct {
 
 	wsHub *websocket.Hub
 
+	bus  *eventbus.Bus
 	cron *cron.Cron
 
 	ctx    context.Context
@@ -277,7 +282,9 @@ const (
 	cadenceNodeTraffic   = "@every 5s"
 	cadenceOutboundSub   = "@every 5m"
 	cadenceCheckHash     = "@every 2m"
-	cadenceCPUAlarm      = "@every 10s"
+	// cpu.Percent samples over a full minute (blocking), so a finer cadence just
+	// stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway.
+	cadenceCPUAlarm = "@every 1m"
 )
 
 // startTask schedules background jobs (Xray checks, traffic jobs, cron
@@ -347,8 +354,7 @@ func (s *Server) startTask(restartXray bool) {
 		s.cron.AddJob(runtime, j)
 	}
 
-	// Make a traffic condition every day, 8:30
-	var entry cron.EntryID
+	// Telegram-bot–dependent jobs: periodic stats report + callback-hash cleanup.
 	isTgbotenabled, err := s.settingService.GetTgbotEnabled()
 	if (err == nil) && (isTgbotenabled) {
 		runtime, err := s.settingService.GetTgbotRuntime()
@@ -360,23 +366,50 @@ func (s *Server) startTask(restartXray bool) {
 			runtime = "@daily"
 		}
 		logger.Infof("Tg notify enabled,run at %s", runtime)
-		_, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob())
-		if err != nil {
+		if _, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob()); err != nil {
 			logger.Warningf("Add NewStatsNotifyJob: failed to schedule runtime %q: %v", runtime, err)
-			return
 		}
 
 		// check for Telegram bot callback query hash storage reset
 		s.cron.AddJob(cadenceCheckHash, job.NewCheckHashStorageJob())
+	}
+
+	// CPU monitor publishes cpu.high events; register it whenever any notifier
+	// (Telegram or Email) wants them, independent of the Telegram bot being on.
+	if s.cpuAlarmWanted() {
+		s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob())
+	}
+}
 
-		// Check CPU load and alarm to TgBot if threshold passes
-		cpuThreshold, err := s.settingService.GetTgCpu()
-		if (err == nil) && (cpuThreshold > 0) {
-			s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob())
+// cpuAlarmWanted reports whether any notifier is configured to receive cpu.high
+// alerts, so the minute-long blocking CPU sampler only runs when it's needed.
+func (s *Server) cpuAlarmWanted() bool {
+	wants := func(events string, threshold int) bool {
+		if threshold <= 0 {
+			return false
 		}
-	} else {
-		s.cron.Remove(entry)
+		for _, e := range strings.Split(events, ",") {
+			if strings.TrimSpace(e) == string(eventbus.EventCPUHigh) {
+				return true
+			}
+		}
+		return false
 	}
+	if on, _ := s.settingService.GetTgbotEnabled(); on {
+		events, _ := s.settingService.GetTgEnabledEvents()
+		cpu, _ := s.settingService.GetTgCpu()
+		if wants(events, cpu) {
+			return true
+		}
+	}
+	if on, _ := s.settingService.GetSmtpEnable(); on {
+		events, _ := s.settingService.GetSmtpEnabledEvents()
+		cpu, _ := s.settingService.GetSmtpCpu()
+		if wants(events, cpu) {
+			return true
+		}
+	}
+	return false
 }
 
 // Start initializes and starts the web server with configured settings, routes, and background jobs.
@@ -479,6 +512,42 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 		s.httpServer.Serve(listener)
 	}()
 
+	// Create event bus before startTask so jobs can use it
+	s.bus = eventbus.New(eventbus.DefaultBufferSize)
+	service.SetEventBus(s.bus)
+	job.EventBus = s.bus
+	tgbot.EventBus = s.bus
+
+	// Wire xray crash callback BEFORE startTask so it's ready
+	xray.OnCrash = func(err error) {
+		if s.bus != nil {
+			s.bus.Publish(eventbus.Event{
+				Type: eventbus.EventXrayCrash,
+				Data: err.Error(),
+			})
+		}
+	}
+
+	// Register email subscriber (always — it checks smtpEnable at runtime)
+	emailService := email.NewEmailService(s.settingService)
+	emailSub := email.NewSubscriber(s.settingService, emailService)
+	s.bus.Subscribe("email-notifier", emailSub.HandleEvent)
+
+	// Wire email service to controller for test endpoint
+	controller.SetEmailService(emailService)
+
+	// Wire Telegram test function to controller
+	controller.SetTestTgFunc(func() error {
+		if !s.tgbotService.IsRunning() {
+			return fmt.Errorf("telegram bot is not running (check token and chat ID)")
+		}
+		if err := s.tgbotService.TestConnection(); err != nil {
+			return fmt.Errorf("telegram API test failed: %w", err)
+		}
+		s.tgbotService.SendMsgToTgbotAdmins("✅ Test message from 3x-ui")
+		return nil
+	})
+
 	s.startTask(restartXray)
 
 	if startTgBot {
@@ -486,6 +555,8 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 		if (err == nil) && (isTgbotenabled) {
 			tgBot := s.tgbotService.NewTgbot()
 			tgBot.Start(i18nFS)
+			// Subscribe Telegram notifications for event bus
+			s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent)
 		}
 	}
 
@@ -510,6 +581,9 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
 	if s.cron != nil {
 		s.cron.Stop()
 	}
+	if s.bus != nil {
+		s.bus.Stop()
+	}
 	if err := service.PersistSystemMetrics(); err != nil {
 		logger.Warning("persist system metrics on shutdown failed:", err)
 	}

+ 5 - 0
internal/xray/process.go

@@ -213,6 +213,8 @@ func (p *process) SetOnlineAPISupport(v OnlineAPISupport) {
 var (
 	xrayGracefulStopTimeout = 5 * time.Second
 	xrayForceStopTimeout    = 2 * time.Second
+	// OnCrash is called when xray crashes unexpectedly. Set from web layer.
+	OnCrash func(err error)
 )
 
 // newProcess creates a new internal process struct for Xray.
@@ -566,6 +568,9 @@ func (p *process) waitForCommand(cmd *exec.Cmd) {
 
 	logger.Error("Failure in running xray-core:", err)
 	p.exitErr = err
+	if OnCrash != nil {
+		OnCrash(err)
+	}
 }
 
 // Stop terminates the running Xray process.