package notify import ( "encoding/json" "fmt" "strings" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/i18n" ) // budapest timezone for formatting. var budapest *time.Location // nowFn is the clock the mail bodies stamp themselves with. It exists so the golden tests can // render a mail that is byte-stable; production never replaces it. // // A mail body carries `time.Now()`, so without this seam every golden would differ from itself one // minute later and the parity measurement slice 3 rests on would be impossible to take. Replaced // only from tests, and restored by them. var nowFn = time.Now 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 := nowFn().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. // customerMessages maps event_type → the Hungarian customer message, DERIVED FROM THE BUNDLE. // // It was a hand-written literal until v0.118.0. The sentences now live in // internal/i18n/locales/hu.json under `mail.event.`, because they need an English twin; // this map is rebuilt from them so a sentence is written in exactly ONE place. Everything that read // this map still reads it, and "a new event type must enter allowedEventTypes AND customerMessages // together" is unchanged in meaning — the entry is now a line in hu.json (and its English twin, or // the missing-key gate fails). // // WHAT AN ABSENT ENTRY MEANS, because it is deliberate for several types and has been misread once // already (asserted in a v0.78.0 comment, corrected in v0.79.0 / R-97c): // // - An absent entry does NOT block delivery. FormatCustomerEmail falls back to the sentence the // box sent, and the customer is mailed that. // - `disk_warning` / `disk_critical`, `offbox_enlarge_blocked` and `disk_health_degraded` have no // entry ON PURPOSE. Their producers send a DYNAMIC sentence naming the filesystem and its free // space, and the entry WINS over the message — so adding one would throw away the drive name and // the byte figures, leaving the customer a warning with nothing to act on. Pinned by // TestDiskFillTypesHaveNoGenericCustomerMessage and templates_offbox_test.go. // - Operator-tier types (`whole_guest_backup_failed`, `offsite_delivery_stuck`, the restore-test // pair) have no entry because they are listed in operatorOnlyEvents, which is what actually keeps // them off the customer channel. The absent entry is a consequence, not the mechanism. var customerMessages = bundleMessages("mail.event.") // severityLabels maps severity → the Hungarian label. Derived from the bundle, same reasoning. var severityLabels = bundleMessages("mail.severity.") // bundleMessages rebuilds a legacy map from the Hungarian bundle: every key under prefix, with the // prefix stripped. Hungarian only — a caller that wants the household's language asks the bundle. func bundleMessages(prefix string) map[string]string { b := i18n.Shared() out := map[string]string{} for _, k := range b.Keys(i18n.Default) { if strings.HasPrefix(k, prefix) { out[strings.TrimPrefix(k, prefix)] = b.Msg(i18n.Default, k) } } return out } // FormatCustomerEmail returns (subject, textBody) for the customer channel, written in lang. // // SLICE 3 (R-558). Two things arrive from the box and they are NOT interchangeable: // // - `message` is the box's own Hungarian sentence. It is what the operator reads, it is what is // written to notification_log, and it is what every hub before v0.118.0 put in this mail. // - `messageCustomer` is the SAME sentence in the household's language, sent beside it by // controller v0.256.0 and later. It is optional, forever: Peti's parked box will never send it, // and an absent one must leave this mail exactly as it was. // // The hub cannot translate a sentence the box composed, so this is the only way a dynamic line // (`the /mnt/adat disk is 91% full`) can reach an English household in English. // // `lang` governs only what the HOUSEHOLD reads. FormatOperatorEmail is untouched and still receives // `message`. func FormatCustomerEmail(lang, customerID, eventType, severity, message, messageCustomer, detailsJSON string) (string, string) { b := i18n.Shared() lang = i18n.Normalize(lang) label := b.Msg(lang, "mail.severity."+severity) if !b.Has(i18n.Default, "mail.severity."+severity) { label = severity // an unlabelled severity prints its own name, as it always has } // The box's sentence, in the household's language when the box sent one. boxMessage := message if messageCustomer != "" { boxMessage = messageCustomer } // The per-event-type message if this type has one, otherwise the box's own sentence. // // The ENTRY WINS over the message, and that is load-bearing rather than incidental: the types // that deliberately have no entry (disk_warning, disk_health_degraded, offbox_enlarge_blocked) // send a dynamic sentence naming the drive and the free space, and a generic entry would throw // exactly that away. Pinned by TestDiskFillTypesHaveNoGenericCustomerMessage. headline := "" if b.Has(i18n.Default, "mail.event."+eventType) { headline = b.Msg(lang, "mail.event."+eventType) // v0.120.0: an entry that NAMES THE APP (`%s`) is rendered with the details' stack_name — the // subject then reads "docmost: …" rather than a sentence that could be about any app. No // stack_name → the box's own sentence; neither → "?" in the app's place. Never a literal "%s" // in a household's inbox, never an empty subject. if appNamedMailEvents[eventType] { switch app := stackNameOf(detailsJSON); { case app != "": headline = b.Msgf(lang, "mail.event."+eventType, app) case boxMessage != "": headline = "" default: headline = b.Msgf(lang, "mail.event."+eventType, "?") } } } if headline == "" { headline = boxMessage } subject := b.Msgf(lang, "mail.customer.subject", label, headline) now := nowFn().In(budapest).Format("2006-01-02 15:04") body := b.Msgf(lang, "mail.customer.body", headline, customerID, now, label, eventType) if boxMessage != "" && boxMessage != headline { body += b.Msgf(lang, "mail.customer.line.message", boxMessage) } if detailsJSON != "" && detailsJSON != "{}" { body += b.Msgf(lang, "mail.customer.line.note", detailsJSON) } body += b.Msg(lang, "mail.customer.signoff") 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(lang, kind, customerID, domain, code string) (string, string) { b := i18n.Shared() dashboardURL := "https://felhom." + domain // An unknown kind is the "claim" mail, exactly as the switch's default always was. switch kind { case "reset", "reenroll", "claimed": default: kind = "claim" } subject := b.Msg(lang, "mail.claim."+kind+".subject") // "claimed" is the one branch that carries no code — it confirms a claim that already happened, // and putting a live secret in a mail that needs none would be a step backwards. if kind == "claimed" { return subject, b.Msgf(lang, "mail.claim.claimed.body", dashboardURL) } return subject, b.Msgf(lang, "mail.claim."+kind+".body", code, dashboardURL) } // 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. // // R-323, THE THIRD NAME (2026-08-13). This mail used to call the five-word phrase „visszaállító // jelszó" — one word away from „Visszaállító kód", which had just been retired for colliding with // „Helyreállítási kód". It is „Tulajdonosi jelmondat" now. Two reasons, and the second is the one // that rules out the obvious alternatives: // // 1. THE OLD NAME WAS FALSE. The phrase restores nothing. It proves the account owns the box being // linked — so the name says that. // 2. IT MUST NOT COLLIDE WITH THE OTHER FACTOR ON THE SAME PAGE. Item 1 below is the „Párosító // kód". Naming this one after the same act („Összekötési jelszó") would leave the two factors a // customer types in one sitting distinguished only by kód-versus-jelszó — which is EXACTLY the // „Visszaállító kód" / „Visszaállító jelszó" shape being removed. „Fiókjelszó" is worse still: // there IS an account password (the dashboard login), so it would collide with a different real // secret. „Tulajdonosi jelmondat" is distinct from all three on BOTH axes — the stem // (Tulajdonosi vs Beállító / Helyreállítási / Párosító) and the noun (jelmondat vs kód / jelszó). // // Naming only: no acceptance logic moved, and the same phrase is still accepted. Pinned by // TestSelfBind_ThirdSecretNaming and TestSelfBindPassphrase_StillAcceptedAfterTheRename. func FormatSelfBindEmail(lang, customerID, link string) (string, string) { b := i18n.Shared() return b.Msg(lang, "mail.selfbind.subject"), b.Msgf(lang, "mail.selfbind.body", link) } // ────────────────────────────────────────────────────────────────────── // 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]) } // appNamedMailEvents are the types whose `mail.event.*` entry carries the app's name as `%s` // (v0.120.0). A named register, like operatorOnlyEvents: an entry gains an argument only on purpose. var appNamedMailEvents = map[string]bool{ "app_update_undone": true, "app_update_held": true, // v0.123.0 (`09` §3 decision 28): the box stopped an app that kept crashing / ran out of memory. "app_stopped_unhealthy": true, } // stackNameOf reads `stack_name` from an event's details, "" when absent or unreadable. func stackNameOf(detailsJSON string) string { if detailsJSON == "" { return "" } var d struct { StackName string `json:"stack_name"` } if json.Unmarshal([]byte(detailsJSON), &d) != nil { return "" } return d.StackName }