Files
felhom.eu/hub/internal/notify/templates.go
T
admin f21e7caed1
gates / gates (push) Successful in 7s
hub v0.90.1 — the digest's per-app lines stop repeating the filesystem figures (R-182)
Found by reading the first REAL digest, not by design. Every app row ended with
the same usage clause the mail already prints once on its own Filesystem line.
On a two-app box that is untidy; down a list of a dozen it is the same forty
characters twelve times, pushing the part that DIFFERS off a phone screen at
07:00 — the only moment this mail has to work.

The reserve's refusal message is authored for a single-app alert where naming
the filesystem is right, so the message is unchanged; the digest trims the
duplicate when rendering. trimRepeatedUsage removes ONLY an exact
"— <target path>:" suffix, so an unrelated reason is untouched and a reason that
is nothing but the usage clause is left alone rather than emptied.

Also updates TestRecoveryUnitCaptureFailed_NeverReachesTheCustomer, which
required the OPERATOR to be emailed a per-app capture failure. That was correct
when the event was the only signal and is wrong now that it is the record and
the digest is the notification. Its customer-safety claim is unchanged and is
why the test still exists; the operator assertion is inverted with the reasoning
written in place, and R-158's guarantee is shown to have MOVED, not weakened.
2026-08-03 13:54:02 +02:00

409 lines
18 KiB
Go

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 "— <target path>:" 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])
}