From a987667233f61ebcf7f38d5479780e1c1626a04f Mon Sep 17 00:00:00 2001 From: maxim Date: Fri, 31 Jul 2026 23:01:46 +0300 Subject: [PATCH] Add configurable email notification digests --- README.md | 6 +++ email.go | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++ email_test.go | 40 ++++++++++++++++ store.go | 64 +++++++++++++++++++++++++ web.go | 50 ++++++++++++++++++- 5 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 email.go create mode 100644 email_test.go diff --git a/README.md b/README.md index b3b2161..b4d6523 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,12 @@ информационные сообщения по CPU, памяти, storage, SMART, ZFS, UPS, VM/LXC и резервным копиям. Критические события всегда отображаются первыми. +Во вкладке `Настройки → Почта` настраиваются SMTP-сервер, получатель и интервал +агрегированной сводки. Можно отдельно выбрать уровни важности и источники +событий — например, полностью исключить уведомления о резервных копиях. Там же +доступна отправка тестового письма. Поддерживаются STARTTLS (обычно порт 587) и +SMTP поверх TLS (порт 465). + Пороги уведомлений сохраняются в SQLite и настраиваются через вкладку `Настройки → Уведомления`. Панель раз в минуту сохраняет историю CPU, температуры, памяти, swap, сети, storage, ZFS, UPS и гостей. Данные старше семи diff --git a/email.go b/email.go new file mode 100644 index 0000000..0ec0dcb --- /dev/null +++ b/email.go @@ -0,0 +1,130 @@ +package main + +import ( + "crypto/tls" + "fmt" + "log" + "net" + "net/smtp" + "sort" + "strings" + "time" +) + +func filterEmailAlerts(alerts []Alert, settings EmailSettings) []Alert { + allowedSeverity, allowedSource := map[string]bool{}, map[string]bool{} + for _, value := range settings.Severities { + allowedSeverity[value] = true + } + for _, value := range settings.Sources { + allowedSource[value] = true + } + var result []Alert + for _, alert := range alerts { + if allowedSeverity[alert.Severity] && allowedSource[alert.Source] { + result = append(result, alert) + } + } + return result +} + +func sendAlertEmail(settings EmailSettings, alerts []Alert, test bool) error { + subject := "Proxmox Dashboard: сводка уведомлений" + body := "Система не обнаружила выбранных активных событий." + if test { + subject = "Proxmox Dashboard: тест почты" + body = "Почтовые уведомления настроены правильно." + } + if len(alerts) > 0 { + groups := map[string][]Alert{} + for _, alert := range alerts { + groups[alert.Severity] = append(groups[alert.Severity], alert) + } + var lines []string + for _, severity := range []string{"critical", "warning", "info"} { + items := groups[severity] + if len(items) == 0 { + continue + } + title := map[string]string{"critical": "КРИТИЧЕСКИЕ", "warning": "ПРЕДУПРЕЖДЕНИЯ", "info": "ИНФОРМАЦИЯ"}[severity] + lines = append(lines, title) + sort.Slice(items, func(i, j int) bool { return items[i].Title < items[j].Title }) + for _, item := range items { + lines = append(lines, fmt.Sprintf("• [%s] %s\n %s", item.Source, item.Title, item.Message)) + } + } + body = strings.Join(lines, "\n\n") + subject = fmt.Sprintf("Proxmox Dashboard: событий — %d", len(alerts)) + } + message := []byte("From: " + settings.From + "\r\nTo: " + settings.To + "\r\nSubject: " + subject + "\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n" + body + "\r\n") + address := net.JoinHostPort(settings.Host, fmt.Sprint(settings.Port)) + var client *smtp.Client + if settings.Port == 465 { + connection, err := tls.Dial("tcp", address, &tls.Config{ServerName: settings.Host, MinVersion: tls.VersionTLS12}) + if err != nil { + return err + } + client, err = smtp.NewClient(connection, settings.Host) + if err != nil { + return err + } + } else { + var err error + client, err = smtp.Dial(address) + if err != nil { + return err + } + if ok, _ := client.Extension("STARTTLS"); ok { + if err = client.StartTLS(&tls.Config{ServerName: settings.Host, MinVersion: tls.VersionTLS12}); err != nil { + return err + } + } + } + defer client.Close() + if settings.Username != "" { + if err := client.Auth(smtp.PlainAuth("", settings.Username, settings.Password, settings.Host)); err != nil { + return err + } + } + if err := client.Mail(settings.From); err != nil { + return err + } + if err := client.Rcpt(settings.To); err != nil { + return err + } + writer, err := client.Data() + if err != nil { + return err + } + if _, err = writer.Write(message); err != nil { + return err + } + if err = writer.Close(); err != nil { + return err + } + return client.Quit() +} + +func runEmailNotifier(collect func() (dashboardMetrics, error), store *Store) { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for range ticker.C { + settings := store.EmailSettings(true) + if !settings.Enabled || time.Since(store.LastEmailAt()) < time.Duration(settings.IntervalMinutes)*time.Minute { + continue + } + metrics, err := collect() + if err != nil { + continue + } + alerts := filterEmailAlerts(metrics.Alerts, settings) + if len(alerts) == 0 { + continue + } + if err := sendAlertEmail(settings, alerts, false); err != nil { + log.Printf("Не удалось отправить почтовую сводку: %v", err) + continue + } + store.SetLastEmailAt(time.Now()) + } +} diff --git a/email_test.go b/email_test.go new file mode 100644 index 0000000..75b62de --- /dev/null +++ b/email_test.go @@ -0,0 +1,40 @@ +package main + +import "testing" + +func TestFilterEmailAlerts(t *testing.T) { + alerts := []Alert{ + {ID: "cpu", Severity: "critical", Source: "CPU"}, + {ID: "backup", Severity: "critical", Source: "Backups"}, + {ID: "info", Severity: "info", Source: "CPU"}, + } + settings := EmailSettings{Severities: []string{"critical"}, Sources: []string{"CPU"}} + filtered := filterEmailAlerts(alerts, settings) + if len(filtered) != 1 || filtered[0].ID != "cpu" { + t.Fatalf("unexpected filtered alerts: %+v", filtered) + } +} + +func TestEmailPasswordIsPreserved(t *testing.T) { + store, err := openStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + settings := defaultEmailSettings() + settings.Host, settings.From, settings.To, settings.Password = "smtp.example.com", "dash@example.com", "admin@example.com", "secret" + if err := store.SaveEmailSettings(settings); err != nil { + t.Fatal(err) + } + settings.Password = "" + settings.IntervalMinutes = 120 + if err := store.SaveEmailSettings(settings); err != nil { + t.Fatal(err) + } + if got := store.EmailSettings(true); got.Password != "secret" || got.IntervalMinutes != 120 { + t.Fatalf("unexpected settings: %+v", got) + } + if got := store.EmailSettings(false); got.Password != "" || !got.HasPassword { + t.Fatalf("password leaked or marker lost: %+v", got) + } +} diff --git a/store.go b/store.go index ad2beb1..9853017 100644 --- a/store.go +++ b/store.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" _ "modernc.org/sqlite" @@ -59,6 +60,20 @@ type HistoryPoint struct { type Store struct{ db *sql.DB } +type EmailSettings struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + HasPassword bool `json:"hasPassword"` + From string `json:"from"` + To string `json:"to"` + IntervalMinutes int `json:"intervalMinutes"` + Severities []string `json:"severities"` + Sources []string `json:"sources"` +} + func openStore(path string) (*Store, error) { if path != ":memory:" { if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { @@ -114,6 +129,55 @@ func (s *Store) SaveThresholds(value AlertThresholds) error { return err } +func defaultEmailSettings() EmailSettings { + return EmailSettings{Port: 587, IntervalMinutes: 60, Severities: []string{"critical", "warning"}, Sources: []string{"CPU", "Память", "Storage", "Диски", "ZFS", "UPS", "VM/LXC", "Dashboard"}} +} + +func (s *Store) EmailSettings(includePassword bool) EmailSettings { + settings := defaultEmailSettings() + var value string + if s.db.QueryRow(`SELECT value FROM settings WHERE key='email_notifications'`).Scan(&value) == nil { + _ = json.Unmarshal([]byte(value), &settings) + } + settings.HasPassword = settings.Password != "" + if !includePassword { + settings.Password = "" + } + return settings +} + +func (s *Store) SaveEmailSettings(settings EmailSettings) error { + if settings.Port < 1 || settings.Port > 65535 || settings.IntervalMinutes < 5 || settings.IntervalMinutes > 10080 { + return fmt.Errorf("проверьте порт и интервал (от 5 минут до 7 дней)") + } + if settings.Enabled && (settings.Host == "" || settings.From == "" || settings.To == "") { + return fmt.Errorf("для отправки нужны SMTP-сервер, отправитель и получатель") + } + if strings.ContainsAny(settings.Host+settings.From+settings.To+settings.Username, "\r\n") { + return fmt.Errorf("поля почты содержат недопустимые символы") + } + if settings.Password == "" { + settings.Password = s.EmailSettings(true).Password + } + settings.HasPassword = settings.Password != "" + data, _ := json.Marshal(settings) + _, err := s.db.Exec(`INSERT INTO settings(key,value) VALUES('email_notifications',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, string(data)) + return err +} + +func (s *Store) LastEmailAt() time.Time { + var value string + if s.db.QueryRow(`SELECT value FROM settings WHERE key='email_last_sent'`).Scan(&value) != nil { + return time.Time{} + } + result, _ := time.Parse(time.RFC3339, value) + return result +} + +func (s *Store) SetLastEmailAt(value time.Time) { + _, _ = s.db.Exec(`INSERT INTO settings(key,value) VALUES('email_last_sent',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, value.Format(time.RFC3339)) +} + func validateThresholds(t AlertThresholds) error { percentages := []float64{t.CPUWarning, t.CPUCritical, t.MemoryWarning, t.MemoryCritical, t.SwapWarning, t.StorageWarning, t.StorageCritical, t.ZFSWarning, t.ZFSCritical, t.UPSChargeCritical} for _, value := range percentages { diff --git a/web.go b/web.go index 1b4c648..a14869c 100644 --- a/web.go +++ b/web.go @@ -63,6 +63,7 @@ func routes(collector *cpuCollector, store *Store) http.Handler { return collectDashboard(collector, network, disks, zfs, ups, guests, backups, store) } go runHistoryCollector(collect, store) + go runEmailNotifier(collect, store) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) @@ -112,6 +113,33 @@ func routes(collector *cpuCollector, store *Store) http.Handler { w.WriteHeader(http.StatusMethodNotAllowed) } }) + mux.HandleFunc("/api/settings/email", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + writeJSON(w, store.EmailSettings(false), nil) + case http.MethodPut: + var settings EmailSettings + if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&settings); err != nil { + http.Error(w, "Некорректные настройки", http.StatusBadRequest) + return + } + if err := store.SaveEmailSettings(settings); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, store.EmailSettings(false), nil) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + mux.HandleFunc("/api/settings/email/test", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + settings := store.EmailSettings(true) + writeJSON(w, map[string]bool{"sent": true}, sendAlertEmail(settings, nil, true)) + }) mux.HandleFunc("/api/history", func(w http.ResponseWriter, r *http.Request) { hours, _ := strconv.Atoi(r.URL.Query().Get("hours")) points, err := store.History(hours) @@ -186,7 +214,7 @@ dialog{width:min(760px,calc(100% - 28px));max-height:calc(100vh - 32px);padding: html[data-theme="light"] body{background:radial-gradient(circle at 78% -8%,#cde9da 0,transparent 34%),var(--bg)}html[data-theme="light"] .card{background:linear-gradient(145deg,#fff,#f7f9fa)}html[data-theme="light"] dialog{background:#fff;box-shadow:0 30px 80px #4e64752b}html[data-theme="light"] .modal-head{background:rgba(255,255,255,.94)}html[data-theme="light"] .close{background:#f1f4f6}html[data-theme="light"] .disk-item,html[data-theme="light"] .alert-item{background:#f7f9fa}html[data-theme="light"] .bar{background:#e2e8ec}html[data-theme="light"] dialog::backdrop{background:rgba(30,45,55,.35)}html[data-theme="light"] .badge[data-reason]:hover::after,html[data-theme="light"] .badge[data-reason]:focus::after,html[data-theme="light"] .chart-tooltip{background:#fff;box-shadow:0 12px 32px #4e647533} footer{color:#657581;font-size:12px;margin-top:17px} @media(max-width:700px){main{padding:28px 0}.cards{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column;gap:14px}.detail-grid{grid-template-columns:repeat(2,1fr)}.detail:nth-child(3n){border-right:1px solid var(--line)}.detail:nth-child(2n){border-right:0}.name{max-width:220px}.disk-stats,.setting-list,.threshold-grid{grid-template-columns:repeat(2,1fr)}.chart-head{align-items:flex-start;flex-direction:column}.detail-tabs{overflow-x:auto}} -.save-settings:disabled{cursor:wait;opacity:.55} +.save-settings:disabled{cursor:wait;opacity:.55}[data-settings-panel="email"] .threshold-item input{width:min(210px,52vw)}
@@ -325,7 +353,7 @@ footer{color:#657581;font-size:12px;margin-top:17px} -
+
Тема оформления
Видимые карточки
@@ -349,6 +377,20 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Настройки хранятся на сервере и применяются для всех браузеров.
+