4d6ec7c7bb
gates / gates (push) Successful in 14s
Four paper debts and one fact given a reader. Hub-only — nothing to bake. A4 — the entry about "the tester's machine" named a risk correctly and labelled it in a way that invited deleting it. Established from the hub's own store: `peti-felhom` is a REAL machine (482 reports, 2026-02-27 → 2026-07-15, a named person's own box) and the 3.6 GB with no key and no backup is real. `david` → `tester-1` is a DIFFERENT record with no host, no escrow and no report, ever — deleted 07:55:49 and re-created 07:56:47 this morning. The prompt's premise conflated the two; the register now says which is which. A1 — R-312/R-313/R-303 recorded as DECIDED with their re-open triggers, and moved out of STATUS's "Waiting on you", which is now empty. A3 — day0-install §C.1 said pushing the installer publishes it. It has not since R-110. Corrected, with the two manifest pins named and an outside-verification command; the one copy that repeated it (a dated audit, true when written) carries a superseded note. A5 — standing rule 5: evidence comes off the machine at the end of the phase that produced it, before any revert. Earned twice in three days on the same box at the same point (R-320). Four homes, plus what to do when it is already gone. R-295 hub half — „Beállító kód" everywhere; „Visszaállító kód" retired. New `reenroll` mail kind so the mail names the page a REBUILT box actually shows („A szerver beállítása"), not the „Elfelejtett jelszó" page it has no login screen to reach. Naming only; the acceptance pin proves the secret is untouched. R-319 — the hub models `guest_net` after 23 days of receiving and discarding it. The signal is `heals_last_hour`, not `state`: a guest the watchdog keeps repairing reads healthy between repairs. `heal_succeeded` decoded too (R-260's lesson). Unknown is never drawn as healthy — three absences, three sentences. No alarm, deliberately. Three red-proofs, mutations asserted applied. Wire-gate checked tags 182 → 190. B1 — the operator's 2026-08-12 dispositions were NOT in the register; they are now. Third allowlist kind for the five ruled "no reader wanted"; `reporting_disabled` reclassified redundant. 8 read · 5 deliberately unread · 1 redundant · 6 still owed. Also filed: R-321 (a deliberately-silent box still alarms stale/down — the checker is age-only, and decoding the flag would not have fixed it), R-322 (the claim guard has never scanned the hub; a hand scan returns zero, so it is a scope gap, not a defect).
455 lines
20 KiB
Go
455 lines
20 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ó 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" | "reenroll" | "claimed" (claim.EmailKind values). The code appears ONLY in the
|
|
// returned body — callers must never log it.
|
|
//
|
|
// R-295, HUB HALF (2026-08-13). ONE NAME PER SECRET, and it is „Beállító kód".
|
|
//
|
|
// The three-word code that gives a person control of the DASHBOARD is „Beállító kód" everywhere —
|
|
// in this mail, on the operator button, and on the box's own page, which has said so since the
|
|
// controller half shipped on 2026-08-10. The TEN-word code that opens the sealed backups is
|
|
// „Helyreállítási kód" and is a different secret entirely. **„Visszaállító kód" is retired**: it was
|
|
// a near-homograph of „Helyreállítási kód", the collision cost a real code, and the hub was the last
|
|
// place it survived — this half was dropped twice before it was finished.
|
|
//
|
|
// The rule the three branches below follow: ONE SECRET IN TWO SITUATIONS KEEPS ITS NAME, AND THE
|
|
// SENTENCE AROUND IT CHANGES. „reset" and „reenroll" carry the identical secret under the identical
|
|
// name; they differ only in which page the customer will actually be looking at.
|
|
//
|
|
// THIS IS NAMING, NOT FUNCTION. No acceptance logic moved: the code is minted, hashed, rotated,
|
|
// capped and consumed exactly as before, and a reset code is still accepted on the setup page.
|
|
// Pinned by TestFormatClaimEmail_OneNamePerSecret and, on the box side, by the controller's
|
|
// claim_code_naming_test.go.
|
|
func FormatClaimEmail(kind, customerID, domain, code string) (string, string) {
|
|
dashboardURL := "https://felhom." + domain
|
|
switch kind {
|
|
case "reset":
|
|
// The box HAS a password: the customer asked for a reset from the login screen, so the
|
|
// „Elfelejtett jelszó" link IS on their screen and naming it is correct here.
|
|
subject := "[Felhom] Beállító kód a jelszavad visszaállításához"
|
|
body := fmt.Sprintf(`Kedves Ügyfél!
|
|
|
|
Jelszó-visszaállítást kértél a Felhom vezérlőpultodhoz.
|
|
|
|
Beá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 "reenroll":
|
|
// The box was rebuilt and has NO password, so it shows „A szerver beállítása" and serves no
|
|
// login page — there is no „Elfelejtett jelszó" link to send anyone to. Same secret, same
|
|
// name, the page the machine is actually showing.
|
|
//
|
|
// It deliberately says NOTHING about the apps or the backups. A clean-slate reinstall is
|
|
// exactly the situation in which such a reassurance could be false, and this project has
|
|
// spent four register rows removing promises it could not see were still true.
|
|
subject := "[Felhom] Új beállító kód — újratelepült a szervered"
|
|
body := fmt.Sprintf(`Kedves Ügyfél!
|
|
|
|
A Felhom szervered újratelepült, ezért a vezérlőpultod belépését újra be kell
|
|
állítani. A korábbi jelszavad már nem érvényes.
|
|
|
|
Beállító kód: %s
|
|
|
|
A kód 72 óráig érvényes, és egyszer használható fel. Nyisd meg a vezérlőpultot —
|
|
"A szerver beállítása" oldal fogad —, add meg a kódot, majd válassz új jelszót:
|
|
|
|
%s
|
|
|
|
Ha nem te telepítetted újra a szervered, vedd fel a kapcsolatot az üzemeltetővel.
|
|
|
|
Ü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])
|
|
}
|