99c0709cbe
gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
403 lines
19 KiB
Go
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
|
|
}
|