Files
felhom.eu/hub/internal/notify/templates.go
T
2026-09-24 11:38:13 +02:00

403 lines
19 KiB
Go

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.<event_type>`, 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 "— <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])
}
// 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
}