package notify import ( "encoding/json" "fmt" "strings" "time" ) // budapest timezone for formatting. var budapest *time.Location func init() { var err error budapest, err = time.LoadLocation("Europe/Budapest") if err != nil { budapest = time.FixedZone("CET", 3600) } } // ────────────────────────────────────────────────────────────────────── // Operator email — concise, English // ────────────────────────────────────────────────────────────────────── // FormatOperatorEmail returns (subject, textBody) for the operator channel. func FormatOperatorEmail(customerID, eventType, severity, message, detailsJSON string) (string, string) { // Icon is eventType-aware for recovery (v0.71.0, audit F11); severity stays the fallback. icon := "⚠️" switch { case strings.HasSuffix(eventType, "_recovered"): icon = "✅" case severity == "error" || severity == "critical": icon = "🔴" } subject := fmt.Sprintf("[Felhom] %s %s: %s", icon, customerID, eventType) now := time.Now().In(budapest).Format("2006-01-02 15:04 MST") body := fmt.Sprintf(`Customer: %s Event: %s Severity: %s Time: %s Message: %s`, customerID, eventType, severity, now, message) // R-182: the backup run digest gets a rendered list instead of a raw JSON blob. It is the one // operator mail that carries a VARIABLE-LENGTH payload, and a dozen apps as one line of JSON is // unreadable on a phone at 07:00, which is the only time it matters. if eventType == "backup_run_failures" { if rendered, sub, ok := renderBackupRunFailures(customerID, detailsJSON); ok { return sub, body + rendered + fmt.Sprintf("\n\nDashboard: https://hub.felhom.eu/customers/%s", customerID) } // Unparseable details fall through to the raw form below rather than losing the mail. A // digest that renders badly still tells the operator something; a swallowed one does not. } if detailsJSON != "" && detailsJSON != "{}" { body += fmt.Sprintf("\nDetails: %s", detailsJSON) } body += fmt.Sprintf("\n\nDashboard: https://hub.felhom.eu/customers/%s", customerID) return subject, body } // ────────────────────────────────────────────────────────────────────── // Customer email — Hungarian, friendly // ────────────────────────────────────────────────────────────────────── // customerMessages maps event_type → Hungarian customer message. var customerMessages = map[string]string{ // Customer-claim arc (v0.50.0) "claim_lockout": "Túl sok hibás beállító/visszaállító kód próbálkozás történt — a beállító oldal 15 percre zárolva lett. Ha nem te próbálkoztál, jelezd az üzemeltetőnek.", // Backup events "backup_completed": "A biztonsági mentés sikeresen elkészült.", "backup_failed": "A biztonsági mentés sikertelen! Kérjük, ellenőrizd a rendszert.", "db_dump_completed": "Az adatbázis mentés sikeresen elkészült.", "db_dump_failed": "Az adatbázis mentés sikertelen!", "backup_integrity_ok": "A mentés integritás ellenőrzés sikeres.", "backup_integrity_failed": "A mentés integritás ellenőrzés hibát talált!", "crossdrive_completed": "A másodlagos mentés sikeresen elkészült.", "crossdrive_failed": "A másodlagos mentés sikertelen!", // Offsite-repo continuity (controller v0.142.0) "offbox_repo_orphaned": "A távoli mentési tároló elárvult: a benne lévő mentések egy korábbi, már nem elérhető kulccsal készültek (jellemzően újratelepítés után). Új mentés a tároló visszaállításáig nem készül — nyisd meg a Távoli mentés oldalt.", "offbox_repo_reset": "A távoli mentési tároló visszaállítva: a régi előzmény félretéve (nem törölve), és egy üres, új tároló jött létre a mostani kulccsal.", // Disk events (GUEST — the controller's own view) — `disk_warning` / `disk_critical`. // // DELIBERATELY NO ENTRY, from hub v0.89.0 / controller v0.191.0 (R-167, decision D-c). These two // types were allowlisted here, carried generic Hungarian copy, sat in the controller's // DefaultEnabledEvents and had a UI checkbox — and NOTHING IN ANY REPO EMITTED THEM. A complete // customer pipeline with no producer; the sixth "built but never wired" instance in this project. // The controller became their producer in v0.191.0. // // The producer sends a DYNAMIC Hungarian message naming the filesystem and its free space, so a // static entry here would be actively harmful: FormatCustomerEmail PREFERS the entry over the // message, so re-adding one would discard the drive name and the byte figures and leave the // customer with "A lemezterület 90% felett van" — a warning with nothing to act on. Same reason // `offbox_enlarge_blocked` and `disk_health_degraded` have no entry. Pinned by // TestDiskFillTypesHaveNoGenericCustomerMessage. // Host disk events (the Proxmox HOST root filesystem — distinct from the guest disk above) "host_disk_warning": "A házszerver alaprendszerének (Proxmox-gazda) gyökérlemeze 90% felett van — kérjük, szabadíts fel helyet (pl. régi biztonsági mentések).", "host_disk_critical": "A házszerver alaprendszerének gyökérlemeze kritikusan tele van (95%+) — azonnali beavatkozás szükséges, különben a rendszer hibázhat!", // Per-storage fill events (a SPECIFIC tároló — mentési kötet, adatmeghajtó, tároló-készlet — telik be) "storage_fill_warning": "Egy tároló a házszerveren 90% felett telt — kérjük, szabadíts fel helyet, mielőtt megtelik.", "storage_fill_critical": "Egy tároló a házszerveren kritikusan tele van (95%+) — a rá készülő mentések/írások meghiúsulhatnak; azonnali beavatkozás szükséges!", // Storage events "storage_disconnected": "Egy meghajtó leválasztva — a mentések szünetelhetnek.", "storage_reconnected": "A meghajtó újra csatlakoztatva.", // E-2 — the drive the whole-system backup is written to is gone. Deliberately NOT folded into // storage_disconnected: the customer action differs (reconnect THIS drive, or the system backup // stops surviving a disk failure), and the fallback is honest about what still protects them. "backup_target_absent": "A rendszermentés meghajtója nem érhető el — amíg vissza nem csatlakoztatod, a teljes rendszermentés nem készül el.", "backup_target_restored": "A rendszermentés meghajtója újra elérhető — a mentés folytatódik.", // Staleness events (Hub-generated) "node_stale": "A szerver nem küldött jelentést az elmúlt időszakban.", "node_down": "A szerver nem elérhető!", "node_recovered": "A szerver újra elérhető.", "host_recovered": "A házszerver alaprendszere (Proxmox-gazda) újra elérhető.", // Health events "health_degraded": "A rendszer állapota romlott.", "health_critical": "A rendszer állapota kritikus!", "health_recovered": "A rendszer állapota helyreállt.", // Controller events "controller_started": "A vezérlő elindult.", "controller_updated": "A vezérlő frissítve lett.", // Deadline events (Hub-generated) "expected_backup_missed": "A mai biztonsági mentés nem készült el a határidőig!", "expected_dbdump_missed": "A mai adatbázis mentés nem készült el a határidőig!", // App lifecycle events "app_deployed": "Alkalmazás telepítve.", "app_removed": "Alkalmazás eltávolítva.", "app_start_failed": "Egy telepített alkalmazás nem fut — ellenőrizze a rendszermonitort.", // Disaster recovery events "disaster_recovery_started": "Katasztrófa helyreállítás elindítva.", "disaster_recovery_completed": "Katasztrófa helyreállítás befejezve.", // Test "test": "Ez egy teszt értesítés.", } // severityLabels maps severity to Hungarian labels. var severityLabels = map[string]string{ "info": "Információ", "warning": "Figyelmeztetés", "error": "Hiba", "critical": "Kritikus hiba", } // FormatCustomerEmail returns (subject, textBody) for the customer channel. func FormatCustomerEmail(customerID, eventType, severity, message, detailsJSON string) (string, string) { label := severityLabels[severity] if label == "" { label = severity } // Use the per-event-type Hungarian message if available, otherwise fall back to message hunMessage := customerMessages[eventType] if hunMessage == "" { hunMessage = message } subject := fmt.Sprintf("[Felhom] %s: %s", label, hunMessage) now := time.Now().In(budapest).Format("2006-01-02 15:04") body := fmt.Sprintf(`Kedves Ügyfél! A Felhom rendszered a következő értesítést küldte: %s Részletek: - Szerver: %s - Időpont: %s - Szint: %s - Típus: %s`, hunMessage, customerID, now, label, eventType) if message != "" && message != hunMessage { body += fmt.Sprintf("\n- Üzenet: %s", message) } if detailsJSON != "" && detailsJSON != "{}" { body += fmt.Sprintf("\n- Megjegyzés: %s", detailsJSON) } body += ` Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. Üdvözlettel, Felhom.eu monitoring` return subject, body } // ────────────────────────────────────────────────────────────────────── // Customer-claim password arc (v0.50.0) — Hungarian code-delivery emails // ────────────────────────────────────────────────────────────────────── // FormatClaimEmail returns (subject, textBody) for a claim-arc email. kind is one of // "claim" | "reset" | "claimed" (claim.EmailKind values). The code appears ONLY in the // returned body — callers must never log it. func FormatClaimEmail(kind, customerID, domain, code string) (string, string) { dashboardURL := "https://felhom." + domain switch kind { case "reset": subject := "[Felhom] Jelszó-visszaállítási kód" body := fmt.Sprintf(`Kedves Ügyfél! Jelszó-visszaállítást kértél a Felhom vezérlőpultodhoz. Visszaállító kód: %s A kód 72 óráig érvényes, és egyszer használható fel. Add meg a vezérlőpult "Elfelejtett jelszó" oldalán, majd válassz új jelszót: %s Ha nem te kérted, hagyd figyelmen kívül — a jelenlegi jelszavad változatlan. Üdvözlettel, Felhom.eu`, code, dashboardURL) return subject, body case "claimed": subject := "[Felhom] A vezérlőpultod mostantól jelszóval védett" body := fmt.Sprintf(`Kedves Ügyfél! A Felhom vezérlőpultod beállítása elkészült — a vezérlőpultod mostantól jelszóval védett. A megadott jelszóval tudsz bejelentkezni: %s Ha nem te végezted a beállítást, azonnal vedd fel a kapcsolatot az üzemeltetővel. Üdvözlettel, Felhom.eu`, dashboardURL) return subject, body default: // "claim" subject := "[Felhom] Elindult a Felhom szervered — beállító kód" body := fmt.Sprintf(`Kedves Ügyfél! Elindult a Felhom szervered. A vezérlőpult első használatához add meg az alábbi beállító kódot, majd válassz saját jelszót: Beállító kód: %s A kód 72 óráig érvényes, és egyszer használható fel. A vezérlőpultot itt éred el: %s Ha nem kaptad volna meg időben, a vezérlőpult "Új kód kérése" gombjával kérhetsz frisset — az mindig erre az e-mail címre érkezik. Üdvözlettel, Felhom.eu`, code, dashboardURL) return subject, body } } // FormatSelfBindEmail builds the customer-facing Hungarian email carrying the self-bind capability // link (v0.66.0, R-27 slice 1). The link is the ONLY secret here — the passphrase is never in the // mail (the customer already holds it), and the console pairing code is read off the box screen. The // copy tells the customer they will need both factors on the page. Adult tone, no emoji. func FormatSelfBindEmail(customerID, link string) (string, string) { subject := "[Felhom] Kösd össze a Felhom dobozodat" body := fmt.Sprintf(`Kedves Ügyfél! Elkészült a Felhom dobozod, és készen áll az összekötésre. Az alábbi hivatkozáson tudod te magad összekötni a fiókoddal — nincs szükség bejelentkezésre: %s A hivatkozás megnyitása után két adatot kell megadnod: 1. A párosító kódot, amely a doboz képernyőjén (a monitoron) látható. 2. A visszaállító jelszavadat (az 5 szóból álló kifejezést), amelyet a beállításkor kaptál. A hivatkozás 7 napig érvényes. Biztonsági okból 5 sikertelen próbálkozás után zárolódik — ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal. Ha nem te kérted ezt, hagyd figyelmen kívül ezt az e-mailt. Üdvözlettel, Felhom.eu`, link) return subject, body } // ────────────────────────────────────────────────────────────────────── // R-182 — the backup run digest // ────────────────────────────────────────────────────────────────────── // backupRunFailure is one app's failed leg within a run. type backupRunFailure struct { App string `json:"app"` Leg string `json:"leg"` Reason string `json:"reason"` } // backupRunDetails is the digest payload the controller sends. type backupRunDetails struct { RunID string `json:"run_id"` RunKind string `json:"run_kind"` Failed int `json:"failed"` Attempted int `json:"attempted"` TargetPath string `json:"target_path"` UsedGB float64 `json:"used_gb"` AvailGB float64 `json:"avail_gb"` TotalGB float64 `json:"total_gb"` UsedPercent float64 `json:"used_percent"` SpaceKnown bool `json:"space_known"` Apps []backupRunFailure `json:"apps"` } // renderBackupRunFailures turns the digest details into an operator-readable block and a subject // that says the count without being opened. Returns ok=false when the payload cannot be parsed or // names no apps, so the caller can fall back to the raw rendering rather than mail an empty list. // // THE SUCCESS COUNT IS NOT DECORATION. "3 of 4 apps failed" is a catastrophe and "3 of 40" is a bad // night; the list alone cannot tell them apart, and the operator's first decision — get up now, or // look after coffee — depends entirely on which it is. func renderBackupRunFailures(customerID, detailsJSON string) (string, string, bool) { if detailsJSON == "" { return "", "", false } var d backupRunDetails if err := json.Unmarshal([]byte(detailsJSON), &d); err != nil || len(d.Apps) == 0 { return "", "", false } kind := d.RunKind if kind == "" { kind = "backup" } subject := fmt.Sprintf("[Felhom] 🔴 %s: %d of %d apps failed to back up (%s run)", customerID, d.Failed, d.Attempted, kind) // Column-align the app names so the leg and reason line up and the block scans vertically. width := 0 for _, a := range d.Apps { if len(a.App) > width { width = len(a.App) } } legWidth := 0 for _, a := range d.Apps { if len(a.Leg) > legWidth { legWidth = len(a.Leg) } } var b strings.Builder fmt.Fprintf(&b, "\n\nFAILED: %d of %d apps attempted in this %s run.\n\n", d.Failed, d.Attempted, kind) for _, a := range d.Apps { reason := trimRepeatedUsage(a.Reason, d.TargetPath) if reason == "" { reason = "(no reason recorded)" } fmt.Fprintf(&b, " %-*s %-*s %s\n", width, a.App, legWidth, a.Leg, reason) } // The space figures answer "is this one broken app or a full disk" before the reasons are read. // An absent reading renders as unavailable, never as zeros — "0 GB free" and "we could not look" // are opposite diagnoses (the UnitSpace rule, same reasoning, other side of the wire). if d.SpaceKnown { fmt.Fprintf(&b, "\nFilesystem: %s — %.1f/%.1f GB used (%.0f%%), %.1f GB free\n", d.TargetPath, d.UsedGB, d.TotalGB, d.UsedPercent, d.AvailGB) } else { fmt.Fprintf(&b, "\nFilesystem: %s — usage unavailable (the filesystem could not be read)\n", d.TargetPath) } b.WriteString("\nEvery failure above is also recorded individually in the notification log,\n") b.WriteString("whether or not this mail was sent.") return b.String(), subject, true } // trimRepeatedUsage strips the trailing "— /path: X/Y GB used (Z%), W GB free" clause from a per-app // reason, because the digest prints those figures ONCE for the whole run on its own line. // // This is a copy fix, and it was made after reading the first real digest rather than from the // design. The reserve's refusal message is authored for a single-app alert, where naming the // filesystem is exactly right; repeated down a list of a dozen apps it is the same forty characters // twelve times, and it pushes the part that differs off the right-hand edge of a phone screen at // 07:00 — which is the only moment this mail has to work. // // It trims ONLY an exact "— :" suffix, so a reason that mentions a different path, or // none, is left completely alone. A reason that is nothing but the usage clause is left alone too: // removing everything would turn a bad line into an empty one. func trimRepeatedUsage(reason, targetPath string) string { if reason == "" || targetPath == "" { return reason } marker := " — " + targetPath + ":" i := strings.LastIndex(reason, marker) if i <= 0 { return reason } return strings.TrimSpace(reason[:i]) }