diff --git a/.claude/rules/hub.md b/.claude/rules/hub.md index 27707b06..b0534417 100644 --- a/.claude/rules/hub.md +++ b/.claude/rules/hub.md @@ -50,7 +50,9 @@ cannot be read off the code: the blocked thread remains. Liveness is decided from `/proc` and kernel state, never by reading or writing the filesystem. - New event types must enter `allowedEventTypes` **and** `customerMessages` together, or `POST - /event` 400s. + /event` 400s. **From v0.118.0 the `customerMessages` half is a line in `internal/i18n/locales/hu.json` + (`mail.event.`) AND its English twin** — the map is derived from the bundle, and the + missing-key gate is held at zero. Customer copy lives in the bundle; the operator's mails do not. - Status logic: OK (report younger than `alerting.stale_threshold`), WARN (past the threshold or `health=warn`), DOWN (past 2× the threshold or `health=fail`). The threshold is **configuration** (`manifests/hub.yaml`; 45 m by operator ruling 2026-09-17, R-549), and the display diff --git a/REPORT.md b/REPORT.md index 80f1c7e7..99fc1a60 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,90 +1,99 @@ -## Claims in the prompt that turned out wrong — settled so far (measured, not argued) +# REPORT — localisation slice 3 Part A: the hub's e-mails follow the household's language -1. **"A custom PBS role can carry just `Datastore.Modify`" — FALSE, and the prompt itself flagged it - as unverified.** Proxmox Backup Server has **no role-create command** and no custom roles: the CLI - describes `` as "Enum representing roles via their [PRIVILEGES] combination", and - `proxmox-backup-manager` offers no `role` subcommand at all. I then measured the narrowest - BUILT-IN role by applying it and reading the effective permissions back: `DatastorePowerUser` - grants **Datastore.Backup + Datastore.Prune only** — it does not help. `DatastoreAdmin` grants - Audit, Backup, Modify, Prune, Read, Verify, and is therefore the narrowest role that works. It is - applied for the hub's `felhom@pbs` on `/datastore/felhom-offsite` only; the per-customer - `DatastoreBackup` entries are untouched. +**hub v0.118.0** · felhom.eu base `20aafc3dec20` · 2026-09-18 · R-558 (Part A), R-555 closed -2. **"The banner unit can learn the claimed state" — NOT REACHABLE, as the prompt suspected.** Two - measurements: the one-shot bind delivery emits `FELHOM_CUSTOMER_ID`, `FELHOM_RETRIEVAL_PASSPHRASE`, - `FELHOM_MODE` and `FELHOM_EXTRA_ARGS` — **no domain** — so the console cannot name the dashboard - URL without inventing it; and the unit hands over to the host install and exits, so the later CLAIM - happens when nothing is watching. What IS reachable, and is what shipped: the pairing code stops - being the last thing on the screen the moment the bind lands. The rest of R-535 is recorded as a - residue rather than implied away. +## Claims in the task that turned out wrong, named first -3. **The baseline "ISO 1.27.1 published → target 1.28.0" needed care, and the care found a trap.** - `installer-v1.28.0` **already existed as a git tag** — from 2026-08-13 — because the install SCRIPT - and the ISO IMAGE are two separately numbered artifacts (`SCRIPT_VERSION` vs `ISO_VERSION`, and - `build-felhom-iso.sh` says so in a comment). The published ISO really was 1.27.1 (confirmed live: - the bucket serves 1.27.1 and 404s 1.28.0), so the target is right — but a session that read the tag - list as the ISO history would have concluded 1.28.0 was already published. +1. **"`customerMessages` 40 entries (L70)"** — **39 entries, at L69.** +2. **"`customers.language`, `appliances.reported_language`"** — **neither table exists.** There is no + `customers` table and no `appliances` table. The real ones are **`customer_configs`** (the customer + record) and **`reports`** (the box's heartbeat, one row per report). The columns added are + `customer_configs.language` and `reports.language`. +3. **"no `ALTER TABLE customers ADD` found by grep — the migration pattern is something else; if it + is not obvious, stop and report."** The pattern is completely obvious and there are ~20 instances: + `s.db.Exec("ALTER TABLE ADD COLUMN NOT NULL DEFAULT ")`, error deliberately + ignored so it is idempotent. The grep failed only because it named a table that does not exist. +4. **"`renderBackupRunFailures` gains a `lang` parameter"** — it is **operator-only and already + English**. Its single caller is `FormatOperatorEmail`. Untouched. +5. **"`message_customer`'s length is capped like `message`"** — **`message` has no length cap.** Both + are bounded only by the 1 MB `LimitReader` on the request body. I wrote a cap, then removed it: a + byte-count truncation would also cut a UTF-8 sequence in half. +6. **"`python3 scripts/hub_gates.py` (or the runner the hub uses — name it)"** — there is no + `hub_gates.py`. The hub's gates run from **`scripts/repo_gates.py`**, this repo's single runner. +7. **Line numbers** were mostly off by one or two (`customerMessages` 69 not 70, `severityLabels` 158 + not 159, `FormatCustomerEmail` 166 not 167). -4. **Still open at the time of writing:** "the off-site wizard's full restore brings back deleted files - for nextcloud" — proven for immich in July and read from the design for nextcloud; Part E walks it. - -5. **„The off-site wizard's full restore brings back deleted files for nextcloud" — TRUE, now measured.** - It was proven for immich in July and read from the design for nextcloud. Tonight it was walked: - five photos, deleted, returned byte-identical (sha256 5/5, negative control). - -6. **My own wrong reading, recorded because I nearly filed it as a product fault.** I reported the data - drive as „formatted but not mounted, 42 minutes on" from `/api/disks/candidates`. The storage page - said the opposite and was right — the drive was mounted, registered and default. The endpoint reports - raw disks from the agent, not what the controller has registered (now **R-542**). +Claims that were **right**: no mail goldens existed; the hub read no `language` anywhere outside a +comment; `05-hub-architecture.md` had no customer-mail section; the box has published the field since +v0.247.0. I briefly believed `Report.Language` was never assigned and was wrong — `cmd/` is +gitignored, so `rg` skips `main.go`, where all four assignments live. ## What shipped -**controller v0.244.0** — the backup label is PER TIER and no longer claims files a Tier-1 unit cannot -hold (R-537); a unit restore REFUSES before touching anything when it cannot return the app's drive-side -files, and names the route that can (R-538); `app_deployed` moved from the deploy's acceptance to its -completion, with `app_deploy_started` / `app_deploy_failed` as the honest pair (R-536). Plus the pending -„0 B" tile fix. +The hub reads the language the box reports and writes the household's e-mail in it. **Nothing an +operator reads changed.** Hungarian is byte-identical, measured. -**hub v0.116.0** — off-site backup is ON by default for a new customer (shared, 100 GB prefilled), and -the two new deploy event types are registered in both `allowedEventTypes` and `customerMessages`. +- **`internal/i18n`** — flat bundle, 79 keys, hu authoritative + hu fallback, missing ceiling 0. +- **56 mail goldens per language**, captured from v0.117.0 **before** any string moved. All 56 + Hungarian ones pass unchanged after the rewrite. A `nowFn` seam makes them byte-stable. +- **`customerMessages`/`severityLabels` are derived from the bundle** — one place per sentence, and + all 40+ existing tests and comments that read them still work. +- **Order: last reported → created-with → `hu`.** `reports.language` defaults to **empty**, never + `hu`: "never told us" is not "chose Hungarian". +- **`message_customer`** accepted on `POST /api/v1/event` (additive, optional forever). +- **Per-language bind page**, built the controller's way (substitute markers, then parse). +- Customer form `language` select; `customer.language` in `controller.yaml` (diff: one line). -**ep0** — one narrow grant: `DatastoreAdmin` for the hub's `felhom@pbs` on `/datastore/felhom-offsite` -only. The narrowest role was MEASURED: `DatastorePowerUser` carries Backup+Prune only, and PBS has no -custom roles. Per-customer `DatastoreBackup` entries untouched. +## Live evidence, read off the running hub -**ISO 1.28.0** — built, gate-checked, installed and walked. **NOT published** — that is the operator's -call and the one STOP of this task. +The wire already worked and I measured it before writing anything (read-only copy of `hub.db` + WAL): -**golden 0.244.0** — baked, published, vouched as a three-field change (agent and min_agent unchanged at -0.131.0), fleet floor raised 0.242.0 → 0.244.0 and already delivering (demo-felhom moved itself). +``` +demo-felhom 2026-09-18 13:42:56 language='hu' controller 0.255.0 +demo-hp 2026-09-18 13:45:24 language='en' controller 0.255.0 +drill-r50 / peti-felhom / tester-1 language= (0.213.0 / 0.115.0 / 0.245.0) +``` -## Red-proofs +That is the positive and the negative control in one read: boxes ≥ 0.247.0 report the field, older +ones send nothing at all — which is exactly the case the empty default exists for. -Each fix seen failing with its own sentence, then passing: the app-shaped label restored → the Tier-1 -assertion fails; the guard disabled → „a restore that cannot return the files must refuse" fails; the -accept-time call put back → „the deploy handler announces an INSTALLED app at accept time" fails; the -success hook removed → „the deploy ended and nothing was told about it" fails; the hub default dropped → -„the new-customer form does not default the off-site copy ON" fails. +## Two defects found inside this release -## The walk, end to end (evidence: `audits/evidence-backup-promise-2026-09-16/`) +- **R-581 (P2, partly still open).** `CustomerLanguage` picked the newest report by `received_at`, + which has **second** granularity — same-second reports tie and the winner is arbitrary. A household + that had just switched would get the old language back at random. Fixed by ordering on the + autoincrement `id`; caught by a test that failed on the first version. **Still open: + `GetCustomers()` has the same shape** and feeds the whole operator dashboard. +- **R-582 (closed, kept for the lesson).** The English copy-guard stems, ported word-for-word from the + Hungarian, convicted **141 honest sentences**. In Hungarian the stem *is* the claim + (`visszaállíthat` = *can restore*); English splits the modal from the verb, so the claim is a + phrase. Then the decoy suite caught the fix being too narrow — "can **still** be restored" walked + through a pattern written for "can be restored". -Fresh VM from the BUILT image → Felhom's own first screen, no admin URL → registered itself → **bound -with zero operator presses** (the mail the hub sent itself after the morning's host delete) → claimed → -landed on agent 0.131.0 + controller 0.244.0 → data drive registered → Nextcloud deployed → five photos -in → tier-1 leg → **the PBS cascade stopped at R-511's refusal and needed ONE operator press**, which -then succeeded because of this morning's grant → escrow ceremony (re-auth required; code shown once, -captured out-of-band) → tier-3 „Sikeres" → photos deleted → **the old route REFUSED and touched nothing** -→ off-site restore (verification copy, then reconstitution: „5 fájl és 3 adatkötet és az adatbázis") → -**the photos open, byte-identical** → teardown in three layers → the automatic connect e-mail again, one -second after the host delete. +## Gate work -## Rows +`wire_contract_gate.py`: the R-555 `language` allowlist entry **deleted** — the field is genuinely +decoded now and the gate checks it (202 tags, was 201). `hub_copy_gate.py`: **the sentences moved into +the bundle**, so `CUSTOMER_SURFACES` had to move with them — without that the four declared Go files +would all still exist, the gate would still report success, and it would be scanning nothing. It +learned English, and found one real occurrence (operator-tier, registered with its reason). Three new +decoys including an **innocent control**; the `hub-copy` exemption is removed from +`decoy_coverage_gate.py`. -Opened: R-539, R-540, R-541, R-542, **R-543** (P1 — off-site on by default is not off-site working on day -one), R-544. Closed: R-511, R-534, R-536, R-537, R-538. Register 236 → 244 open. +## The mails -## Checks +| Group | Count | Golden | English | Live-tested | +|---|---|---|---|---| +| Event types (`mail.event.*`) | 39 | ✅ hu + en | ✅ | pending (§13.1) | +| Severity labels | 4 | ✅ | ✅ | — | +| Customer wrapper (subject, body, 2 lines, sign-off) | 5 | ✅ | ✅ | — | +| Claim arc (claim / reset / reenroll / claimed) | 4×2 | ✅ | ✅ | pending (§13.2) | +| Self-bind mail | 2 | ✅ | ✅ | pending | +| Bind page | 21 | — (render tests) | ✅ | pending | +| Operator mails | 3 | ✅ (unchanged) | n/a — never localised | — | -`repo_gates.py --fast` green at every push; controller `go build/vet/test` green; controller gates 15/15; -`unproven.py --summary` unchanged at 35 of 55 not walked. Secret-leak check on the evidence: six real -secret values as needles, planted control matched 6/6, committed evidence 0. +## Green + +`go build` / `go vet` / `go test ./...` clean. All 14 felhom.eu gates OK. 15/15 decoys behave. + +**Not yet done:** deploy + live proof (§13), and Part B (controller v0.256.0, the box's own sentence). diff --git a/documentation/architecture/05-hub-architecture.md b/documentation/architecture/05-hub-architecture.md index 93cd1814..146fc70f 100644 --- a/documentation/architecture/05-hub-architecture.md +++ b/documentation/architecture/05-hub-architecture.md @@ -284,3 +284,75 @@ the explicit Re-issue action") and the re-issue itself then refused with 400 — tier. **Not built:** releasing ONLY the token on host delete. The endpoint's only removal op (`deprovision`) destroys the backups too, so a token-only release needs a new endpoint operation. +## 15. Customer e-mails — what the hub writes to a household [hub v0.118.0, R-558] + +**The section this document did not have.** The hub composes every sentence a household reads before +it has seen any box screen, and until v0.118.0 nothing here described that. + +### 15.1 The four mails + +| Mail | Trigger | Rendered by | Language source | +|---|---|---|---| +| Event notification (39 event types) | a box event, or a hub checker | `FormatCustomerEmail` | reported → created-with → `hu` | +| Claim / reset / re-enroll / claimed | the claim arc | `FormatClaimEmail` | created-with (no box has reported yet) | +| Self-bind link | customer creation, or the operator's button | `FormatSelfBindEmail` | created-with | +| The public bind PAGE at `/bind/` | the customer opens the link | one template per language | created-with, **except `expired`** — see 15.4 | + +The operator's channel (`FormatOperatorEmail`, and the R-182 backup-run digest) is **not** in this +table and is not localised. It is English, it names host ids and blob counts, and it is untouched. + +### 15.2 Where the sentences live + +`hub/internal/i18n/locales/{hu,en}.json`, one flat key→text map per language. Hungarian is +authoritative and holds every key; a key missing from English renders the Hungarian and is counted by +a gate held at zero. `customerMessages` and `severityLabels` are DERIVED from the bundle rather than +being literals, so a sentence is written in exactly one place. **A new event type therefore needs a +line in `hu.json` and its English twin**, alongside its `allowedEventTypes` entry — the long-standing +"both together" rule, in its new home. + +### 15.3 The language order, and why it is that order + +**Last reported → created-with → Hungarian** (`Store.CustomerLanguage`). + +1. **What the box last reported** is what the HOUSEHOLD chose on their own dashboard. It outranks + everything else: the operator's creation-time pick is a default, never an override. +2. **The creation-time language** (`customer_configs.language`) covers the window before any box has + reported — which is precisely when the claim mail and the bind page are sent, so it is not an edge + case. It also seeds the box: configgen writes it as `customer.language`. +3. **Hungarian**, for every customer that predates all of this. + +Two storage rules follow from that order and are easy to get wrong: + +- `reports.language` defaults to **empty**, never `hu`. Empty means *this box has never told us*, + which is not the same as *this household chose Hungarian* — a controller older than v0.247.0 sends + no language at all, and storing `hu` would make a later real choice indistinguishable from the + absence of one. +- The newest report is found by the autoincrement **`id`**, not by `received_at`. `received_at` has + second granularity, so two reports arriving in one second tie and the winner is arbitrary. + +A quiet-box alarm deliberately uses the last REPORTED language even though the box is silent: the +last thing it said is still the best thing known about the household. + +### 15.4 The box's own sentences, and the one thing the hub cannot do + +About a third of the customer mails carry a sentence the BOX composed, naming a drive, an app or a +number. **The hub cannot translate one.** So the box sends the household's version beside the +Hungarian one, as `message_customer` on `POST /api/v1/event`; the hub puts that in the household's +mail and keeps the Hungarian for the operator's. It is additive and optional **forever** — a parked +box will never send it, and its absence must leave the mail exactly as it was. + +Until every box runs controller v0.256.0 or later, an English household's mail can carry one +Hungarian line. The rest of the mail is English. That is expected, not a defect. + +**The bind page is a no-oracle surface, and the LANGUAGE is part of that.** The page folds an unknown +token into `expired` so a stranger cannot learn whether a link was ever real. If it then rendered a +real English customer's expired token in English and an unknown one in Hungarian, the language would +answer the question the text refuses to — for every customer who is not Hungarian. The `expired` +state therefore always renders in the default language; every other state already discloses that the +token is real. Pinned by `TestBindExpiredIsAlwaysDefaultLanguage`. + +### 15.5 How "the Hungarian did not change" is known + +56 goldens captured from v0.117.0 before any string moved, in +`hub/internal/notify/testdata/mail_goldens/hu/`, with the English set beside them. The claim is a +diff, not a reading. A golden is never regenerated to make a change pass. diff --git a/documentation/architecture/10-localisation.md b/documentation/architecture/10-localisation.md index 4b18806f..00049b72 100644 --- a/documentation/architecture/10-localisation.md +++ b/documentation/architecture/10-localisation.md @@ -119,7 +119,16 @@ the hub's e-mails follow.** `"language": "en"`, the three pages English; the hub's stored reports read no field (0.246.0), `hu`, `en` at 12:54:43Z, `hu` at 12:55:22Z after switching back; without `_csrf` → 403 and nothing changed (`audits/i18n-2026-09-17/live/README.md`). -- **[DESIGN] Not built — slice 3:** the hub stores a per-customer language, renders it into +- **[FACT] Slice 3 Part A, hub v0.118.0 (2026-09-18):** the hub READS the reported language and writes + the household's e-mails in it. `hub/internal/i18n` (79 keys, hu authoritative, hu fallback, missing + ceiling 0); 56 mail goldens captured from v0.117.0 BEFORE any string moved, and all 56 Hungarian + ones pass unchanged. `customerMessages`/`severityLabels` are derived from the bundle. Order: last + reported → `customer_configs.language` → `hu` (`Store.CustomerLanguage`). `message_customer` is + accepted on `POST /api/v1/event` for the box's own sentences. The bind page is per-language, with + `expired` pinned to Hungarian so the language cannot become the oracle the text refuses to be. + Full design: `05-hub-architecture.md` §15. **R-555 closed** — the `language` allowlist entry is out + of `wire_contract_gate.py` and the gate now checks the field for real. +- **[DESIGN] Slice 3 Part A as planned — now built; the box half (Part B) is the remaining piece:** the hub stores a per-customer language, renders it into `controller.yaml` next to `customer.id/name/domain/email` (`hub/internal/configgen/configgen.go`), and the box uses it **only while the household has never chosen** (`settings.json` empty). The household's own choice always wins; the hub's e-mails follow the language the box **reports**, which diff --git a/documentation/backlog/OPEN-ITEMS.md b/documentation/backlog/OPEN-ITEMS.md index 9556d0bc..f8a9681a 100644 --- a/documentation/backlog/OPEN-ITEMS.md +++ b/documentation/backlog/OPEN-ITEMS.md @@ -756,6 +756,8 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server` | **R-578** | **[P3-LOW] A helper that takes the settings lock must never be called from inside a settings callback — there is no gate, only one test in one package.** FOUND 2026-09-18 the hard way, by localisation slice 2 release C introducing exactly that: `UpdateOffboxStatus` holds the settings WRITE lock while it runs its callback, `boxLang()` reads the language through the READ lock, and `sync.RWMutex` is not reentrant — so the off-site run's final status write DEADLOCKED, **holding the settings lock**, which would wedge everything else on that box that touches `settings.json`. The only symptom was `go test ./internal/backup/` going from 8 minutes to a 25-minute timeout. Fixed by hoisting the language resolution; `TestNoteHelpersAreNotCalledUnderTheSettingsLock` (internal/backup) now names the file and line in a second. **What is still open:** that test covers `internal/backup` only, and it knows only the `note`/`noteErr`/`boxLang` helpers. Any other settings-reading helper, in any other package, can make the same mistake with nothing to catch it but a hang. **Fix shape:** promote it to a gate over every package, keyed on "a call to a method that reads settings, inside a literal passed to a `settings.Update*` function"; or give `Settings` a re-entrant read path and remove the class. | **READY - rank P3-LOW; owner: CC** | | **R-579** | **[P3-LOW] Five page shells loaded `style.css` with NO cache-buster, so a browser holding an older copy kept being served CSS that did not know about the newest UI.** FOUND 2026-09-18 by the operator's screenshot of the v0.254.0 language globe: it rendered as a bare, unstyled `
` — a stray triangle and two plain words outside the card — because `login.html`, `claim.html`, `recovery.html`, `launcher_shared.html` and `launcher_share_password.html` requested `/static/style.css` with no `?v=`, while `layout.html` has used `?v={{.Version}}` since v0.166.0. **`.Version` was also absent from three of those five data maps.** FIXED AND CLOSED in the same session (controller v0.255.0): the parameter on all five, and `Version` set once in `executeTemplateLang` so a new shell cannot miss it; `TestGlobeOnAnonymousShells` now refuses an absent or EMPTY `?v=`. **The general form, which is the part worth keeping: a template that loads a versioned asset WITHOUT its version is invisible to every test that reads markup — the markup is correct and the browser fetches the wrong file.** A gate over "every stylesheet/script link in a template carries `?v=`" would catch the class; not built, because 8 first-boot-wizard templates would fail it and R-554 deletes them. | **CLOSED 2026-09-18 - controller v0.255.0** | | **R-580** | **[P3-LOW] `curl -w '%{redirect_url}'` prints Basic-auth credentials back into the session transcript.** FOUND 2026-09-18 raising the fleet floor to 0.255.0: the hub's `/configuration/global-floor` form was driven with `--netrc-file` and `-w 'POST http=%{http_code} -> %{redirect_url}'` to read the `flash=floor_set` confirmation. curl re-injects the credentials into the redirect URL it formats, so the operator's hub password appeared in the session's own output. **Nothing was written to a file and nothing was committed** — the exposure is the transcript. **The general form, which is the part worth keeping: a curl format variable can carry a secret that never appeared in the command.** `%{redirect_url}`, `%{url_effective}` and `%{referer}` all reconstruct the request URL, and `--netrc`/`-u` credentials go back into it. **Fix shape:** print `%{http_code}` alone and read the destination from `-D-` headers, or pipe the format output through a redactor. Recorded in the `felhom-build-deploy` skill so the next hub session does not repeat it. | **READY - rank P3-LOW; owner: CC** | +| **R-581** | **[P2-MED] `ORDER BY received_at` cannot answer "the newest report" — the column has SECOND granularity.** FOUND 2026-09-18 building hub v0.118.0 (R-558): `Store.CustomerLanguage` read the household's language from the newest report ordered by `received_at`, and `TestNewestReportedLanguageWins` failed — four reports written in the same test tick all carry the same `datetime('now')` string, so the "newest" was whichever row SQLite felt like returning. On a real box the same shape appears whenever two reports land in one second (a settle burst, a restart race), and the symptom would have been a household switching language on their dashboard and getting the old language back at random. FIXED in the same release by ordering on the autoincrement `id`, which is the real insertion order. **What is still open, and it is the reason this is a row rather than a note: `GetCustomers()` has the same shape** — `INNER JOIN (SELECT customer_id, MAX(received_at) …)` with no tie-break — and it is what the whole operator dashboard and `countBoxesBelowFloor` read. A same-second tie there picks an arbitrary report's health, version and vitals. Not observed in the wild; not looked for either. **Fix shape:** tie-break every newest-report query on `id DESC`, or give `reports` a monotonic ordering column and use it everywhere; then a test that writes two reports in one tick and asserts which one wins. | **READY - rank P2-MED; owner: CC** | +| **R-582** | **[P3-LOW] An English copy guard written from the Hungarian one matches ordinary words instead of the claim.** FOUND 2026-09-18 extending `hub_copy_gate.py` for R-558. The Hungarian retrieval stems ARE the claim — `visszaállíthat` is one word meaning *can restore* — so the guard matches a stem. Ported to English as the bare stems `restor`/`recover`, the gate convicted **141 honest sentences** on its first run: "Disaster recovery has started", "Your server can be reached again", the name of the Restore page, and every JSON KEY containing the word. English splits the modal from the verb, so the English form of the claim is a PHRASE. FIXED in the same session: the patterns carry the modal, and the gate scans bundle VALUES only, never keys. **Then the decoy suite caught the fix being too narrow** — a planted "can STILL be restored" walked through a pattern written for "can be restored", which is R-299 again in another language; every pattern now admits an adverb. **The general form worth keeping: a guard ported between languages must be re-derived from what the claim IS in the new language, not translated word for word — and a guard that convicts 141 true sentences is worse than no guard, because it earns an allowlist entry per sentence and then nobody reads it.** | **CLOSED 2026-09-18 - hub v0.118.0 (recorded for the lesson; three decoys incl. an innocent control)** | | **R-537** | **[P1-HIGH] The app-backup page labels the tier-1 backup „DB + Konfig + Adatok" and prints the app's data-drive size next to it — but the tier-1 unit contains NO drive-side app data at all.** MEASURED 2026-09-16 on the drill box (fresh install, controller 0.243.0, one drive, tier 2 and tier 3 both „Nincs beállítva"): five photos (3 000 000 B) were uploaded into Nextcloud through its own WebDAV interface, then the customer-visible „Mentés most" was pressed (`POST /api/backup/run` → 200, the unit grew 25 337 B → 978 MB). The resulting unit's `manifest.json` lists `db-dumps` + three **docker volume** dumps and nothing else; listing the 781 MB `nextcloud_nextcloud_html.tar` (29 346 entries, positive control `version.php` = 3 hits) gives **`Fotok` = 0 and `nyaralas` = 0**, and `./data/` is the empty bind-mount point. A `find` over the whole `backups/` tree for `*appdata*` / `*Fotok*` returns nothing. The page nevertheless renders „1. mentés … DB + Konfig + Adatok" and „Nextcloud Adatlemez 65.1 MB" — a size measured on exactly the data it does not copy (`internal/web/handlers.go:1176-1178`, `BackupContents`). **This is a truth defect, not a design defect:** `07-backup-architecture.md` §6.2 places nextcloud's file leg at **Tier 2 and Tier 3 only**, and its „[FACT] What the whole-guest tiers do NOT carry" says `mp8 /mnt/felhom-drives` is out of vzdump scope (confirmed live: „excluding bind mount point mp8 … (not a volume)"). So on a one-drive box with no off-site tier — the state every fresh install starts in — the household's files are in **no backup**, while the page says „Adatok". Same family as R-517/R-518. **Fix shape:** render tier-1 contents from the capture set actually written (`ComputeCaptureSet`), so a unit with no file leg reads „DB + Konfig" and the drive size is not shown beside it; and say on the page that the app's files need tier 2 or tier 3. Evidence: `audits/evidence-drill-0243-2026-09-16/phase2-f10.txt`. **CLOSED 2026-09-16 — controller v0.244.0, proven live.** The contents label is computed PER TIER from what that tier captures: Tier 1 says „Adatok" only when the app's data really is in the volumes the unit captured, and a class-A app carries one sentence saying where its files ARE protected. Proven on demo-hp through the page the customer opens: Paperless-ngx reads „1. mentés … DB + Konfig" with „Az alkalmazás fájljait a távoli másolat (és a második meghajtó) védi …", while its „2. mentés" row still reads „DB + Konfig + Adatok". Red-proof: restoring the old app-shaped label fails `TestAppBackupRows_Tier1LabelDoesNotClaimFilesItCannotHold`. **RE-PROVEN 2026-09-16 on a FRESH box** (installed from the built ISO 1.28.0, controller 0.244.0, off-site on by default): the Nextcloud row read „1. mentés … DB + Konfig" with the new sentence, „2. mentés … Nincs 2. (off-drive) másolat", „3. mentés Sikeres restic → …your-storagebox.de"; „DB + Konfig + Adatok" appeared ZERO times while the local unit held no file leg. | **CLOSED 2026-09-16 — controller v0.244.0 (proven live on demo-hp)** | | **R-538** | **[P1-HIGH] A tier-1 app restore reports plain success and leaves Nextcloud listing files whose bytes were never in the backup — and it destroys the app's own trash, the customer's last copy.** MEASURED 2026-09-16 on the drill box, F10 („a child deletes the photo folder"): the five photos were deleted through Nextcloud (DELETE 204, PROPFIND 404), then restored through the page exactly as a customer would (`POST /backup/restore` `stack_name=nextcloud` `snapshot_id=helyi` → 302, finished in **35 s**, „A(z) nextcloud: 3 adatkötet és az adatbázis visszaállítva — az alkalmazás újraindult."). Afterwards the folder is back and **lists all five photos**, and **none of them opens**: `GET nyaralas-1..5` = 404 / 503×4 with `Sabre\DAV\Exception\NotFound`, while the positive controls at the same moment pass (`status.php` 200, WebDAV PUT 201, GET 200). Cause: the replayed MariaDB dump (11:01:45Z) knows the photos, the bytes live on `mp8` and were never captured (R-537). **Worse:** the bytes were still on the drive in Nextcloud's own trash (`appdata/nextcloud/admin/files_trashbin/files/Fotok.d1789556707/nyaralas-1..5.jpg`, all five present) and the restored database no longer references them — the trash listing comes back **empty**, so „restore from trash", the one route that would have worked, is gone. The customer is left with five unopenable photos, a success message, and no warning. **Fix shape:** before replaying a database whose app has an uncaptured file leg, refuse or warn („ennek az alkalmazásnak a fájljai nincsenek ebben a mentésben — a visszaállítás után a fájlok hiányozni fognak"); and never present a DB-only restore of a class-A app as a complete one. Evidence: `audits/evidence-drill-0243-2026-09-16/phase2-f10.txt`. **CLOSED 2026-09-16 — controller v0.244.0, proven live.** A unit restore refuses before anything is touched when the unit cannot return the app's drive-side files, and names the route that can. Fired live on demo-hp: `POST /backup/restore` for paperless-ngx → 302 with „Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. A fájlok a távoli másolatból állíthatók vissza …", and the app read `running` before AND after, so nothing was stopped and no trash was made unreachable. The database-and-settings-only path exists as a separately worded second step. Red-proof: disabling the guard fails `TestUnitRestore_RefusesWhenTheUnitCannotHoldTheFiles`. **RE-PROVEN 2026-09-16 on a FRESH box, and this time the refusal had somewhere to point:** after five photos were deleted, `POST /backup/restore` was refused with „…a fájlok így a helyükön maradnak. A fájlok a távoli másolatból állíthatók vissza: … „Teljes visszaállítás (fájlok + adatbázis)"", the app read `running` before AND after, and the wastebasket was untouched. The off-site route then returned all five photos — 200 with the exact uploaded sizes and sha256 IDENTICAL to the originals, 5/5, with a negative control. Evidence: `audits/evidence-backup-promise-2026-09-16/phaseE-photos.txt`. | **CLOSED 2026-09-16 — controller v0.244.0 (proven live on demo-hp)** | | **R-525** | **[P3-LOW] FileBrowser has its own login; putting it behind the dashboard session (traefik forwardAuth or Quantum proxy auth) is a new mechanism nobody has measured.** Filed 2026-09-15 by the P1-fixes task (B.5). R-513 closed the default-password hole with a generated password; a household still has two logins. **What it needs:** a spike on a scratch guest — forwardAuth to the controller session, and what FileBrowser Quantum does with a trusted header. | **READY — rank P3-LOW; owner: CC (spike)** | diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 7958a8d9..ab2be7d0 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,56 @@ +## v0.118.0 — the household's e-mails follow the household's language (2026-09-18, R-558 / R-555) + +**Localisation slice 3, Part A.** The hub has written every customer e-mail in Hungarian whatever the +box was set to. The box has published its language since controller v0.247.0; nothing read it. Now it +does. **Nothing the OPERATOR reads changes** — `FormatOperatorEmail`, the hub's pages and its logs are +untouched, and the Hungarian mails are byte-identical, measured rather than asserted. + +- **56 mail goldens, captured from v0.117.0 BEFORE a string moved** (`internal/notify/testdata/ + mail_goldens/{hu,en}/`). Every event type — generated from the message map itself, so a new type + cannot be added without a golden appearing beside it — plus the fallback shapes, all four + severities, the four claim mails, the self-bind mail and three operator mails. **All 56 Hungarian + goldens pass unchanged** after every string was routed through the bundle. Red-proofed: one byte + altered in the bundle names the mail and the line. +- **`internal/i18n`** — a flat key→text bundle per language, embedded, Hungarian authoritative, + Hungarian fallback, 79 keys. `EnMissingCeiling = 0`. +- **`customerMessages` and `severityLabels` are now DERIVED from the bundle**, not literals. Every one + of the forty-odd tests and comments that reads them still does, 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. +- **The language order, and it is the design:** last reported → created-with → Hungarian. A household + that picks English on their own dashboard outranks the operator's creation-time default, which in + turn covers the window before any box has reported — exactly when the claim mail and the bind page + are sent. +- **`message_customer`** on `POST /api/v1/event`, additive and optional forever. The hub cannot + translate a sentence the box composed ("the /mnt/adat disk is 91% full"), so the box will send the + household's version beside the Hungarian one (controller v0.256.0). An absent one leaves the mail + byte-identical, which is every box in the fleet today. +- **The public bind page** is per-language, built the controller's way: one parsed template per + language, markers substituted before `html/template` sees the file, so the Hungarian page is + byte-identical by construction. **The `expired` state always renders in Hungarian** — it is the + state an unknown token lands in, and rendering a real English customer's token in English would + make the LANGUAGE answer what the TEXT refuses to. Red-proofed. +- **A `language` select on the customer form**, `customer.language` in `controller.yaml` (configgen + diff: exactly one line), two new columns (`reports.language`, `customer_configs.language`). +- **Two defects found and fixed inside this release.** (1) `CustomerLanguage` ordered the newest + report by `received_at`, which has SECOND granularity — two reports in one second tied and the + winner was arbitrary, so a household that had just switched could get the old language back at + random. It orders by the autoincrement `id` now; caught by `TestNewestReportedLanguageWins`, which + failed on the first version. (2) The first English retrieval-stem list convicted 141 honest + sentences ("Disaster recovery has started", the Restore page's name) because it matched bare verbs; + the English form of the claim is a phrase carrying a modal, so the patterns carry one. +- **R-555 closed**: the `language` allowlist entry is deleted from `wire_contract_gate.py` — the field + is really decoded now, and the gate checks it (202 tags, up from 201). +- **`hub_copy_gate.py` learned the bundles and English.** ⚠ The sentences MOVED into the JSON files; + without adding them to `CUSTOMER_SURFACES` the four declared Go files would all still exist, the + gate would still report success, and it would be scanning nothing. Three new decoys, including an + INNOCENT control — and the English decoy exposed a live hole in the first pattern set ("can STILL + be restored" walked through a pattern written for "can be restored"). The `hub-copy` exemption in + `decoy_coverage_gate.py` is removed. + +**MinAgent: unchanged. No controller release required for the hub half** — an older controller sends +no `message_customer` and its mails are exactly what they are today. + ## v0.117.0 — the quiet-box alarm waits three report cycles, and two new events (2026-09-17, R-549 / R-550 / R-539) - **The dashboard's customer status now follows the configured staleness threshold** (R-549, operator diff --git a/hub/internal/api/chaosnight_events_test.go b/hub/internal/api/chaosnight_events_test.go index ea0da4a8..aaf2b82b 100644 --- a/hub/internal/api/chaosnight_events_test.go +++ b/hub/internal/api/chaosnight_events_test.go @@ -35,7 +35,7 @@ func TestRestoreInterruptedReachesTheHouseholdInHungarian(t *testing.T) { const raw = "RAW-ENGLISH-SENTINEL restore interrupted" // The SUBJECT is built from the Hungarian message alone; the body legitimately repeats the raw // message as a detail line ("Üzenet: …"), so the body cannot be the witness — the subject is. - subject, body := notify.FormatCustomerEmail("c1", et, "warning", raw, "{}") + subject, body := notify.FormatCustomerEmail("hu", "c1", et, "warning", raw, "", "{}") if strings.Contains(subject, "RAW-ENGLISH-SENTINEL") { t.Fatalf("customer mail subject for %s is the raw message — customerMessages entry missing: %q", et, subject) } diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 1924d163..49f42d23 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -2065,11 +2065,11 @@ var allowedEventTypes = map[string]bool{ // sends a dynamic Hungarian message (disk label + the triggering attribute names), so — like // offbox_enlarge_blocked — there is deliberately NO customerMessages entry (which would discard the // specifics via the templates.go fallback priority). - "disk_health_degraded": true, - "health_degraded": true, - "health_critical": true, - "health_recovered": true, - "app_deployed": true, + "disk_health_degraded": true, + "health_degraded": true, + "health_critical": true, + "health_recovered": true, + "app_deployed": true, // R-536 (controller v0.244.0). `app_deployed` used to fire beside the 202 that merely ACCEPTED a // deploy, so an install interrupted five seconds later stood on the timeline as a completed one // — measured 2026-09-16 on the drill box with mealie, which ended `not_deployed`. The accept-time @@ -2079,8 +2079,8 @@ var allowedEventTypes = map[string]bool{ // // Both are customer-tier like `app_deployed` itself (NOT in operatorOnlyEvents): a household that // pressed „Telepítés" is the party who wants to know it did not finish. - "app_deploy_started": true, - "app_deploy_failed": true, + "app_deploy_started": true, + "app_deploy_failed": true, "app_removed": true, "app_start_failed": true, // controller fix-3 (CAMPAIGN-3): a deployed app is not running "disaster_recovery_started": true, @@ -2152,11 +2152,18 @@ func (h *Handler) handleEvent(w http.ResponseWriter, r *http.Request) { } var payload struct { - CustomerID string `json:"customer_id"` - EventType string `json:"event_type"` - Severity string `json:"severity"` - Message string `json:"message"` - Details json.RawMessage `json:"details"` + CustomerID string `json:"customer_id"` + EventType string `json:"event_type"` + Severity string `json:"severity"` + Message string `json:"message"` + // MessageCustomer (controller v0.256.0, R-558) — the SAME sentence as Message, written in + // the household's language. Optional forever: an older controller sends none, and a box + // that never will (a parked one) must keep working exactly as it does today. + // + // It is used ONLY to render the household's e-mail. Message stays the Hungarian one that is + // stored, logged and mailed to the operator, so nothing an operator reads moves. + MessageCustomer string `json:"message_customer"` + Details json.RawMessage `json:"details"` } if err := json.Unmarshal(body, &payload); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) @@ -2208,6 +2215,11 @@ func (h *Handler) handleEvent(w http.ResponseWriter, r *http.Request) { payload.Severity = "info" } + // NO SEPARATE LENGTH CAP, and that is deliberate rather than an omission. `message` has never + // had one either: both are bounded by the 1 MB LimitReader on this request body, which is the + // only cap that has ever existed here. Adding one to the new field alone would make the two + // sentences behave differently for no measured reason — and a byte-count truncation would cut a + // UTF-8 sequence in half, turning a long Hungarian sentence into mojibake in a customer's inbox. // Store details as JSON string detailsStr := "{}" if len(payload.Details) > 0 && string(payload.Details) != "null" { @@ -2225,7 +2237,8 @@ func (h *Handler) handleEvent(w http.ResponseWriter, r *http.Request) { // Dispatch notifications (non-blocking) if h.dispatcher != nil { - go h.dispatcher.ProcessEvent(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, detailsStr, "controller") + go h.dispatcher.ProcessBoxEvent(payload.CustomerID, payload.EventType, payload.Severity, + payload.Message, payload.MessageCustomer, detailsStr, "controller") } w.Header().Set("Content-Type", "application/json") diff --git a/hub/internal/claim/naming_test.go b/hub/internal/claim/naming_test.go index 83621590..8f449950 100644 --- a/hub/internal/claim/naming_test.go +++ b/hub/internal/claim/naming_test.go @@ -34,7 +34,7 @@ const retiredName = "Visszaállító kód" // secrets in one message is how a customer comes to believe they are the same thing. func TestFormatClaimEmail_OneNamePerSecret(t *testing.T) { for _, kind := range []EmailKind{EmailClaim, EmailReset, EmailReenroll} { - subject, body := notify.FormatClaimEmail(string(kind), "c1", "example.hu", "alma-korte-szilva") + subject, body := notify.FormatClaimEmail("hu", string(kind), "c1", "example.hu", "alma-korte-szilva") whole := subject + "\n" + body if !strings.Contains(body, "Beállító kód: alma-korte-szilva") { @@ -57,12 +57,12 @@ func TestFormatClaimEmail_OneNamePerSecret(t *testing.T) { // Sending a re-enrolled customer to „Elfelejtett jelszó" names a route that is not on their screen. // That was the live defect, and this is the test that would have caught it. func TestFormatClaimEmail_NamesThePageTheMachineShows(t *testing.T) { - _, resetBody := notify.FormatClaimEmail(string(EmailReset), "c1", "example.hu", "a-b-c") + _, resetBody := notify.FormatClaimEmail("hu", string(EmailReset), "c1", "example.hu", "a-b-c") if !strings.Contains(resetBody, `"Elfelejtett jelszó"`) { t.Errorf("the forgot-password mail should name the page that IS on that customer's screen:\n%s", resetBody) } - _, reBody := notify.FormatClaimEmail(string(EmailReenroll), "c1", "example.hu", "a-b-c") + _, reBody := notify.FormatClaimEmail("hu", string(EmailReenroll), "c1", "example.hu", "a-b-c") if strings.Contains(reBody, "Elfelejtett jelszó") { t.Errorf("a REBUILT box serves no login page, so it has no „Elfelejtett jelszó” link:\n%s", reBody) } @@ -77,7 +77,7 @@ func TestFormatClaimEmail_NamesThePageTheMachineShows(t *testing.T) { // true. Guarding the CLAIM rather than one phrasing of it: any sentence that says the backups are // unaffected would have to say so with one of these stems. func TestFormatClaimEmail_ReenrollPromisesNothingAboutTheData(t *testing.T) { - _, body := notify.FormatClaimEmail(string(EmailReenroll), "c1", "example.hu", "a-b-c") + _, body := notify.FormatClaimEmail("hu", string(EmailReenroll), "c1", "example.hu", "a-b-c") for _, claimWord := range []string{"mentés", "biztonsági", "alkalmazás", "adataid", "visszaállíthat", "visszaszerezhet"} { if strings.Contains(body, claimWord) { t.Errorf("the re-enrol mail must not talk about data or backups (found %q):\n%s", claimWord, body) @@ -90,7 +90,7 @@ func TestFormatClaimEmail_ReenrollPromisesNothingAboutTheData(t *testing.T) { // adding a kind on the engine side and forgetting the template: nothing panics, nothing errors, and // the wrong-but-harmless mail goes out. Pinning it means a future reader knows it was chosen. func TestFormatClaimEmail_UnknownKindFallsBackToSetup(t *testing.T) { - subject, body := notify.FormatClaimEmail("no-such-kind", "c1", "example.hu", "a-b-c") + subject, body := notify.FormatClaimEmail("hu", "no-such-kind", "c1", "example.hu", "a-b-c") if !strings.Contains(subject, "Elindult a Felhom szervered") { t.Errorf("unknown kind should fall back to the first-setup mail, got subject %q", subject) } diff --git a/hub/internal/configgen/configgen.go b/hub/internal/configgen/configgen.go index c316841e..24f36742 100644 --- a/hub/internal/configgen/configgen.go +++ b/hub/internal/configgen/configgen.go @@ -47,6 +47,10 @@ func Generate(templateYAML string, cfg *store.CustomerConfig, claimState *store. setNested(base, []string{"customer", "name"}, cfg.CustomerName) setNested(base, []string{"customer", "domain"}, cfg.Domain) setNested(base, []string{"customer", "email"}, cfg.Email) + // v0.118.0 (R-558): the language the box STARTS in. The controller uses it only when the + // household has not chosen one on the dashboard — an explicit choice on the box always wins, and + // is never overwritten by a config pull. + setNested(base, []string{"customer", "language"}, cfg.Language) setNested(base, []string{"hub", "enabled"}, true) setNested(base, []string{"hub", "url"}, "https://hub.felhom.eu") diff --git a/hub/internal/configgen/language_test.go b/hub/internal/configgen/language_test.go new file mode 100644 index 00000000..84a1ee38 --- /dev/null +++ b/hub/internal/configgen/language_test.go @@ -0,0 +1,93 @@ +package configgen + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// S4/S6 (R-558) — `customer.language` reaches controller.yaml, and it is the ONLY thing that moved. +// +// The controller reads it to decide what language a box STARTS in, before anyone has touched the +// dashboard switch. It has to arrive, and nothing else may arrive with it: a config pull restarts +// the box, so an unintended second change would ride out to the whole fleet on the same wire. +func TestGenerate_CustomerLanguage(t *testing.T) { + const tmpl = "customer:\n id: \"\"\n name: \"\"\nlogging:\n level: info\n" + + base := &store.CustomerConfig{CustomerID: "c1", CustomerName: "Acme", ConfigJSON: "{}"} + withEN := &store.CustomerConfig{CustomerID: "c1", CustomerName: "Acme", ConfigJSON: "{}", Language: "en"} + + huYAML, err := Generate(tmpl, base, nil) + if err != nil { + t.Fatal(err) + } + enYAML, err := Generate(tmpl, withEN, nil) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(enYAML, "language: en") { + t.Fatalf("customer.language did not reach controller.yaml:\n%s", enYAML) + } + + // THE DIFF IS EXACTLY ONE LINE. Compared line by line after dropping the fields that are + // regenerated on every call — a session secret and a timestamp differ between any two renders, + // so counting them would make this test pass no matter what else changed. + huLines := stableLines(huYAML) + enLines := stableLines(enYAML) + var onlyInEN, onlyInHU []string + for _, l := range enLines { + if !contains(huLines, l) { + onlyInEN = append(onlyInEN, l) + } + } + for _, l := range huLines { + if !contains(enLines, l) { + onlyInHU = append(onlyInHU, l) + } + } + if len(onlyInEN) != 1 || !strings.Contains(onlyInEN[0], "language: en") { + t.Errorf("the English config differs from the Hungarian one by %d line(s), want exactly the "+ + "language line: %v", len(onlyInEN), onlyInEN) + } + if len(onlyInHU) != 1 || !strings.Contains(onlyInHU[0], "language:") { + t.Errorf("the Hungarian config has %d line(s) the English one lacks, want exactly the "+ + "language line: %v", len(onlyInHU), onlyInHU) + } +} + +// An empty creation language still writes a line — the box is told "Hungarian" explicitly rather +// than being left to guess from an absent key. +func TestGenerate_EmptyLanguageStillWritesTheKey(t *testing.T) { + const tmpl = "customer:\n id: \"\"\n" + y, err := Generate(tmpl, &store.CustomerConfig{CustomerID: "c1", ConfigJSON: "{}"}, nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(y, "language:") { + t.Errorf("no language key at all — the box cannot tell a default from a missing field:\n%s", y) + } +} + +// stableLines drops the lines that are regenerated on every call, so two renders are comparable. +func stableLines(y string) []string { + var out []string + for _, l := range strings.Split(y, "\n") { + t := strings.TrimSpace(l) + if t == "" || strings.HasPrefix(t, "session_secret:") || strings.HasPrefix(t, "generated_at:") { + continue + } + out = append(out, l) + } + return out +} + +func contains(ls []string, want string) bool { + for _, l := range ls { + if l == want { + return true + } + } + return false +} diff --git a/hub/internal/i18n/i18n.go b/hub/internal/i18n/i18n.go new file mode 100644 index 00000000..d5ae0038 --- /dev/null +++ b/hub/internal/i18n/i18n.go @@ -0,0 +1,200 @@ +// Package i18n is the hub's customer-facing message bundle: one flat key → text map per language, +// embedded in the binary, Hungarian first and authoritative. +// +// Design: felhom.eu/documentation/architecture/10-localisation.md, slice 3 (R-558). +// +// THIS BUNDLE IS FOR THE HOUSEHOLD, NOT THE OPERATOR. Everything the operator reads — the hub's own +// pages, its logs, and FormatOperatorEmail — stays exactly as it is, in the language it is already +// in. A key that would change an operator surface does not belong here. +// +// Three rules, the same three the controller's bundle follows: +// +// 1. HUNGARIAN IS THE SOURCE. hu.json holds every key. A key missing from hu is a programming +// error and Msg says so loudly rather than rendering a blank line into a customer's mail. +// 2. FALLBACK IS HUNGARIAN, AND IT IS COUNTED. A key absent from en.json renders the Hungarian and +// is reported by MissingKeys, which the gate holds at zero. A household never sees a key name +// or an empty space where a sentence should be. +// 3. THE BOX'S OWN SENTENCES ARE NOT IN HERE. Roughly a third of the customer mails carry a +// sentence composed on the box ("the remote target is not set"). The hub cannot translate those +// and does not try: the box sends the household's version beside the Hungarian one +// (`message_customer`), and the hub picks. See FormatCustomerEmail. +// +// Unlike the controller's bundle there is no template expansion here: a mail is plain text, so the +// contextual-escaping problem that shaped the controller's design does not exist. Msg is a plain +// lookup and Msgf formats with the Go verbs the Hungarian already uses. +package i18n + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "sort" + "strings" + "sync" +) + +//go:embed locales/*.json +var localeFS embed.FS + +// Default is the language of a household that never chose one — and the only language every key has. +const Default = "hu" + +// Supported lists the languages a household may be written to in, Default first. +var Supported = []string{"hu", "en"} + +// Bundle holds every language's messages. +type Bundle struct { + msgs map[string]map[string]string +} + +// Normalize maps any input to a supported language; everything unknown is Default. +// +// This is the ONLY gate between a reported language and a rendered mail. A box that reports +// garbage, an old box that reports nothing, and a box that reports "EN " all land on a real +// language rather than on an empty lookup. +func Normalize(lang string) string { + l := strings.ToLower(strings.TrimSpace(lang)) + for _, s := range Supported { + if l == s { + return s + } + } + return Default +} + +// IsSupported reports whether lang is exactly one of Supported. Used where an input must be +// REFUSED rather than coerced — the customer-creation form, which should not silently store `hu` +// for an operator who typed something else. +func IsSupported(lang string) bool { + for _, s := range Supported { + if lang == s { + return true + } + } + return false +} + +// Load reads the embedded bundles. +func Load() (*Bundle, error) { return loadFrom(localeFS) } + +func loadFrom(fsys fs.FS) (*Bundle, error) { + entries, err := fs.Glob(fsys, "locales/*.json") + if err != nil { + return nil, err + } + b := &Bundle{msgs: make(map[string]map[string]string, len(entries))} + for _, e := range entries { + raw, err := fs.ReadFile(fsys, e) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", e, err) + } + var m map[string]string + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("parsing %s: %w", e, err) + } + lang := strings.TrimSuffix(strings.TrimPrefix(e, "locales/"), ".json") + b.msgs[lang] = m + } + if _, ok := b.msgs[Default]; !ok { + return nil, fmt.Errorf("i18n: no %s.json — Hungarian is the source and must exist", Default) + } + return b, nil +} + +var ( + sharedOnce sync.Once + shared *Bundle + sharedErr error +) + +// Shared returns the process-wide bundle, loaded once. +// +// It panics if the embedded bundle cannot be parsed. That is deliberate and it is a BUILD-TIME +// class of fault, not a runtime one: the JSON is compiled into the binary, so a parse failure means +// every mail this binary will ever send is broken. Failing at the first send, loudly, beats sending +// a thousand mails with blank bodies. +func Shared() *Bundle { + sharedOnce.Do(func() { shared, sharedErr = Load() }) + if sharedErr != nil { + panic("i18n: embedded bundle is unloadable: " + sharedErr.Error()) + } + return shared +} + +// Msg returns the message for key in lang, falling back to Hungarian. +// +// A key that exists in NO language returns a visible marker rather than an empty string. An empty +// string in a mail body is invisible — the customer gets a mail with a hole in it and nobody ever +// learns. The marker is ugly on purpose. +func (b *Bundle) Msg(lang, key string) string { + if b == nil { + return "!" + key + "!" + } + if m, ok := b.msgs[Normalize(lang)]; ok { + if v, ok := m[key]; ok && v != "" { + return v + } + } + if m, ok := b.msgs[Default]; ok { + if v, ok := m[key]; ok && v != "" { + return v + } + } + return "!" + key + "!" +} + +// Msgf formats the message for key in lang with args. +// +// Word order is the reason this exists rather than string concatenation at the call site: English +// reorders what Hungarian does not, and a translation reorders with Go's explicit argument indexes +// (`%[2]s`) inside its own value. The Hungarian value is then free to stay exactly the format +// string it always was, which is what makes the goldens hold. +func (b *Bundle) Msgf(lang, key string, args ...any) string { + return fmt.Sprintf(b.Msg(lang, key), args...) +} + +// Has reports whether lang carries key in its own file (no fallback). +func (b *Bundle) Has(lang, key string) bool { + if b == nil { + return false + } + m, ok := b.msgs[lang] + if !ok { + return false + } + v, ok := m[key] + return ok && v != "" +} + +// Keys lists lang's own keys, sorted. +func (b *Bundle) Keys(lang string) []string { + if b == nil { + return nil + } + m := b.msgs[lang] + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// MissingKeys lists the Hungarian keys that lang does not carry, sorted. +// +// The gate holds this at zero for every supported language. It is the difference between "we +// translated it" and "we believe we translated it". +func (b *Bundle) MissingKeys(lang string) []string { + if b == nil { + return nil + } + var out []string + for _, k := range b.Keys(Default) { + if !b.Has(lang, k) { + out = append(out, k) + } + } + sort.Strings(out) + return out +} diff --git a/hub/internal/i18n/i18n_test.go b/hub/internal/i18n/i18n_test.go new file mode 100644 index 00000000..4b27fa6e --- /dev/null +++ b/hub/internal/i18n/i18n_test.go @@ -0,0 +1,149 @@ +package i18n + +import ( + "regexp" + "strings" + "testing" +) + +// EnMissingCeiling is the number of Hungarian keys allowed to be missing from English. It is zero +// and it stays zero: a fallback that renders Hungarian into an English household's mail is the +// failure this bundle exists to prevent, so it must never be reachable in a shipped build. +const EnMissingCeiling = 0 + +func TestEveryLanguageCarriesEveryKey(t *testing.T) { + b := Shared() + for _, lang := range Supported { + missing := b.MissingKeys(lang) + if lang == Default && len(missing) != 0 { + t.Fatalf("%s is the source and cannot be missing keys: %v", lang, missing) + } + if len(missing) > EnMissingCeiling { + t.Errorf("%s is missing %d key(s) (ceiling %d): %v", + lang, len(missing), EnMissingCeiling, missing) + } + } +} + +// verbRe matches a Go format verb, including an explicit argument index (%[2]s). +var verbRe = regexp.MustCompile(`%(\[\d+\])?[-+# 0]*\d*(\.\d+)?[a-zA-Z%]`) + +// TestFormatVerbsMatchAcrossLanguages is the test that stops a translation corrupting a mail. +// +// Msgf formats the TRANSLATED value with the arguments the Hungarian call site passes. A translation +// that drops a `%s` silently loses a customer's server name; one that adds a `%s` renders +// `%!s(MISSING)` into a real e-mail. Neither is visible to a reviewer reading the JSON, and neither +// would fail any other test here — the mail still sends. +// +// Explicit argument indexes (`%[2]s`) are how English reorders what Hungarian does not, so the check +// is on the MULTISET of verbs, not on their order. +func TestFormatVerbsMatchAcrossLanguages(t *testing.T) { + b := Shared() + for _, key := range b.Keys(Default) { + huVerbs := verbCounts(b.Msg(Default, key)) + for _, lang := range Supported { + if lang == Default || !b.Has(lang, key) { + continue + } + got := verbCounts(b.Msg(lang, key)) + if len(got) != len(huVerbs) { + t.Errorf("%s/%s has %d format verb(s), hu has %d — the mail would render wrong", + lang, key, len(got), len(huVerbs)) + continue + } + for verb, n := range huVerbs { + if got[verb] != n { + t.Errorf("%s/%s uses %q %d time(s), hu uses it %d time(s)", + lang, key, verb, got[verb], n) + } + } + } + } +} + +// verbCounts counts each format verb, ignoring the escaped %% which formats nothing. +func verbCounts(s string) map[string]int { + out := map[string]int{} + for _, m := range verbRe.FindAllString(s, -1) { + if m == "%%" { + continue + } + // An indexed verb counts as its bare form: %[2]s and %s consume the same argument type. + if i := strings.Index(m, "]"); i >= 0 { + m = "%" + m[i+1:] + } + out[m]++ + } + return out +} + +func TestNormalizeAndIsSupported(t *testing.T) { + for in, want := range map[string]string{ + "hu": "hu", "en": "en", "EN": "en", " en ": "en", + "": "hu", "de": "hu", "en-GB": "hu", "garbage": "hu", + } { + if got := Normalize(in); got != want { + t.Errorf("Normalize(%q) = %q, want %q", in, got, want) + } + } + // IsSupported REFUSES where Normalize coerces — that difference is the whole reason both exist. + for _, bad := range []string{"", "EN", " en ", "de", "en-GB"} { + if IsSupported(bad) { + t.Errorf("IsSupported(%q) is true — a bad input would be stored as a real language", bad) + } + } + for _, good := range []string{"hu", "en"} { + if !IsSupported(good) { + t.Errorf("IsSupported(%q) is false", good) + } + } +} + +// A key that exists nowhere must be LOUD, never blank: an empty string in a mail body is a hole +// nobody notices. +func TestUnknownKeyIsVisible(t *testing.T) { + got := Shared().Msg("en", "mail.event.no_such_thing_exists") + if got == "" { + t.Fatal("an unknown key rendered as an empty string — the mail would have a silent hole") + } + if !strings.Contains(got, "no_such_thing_exists") { + t.Errorf("an unknown key rendered as %q — it does not name the key", got) + } +} + +// An English key falls back to Hungarian rather than to nothing. Proven with a key deliberately +// absent from en.json would be circular (there are none), so it is proven on the mechanism: a +// language that carries no file at all. +func TestFallbackIsHungarianNotBlank(t *testing.T) { + b := Shared() + if got := b.Msg("de", "mail.event.backup_completed"); got != b.Msg(Default, "mail.event.backup_completed") { + t.Errorf("an unsupported language did not fall back to Hungarian: %q", got) + } +} + +// The English bundle must not be a copy of the Hungarian one. A translation pass that silently +// copied the source would pass every other test in this file. +func TestEnglishIsActuallyTranslated(t *testing.T) { + // The keys that are IDENTICAL IN BOTH LANGUAGES ON PURPOSE, each with the reason. An allowlist + // rather than a count: a count lets the next untranslated key hide inside the budget, and the + // question "why is this one the same?" then has no answer written down anywhere. + sameOnPurpose := map[string]string{ + "mail.customer.subject": `"[Felhom] %s: %s" — a format with no words in it; both halves are themselves translated`, + "bind.placeholder.pairing": `"ABC-234" — a sample of a code the box prints, not prose`, + } + + b := Shared() + for _, key := range b.Keys(Default) { + hu, en := b.Msg(Default, key), b.Msg("en", key) + if hu != en { + if _, listed := sameOnPurpose[key]; listed { + t.Errorf("%s is allowlisted as identical but the two languages now differ — "+ + "remove it from sameOnPurpose", key) + } + continue + } + if _, ok := sameOnPurpose[key]; !ok { + t.Errorf("%s is identical in hu and en — untranslated text would ship: %q", key, hu) + } + } +} diff --git a/hub/internal/i18n/locales/en.json b/hub/internal/i18n/locales/en.json new file mode 100644 index 00000000..63c4c052 --- /dev/null +++ b/hub/internal/i18n/locales/en.json @@ -0,0 +1,81 @@ +{ + "mail.event.claim_lockout": "Too many wrong setup codes were entered — the setup page is locked for 15 minutes. If that was not you, tell your operator.", + "mail.event.backup_completed": "The backup finished successfully.", + "mail.event.backup_failed": "The backup failed. Check your system.", + "mail.event.db_dump_completed": "The database backup finished successfully.", + "mail.event.db_dump_failed": "The database backup failed.", + "mail.event.backup_integrity_ok": "The backup integrity check passed.", + "mail.event.backup_integrity_failed": "The backup integrity check found a problem.", + "mail.event.crossdrive_completed": "The second copy of your backup finished successfully.", + "mail.event.crossdrive_failed": "The second copy of your backup failed.", + "mail.event.offbox_repo_orphaned": "The remote backup store is orphaned: the backups inside it were made with an earlier key that is no longer available (usually after a reinstall). No new backup is made until the store is reset — open the Remote backup page.", + "mail.event.offbox_repo_reset": "The remote backup store has been reset: the old history was set aside (not deleted), and an empty new store was created with the current key.", + "mail.event.host_disk_warning": "The main disk of your home server's base system (the Proxmox host) is over 90% full — free up some space (old backups, for example).", + "mail.event.host_disk_critical": "The main disk of your home server's base system is critically full (95%+). Act now, or the system may start to fail.", + "mail.event.storage_fill_warning": "A drive on your home server is over 90% full — free up some space before it fills.", + "mail.event.storage_fill_critical": "A drive on your home server is critically full (95%+). Backups and writes to it may fail. Act now.", + "mail.event.storage_disconnected": "A drive was disconnected — backups may pause.", + "mail.event.storage_reconnected": "The drive is connected again.", + "mail.event.backup_target_absent": "The drive your whole-system backup is written to is not available. Until you reconnect it, no whole-system backup is made.", + "mail.event.backup_target_restored": "The drive your whole-system backup is written to is available again — backups continue.", + "mail.event.node_stale": "Your server has not sent a report recently.", + "mail.event.node_down": "Your server cannot be reached.", + "mail.event.node_recovered": "Your server can be reached again.", + "mail.event.host_recovered": "Your home server's base system (the Proxmox host) can be reached again.", + "mail.event.health_degraded": "Your system's health has got worse.", + "mail.event.health_critical": "Your system's health is critical.", + "mail.event.health_recovered": "Your system's health is back to normal.", + "mail.event.controller_started": "The controller has started.", + "mail.event.controller_updated": "The controller has been updated.", + "mail.event.expected_backup_missed": "Today's backup did not finish by its deadline.", + "mail.event.expected_dbdump_missed": "Today's database backup did not finish by its deadline.", + "mail.event.app_deployed": "An app was installed.", + "mail.event.app_removed": "An app was removed.", + "mail.event.app_start_failed": "An installed app is not running — check the system monitor.", + "mail.event.restore_interrupted": "A restore was interrupted because the box restarted. Start it again on the Restore page of your dashboard.", + "mail.event.app_deploy_started": "An app has started installing.", + "mail.event.app_deploy_failed": "An app did not finish installing.", + "mail.event.disaster_recovery_started": "Disaster recovery has started.", + "mail.event.disaster_recovery_completed": "Disaster recovery has finished.", + "mail.event.test": "This is a test notification.", + "mail.severity.info": "Information", + "mail.severity.warning": "Warning", + "mail.severity.error": "Error", + "mail.severity.critical": "Critical error", + "mail.customer.subject": "[Felhom] %s: %s", + "mail.customer.body": "Dear Customer,\n\nYour Felhom system sent the following notification:\n\n%s\n\nDetails:\n- Server: %s\n- Time: %s\n- Level: %s\n- Type: %s", + "mail.customer.line.message": "\n- Message: %s", + "mail.customer.line.note": "\n- Note: %s", + "mail.customer.signoff": "\n\nIf you have any questions, contact your operator.\n\nBest regards,\nFelhom.eu monitoring", + "mail.claim.reset.subject": "[Felhom] Setup code to reset your password", + "mail.claim.reset.body": "Dear Customer,\n\nYou asked to reset the password for your Felhom dashboard.\n\nSetup code: %s\n\nThe code is valid for 72 hours and can be used once. Enter it on the\n\"Forgotten password\" page of your dashboard, then choose a new password:\n\n%s\n\nIf you did not ask for this, ignore it — your current password is unchanged.\n\nBest regards,\nFelhom.eu", + "mail.claim.reenroll.subject": "[Felhom] New setup code — your server was reinstalled", + "mail.claim.reenroll.body": "Dear Customer,\n\nYour Felhom server was reinstalled, so the sign-in to your dashboard has to be\nset up again. Your earlier password is no longer valid.\n\nSetup code: %s\n\nThe code is valid for 72 hours and can be used once. Open your dashboard — the\n\"Set up the server\" page will greet you — enter the code, then choose a new\npassword:\n\n%s\n\nIf you did not reinstall your server, contact your operator.\n\nBest regards,\nFelhom.eu", + "mail.claim.claimed.subject": "[Felhom] Your dashboard is now password protected", + "mail.claim.claimed.body": "Dear Customer,\n\nThe setup of your Felhom dashboard is complete — your dashboard is now\npassword protected. You can sign in with the password you chose:\n\n%s\n\nIf you did not do this setup, contact your operator immediately.\n\nBest regards,\nFelhom.eu", + "mail.claim.claim.subject": "[Felhom] Your Felhom server is up — setup code", + "mail.claim.claim.body": "Dear Customer,\n\nYour Felhom server is up. To use the dashboard for the first time, enter the\nsetup code below, then choose your own password:\n\nSetup code: %s\n\nThe code is valid for 72 hours and can be used once. You can reach the\ndashboard here:\n\n%s\n\nIf it did not reach you in time, the dashboard's \"Request a new code\" button\ngets you a fresh one — it always arrives at this e-mail address.\n\nBest regards,\nFelhom.eu", + "mail.selfbind.subject": "[Felhom] Link your Felhom box to your account", + "mail.selfbind.body": "Dear Customer,\n\nYour Felhom box is ready and waiting to be linked. You can link it to your\naccount yourself using the link below — no sign-in needed:\n\n%s\n\nAfter you open the link you will need to enter two things:\n\n 1. The pairing code, shown on the box's own screen (the monitor).\n 2. Your owner passphrase (the 5-word phrase) that you received from your\n Felhom operator — in person or by telephone, never by e-mail. It proves\n the account is yours.\n\nThe link is valid for 7 days. For safety it locks after 5 failed attempts —\nif that happens, contact support.\n\nIf you did not ask for this, ignore this e-mail.\n\nBest regards,\nFelhom.eu", + "bind.title": "Felhom — Link your box", + "bind.heading": "Link your Felhom box", + "bind.lead": "Link the Felhom box you have just installed to your account. Enter the pairing code shown on the box's screen, and your owner passphrase.", + "bind.failed": "Those details are not right. Check the pairing code and the owner passphrase, then try again.", + "bind.label.pairing": "Pairing code", + "bind.placeholder.pairing": "ABC-234", + "bind.hint.pairing": "It appears on the box's monitor, after the install.", + "bind.label.passphrase": "Owner passphrase", + "bind.placeholder.passphrase": "five words, with hyphens or spaces", + "bind.hint.passphrase": "The five-word phrase you received during setup. It proves the account is yours.", + "bind.submit": "Link the box", + "bind.note": "For safety the link locks after 5 failed attempts. If that happens, contact support.", + "bind.success.lead": "Linked successfully.", + "bind.success.body": "The box carries on installing within about a minute. You can close this page — the setup finishes in the background, and your dashboard will be available soon.", + "bind.consumed.lead": "This link has already been used.", + "bind.consumed.body": "The box has been linked. If you think that is a mistake, contact support.", + "bind.locked.lead": "This link is locked.", + "bind.locked.body": "There were too many failed attempts. For safety the link has locked — contact support to link the box.", + "bind.invalid.lead": "This link is invalid or has expired.", + "bind.invalid.body": "The link is valid for 7 days. If it has expired, ask support for a new one, or your operator can do the linking.", + "bind.htmllang": "en" +} diff --git a/hub/internal/i18n/locales/hu.json b/hub/internal/i18n/locales/hu.json new file mode 100644 index 00000000..60cc4d4d --- /dev/null +++ b/hub/internal/i18n/locales/hu.json @@ -0,0 +1,81 @@ +{ + "mail.event.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.", + "mail.event.backup_completed": "A biztonsági mentés sikeresen elkészült.", + "mail.event.backup_failed": "A biztonsági mentés sikertelen! Kérjük, ellenőrizd a rendszert.", + "mail.event.db_dump_completed": "Az adatbázis mentés sikeresen elkészült.", + "mail.event.db_dump_failed": "Az adatbázis mentés sikertelen!", + "mail.event.backup_integrity_ok": "A mentés integritás ellenőrzés sikeres.", + "mail.event.backup_integrity_failed": "A mentés integritás ellenőrzés hibát talált!", + "mail.event.crossdrive_completed": "A másodlagos mentés sikeresen elkészült.", + "mail.event.crossdrive_failed": "A másodlagos mentés sikertelen!", + "mail.event.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.", + "mail.event.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.", + "mail.event.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).", + "mail.event.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!", + "mail.event.storage_fill_warning": "Egy tároló a házszerveren 90% felett telt — kérjük, szabadíts fel helyet, mielőtt megtelik.", + "mail.event.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!", + "mail.event.storage_disconnected": "Egy meghajtó leválasztva — a mentések szünetelhetnek.", + "mail.event.storage_reconnected": "A meghajtó újra csatlakoztatva.", + "mail.event.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.", + "mail.event.backup_target_restored": "A rendszermentés meghajtója újra elérhető — a mentés folytatódik.", + "mail.event.node_stale": "A szerver nem küldött jelentést az elmúlt időszakban.", + "mail.event.node_down": "A szerver nem elérhető!", + "mail.event.node_recovered": "A szerver újra elérhető.", + "mail.event.host_recovered": "A házszerver alaprendszere (Proxmox-gazda) újra elérhető.", + "mail.event.health_degraded": "A rendszer állapota romlott.", + "mail.event.health_critical": "A rendszer állapota kritikus!", + "mail.event.health_recovered": "A rendszer állapota helyreállt.", + "mail.event.controller_started": "A vezérlő elindult.", + "mail.event.controller_updated": "A vezérlő frissítve lett.", + "mail.event.expected_backup_missed": "A mai biztonsági mentés nem készült el a határidőig!", + "mail.event.expected_dbdump_missed": "A mai adatbázis mentés nem készült el a határidőig!", + "mail.event.app_deployed": "Alkalmazás telepítve.", + "mail.event.app_removed": "Alkalmazás eltávolítva.", + "mail.event.app_start_failed": "Egy telepített alkalmazás nem fut — ellenőrizze a rendszermonitort.", + "mail.event.restore_interrupted": "Egy visszaállítás megszakadt, mert a doboz újraindult. Indítsd el újra a vezérlőpult Visszaállítás oldalán.", + "mail.event.app_deploy_started": "Alkalmazás telepítése elindult.", + "mail.event.app_deploy_failed": "Egy alkalmazás telepítése nem fejeződött be.", + "mail.event.disaster_recovery_started": "Katasztrófa helyreállítás elindítva.", + "mail.event.disaster_recovery_completed": "Katasztrófa helyreállítás befejezve.", + "mail.event.test": "Ez egy teszt értesítés.", + "mail.severity.info": "Információ", + "mail.severity.warning": "Figyelmeztetés", + "mail.severity.error": "Hiba", + "mail.severity.critical": "Kritikus hiba", + "mail.customer.subject": "[Felhom] %s: %s", + "mail.customer.body": "Kedves Ügyfél!\n\nA Felhom rendszered a következő értesítést küldte:\n\n%s\n\nRészletek:\n- Szerver: %s\n- Időpont: %s\n- Szint: %s\n- Típus: %s", + "mail.customer.line.message": "\n- Üzenet: %s", + "mail.customer.line.note": "\n- Megjegyzés: %s", + "mail.customer.signoff": "\n\nHa kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.\n\nÜdvözlettel,\nFelhom.eu monitoring", + "mail.claim.reset.subject": "[Felhom] Beállító kód a jelszavad visszaállításához", + "mail.claim.reset.body": "Kedves Ügyfél!\n\nJelszó-visszaállítást kértél a Felhom vezérlőpultodhoz.\n\nBeállító kód: %s\n\nA kód 72 óráig érvényes, és egyszer használható fel. Add meg a vezérlőpult\n\"Elfelejtett jelszó\" oldalán, majd válassz új jelszót:\n\n%s\n\nHa nem te kérted, hagyd figyelmen kívül — a jelenlegi jelszavad változatlan.\n\nÜdvözlettel,\nFelhom.eu", + "mail.claim.reenroll.subject": "[Felhom] Új beállító kód — újratelepült a szervered", + "mail.claim.reenroll.body": "Kedves Ügyfél!\n\nA Felhom szervered újratelepült, ezért a vezérlőpultod belépését újra be kell\nállítani. A korábbi jelszavad már nem érvényes.\n\nBeállító kód: %s\n\nA kód 72 óráig érvényes, és egyszer használható fel. Nyisd meg a vezérlőpultot —\n\"A szerver beállítása\" oldal fogad —, add meg a kódot, majd válassz új jelszót:\n\n%s\n\nHa nem te telepítetted újra a szervered, vedd fel a kapcsolatot az üzemeltetővel.\n\nÜdvözlettel,\nFelhom.eu", + "mail.claim.claimed.subject": "[Felhom] A vezérlőpultod mostantól jelszóval védett", + "mail.claim.claimed.body": "Kedves Ügyfél!\n\nA Felhom vezérlőpultod beállítása elkészült — a vezérlőpultod mostantól\njelszóval védett. A megadott jelszóval tudsz bejelentkezni:\n\n%s\n\nHa nem te végezted a beállítást, azonnal vedd fel a kapcsolatot az üzemeltetővel.\n\nÜdvözlettel,\nFelhom.eu", + "mail.claim.claim.subject": "[Felhom] Elindult a Felhom szervered — beállító kód", + "mail.claim.claim.body": "Kedves Ügyfél!\n\nElindult a Felhom szervered. A vezérlőpult első használatához add meg az\nalábbi beállító kódot, majd válassz saját jelszót:\n\nBeállító kód: %s\n\nA kód 72 óráig érvényes, és egyszer használható fel. A vezérlőpultot itt éred el:\n\n%s\n\nHa nem kaptad volna meg időben, a vezérlőpult \"Új kód kérése\" gombjával\nkérhetsz frisset — az mindig erre az e-mail címre érkezik.\n\nÜdvözlettel,\nFelhom.eu", + "mail.selfbind.subject": "[Felhom] Kösd össze a Felhom dobozodat", + "mail.selfbind.body": "Kedves Ügyfél!\n\nElkészült a Felhom dobozod, és készen áll az összekötésre. Az alábbi hivatkozáson\ntudod te magad összekötni a fiókoddal — nincs szükség bejelentkezésre:\n\n%s\n\nA hivatkozás megnyitása után két adatot kell megadnod:\n\n 1. A párosító kódot, amely a doboz képernyőjén (a monitoron) látható.\n 2. A tulajdonosi jelmondatodat (az 5 szóból álló kifejezést), amelyet a\n Felhom üzemeltetőjétől kaptál — személyesen vagy telefonon, e-mailben\n soha. Ez igazolja, hogy a fiók a tiéd.\n\nA hivatkozás 7 napig érvényes. Biztonsági okból 5 sikertelen próbálkozás után\nzárolódik — ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal.\n\nHa nem te kérted ezt, hagyd figyelmen kívül ezt az e-mailt.\n\nÜdvözlettel,\nFelhom.eu", + "bind.title": "Felhom — Doboz összekötése", + "bind.heading": "Felhom doboz összekötése", + "bind.lead": "Kösd össze a most telepített Felhom dobozodat a fiókoddal. Add meg a doboz képernyőjén látható párosító kódot és a tulajdonosi jelmondatodat.", + "bind.failed": "A megadott adatok nem megfelelőek. Ellenőrizd a párosító kódot és a tulajdonosi jelmondatot, majd próbáld újra.", + "bind.label.pairing": "Párosító kód", + "bind.placeholder.pairing": "ABC-234", + "bind.hint.pairing": "A doboz monitorán jelenik meg, a telepítés után.", + "bind.label.passphrase": "Tulajdonosi jelmondat", + "bind.placeholder.passphrase": "öt szó, kötőjellel vagy szóközzel", + "bind.hint.passphrase": "Az öt szóból álló kifejezés, amelyet a beállításkor kaptál. Ez igazolja, hogy a fiók a tiéd.", + "bind.submit": "Összekötés", + "bind.note": "Biztonsági okból 5 sikertelen próbálkozás után a hivatkozás zárolódik. Ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal.", + "bind.success.lead": "Sikeres összekötés.", + "bind.success.body": "A doboz kb. egy percen belül folytatja a telepítést. Ezt az oldalt bezárhatod — a beállítás a háttérben befejeződik, és a vezérlőpultod hamarosan elérhető lesz.", + "bind.consumed.lead": "Ez a hivatkozás már fel lett használva.", + "bind.consumed.body": "A doboz összekötése megtörtént. Ha úgy gondolod, hogy ez tévedés, vedd fel a kapcsolatot az ügyfélszolgálattal.", + "bind.locked.lead": "Ez a hivatkozás zárolva van.", + "bind.locked.body": "Túl sok sikertelen próbálkozás történt. Biztonsági okból a hivatkozás zárolódott — kérjük, vedd fel a kapcsolatot az ügyfélszolgálattal a doboz összekötéséhez.", + "bind.invalid.lead": "Ez a hivatkozás érvénytelen vagy lejárt.", + "bind.invalid.body": "A hivatkozás 7 napig érvényes. Ha lejárt, kérj újat az ügyfélszolgálattól, vagy az összekötést az üzemeltető is elvégezheti.", + "bind.htmllang": "hu" +} diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index d4f768d6..9f4e490f 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -107,7 +107,28 @@ func severityNotifies(severity string) bool { // ProcessEvent evaluates an event and sends notifications as appropriate. // Safe to call from goroutines. +// +// This is the HUB-GENERATED entry point: the hub composed `message` itself (a checker, a staleness +// sweep, an escrow decision), so there is no second sentence to carry and the customer mail is +// rendered from the bundle alone. func (d *Dispatcher) ProcessEvent(customerID, eventType, severity, message, detailsJSON, source string) { + d.processEvent(customerID, eventType, severity, message, "", detailsJSON, source) +} + +// ProcessBoxEvent is the entry point for an event a BOX sent (POST /api/v1/event). +// +// `messageCustomer` is the box's own sentence in the household's language, sent beside the Hungarian +// `message` by controller v0.256.0 and later (R-558). It is optional forever — an older box sends +// none and the mail is then exactly what it was. +// +// Kept SEPARATE from ProcessEvent rather than added as a parameter to it: 44 of the 45 call sites +// are hub-generated and have no such sentence, and widening all of them would have meant 44 edits +// whose only content is an empty string — churn that hides the one call site that matters. +func (d *Dispatcher) ProcessBoxEvent(customerID, eventType, severity, message, messageCustomer, detailsJSON, source string) { + d.processEvent(customerID, eventType, severity, message, messageCustomer, detailsJSON, source) +} + +func (d *Dispatcher) processEvent(customerID, eventType, severity, message, messageCustomer, detailsJSON, source string) { if d.resendAPIKey == "" { return } @@ -122,7 +143,7 @@ func (d *Dispatcher) ProcessEvent(customerID, eventType, severity, message, deta // branch: *_recovered stays severity "info" (semantics frozen), but is no longer silent. // Operator always hears both edges; the customer hears recovery iff they heard the down. if _, isRecovery := recoveredPairedDownTypes[eventType]; isRecovery { - d.processRecovery(customerID, eventType, severity, message, detailsJSON, source) + d.processRecovery(customerID, eventType, severity, message, messageCustomer, detailsJSON, source) return } @@ -167,7 +188,7 @@ func (d *Dispatcher) ProcessEvent(customerID, eventType, severity, message, deta d.processOperator(customerID, eventType, severity, message, detailsJSON, source) // Customer channel - d.processCustomer(customerID, eventType, severity, message, detailsJSON, source) + d.processCustomer(customerID, eventType, severity, message, messageCustomer, detailsJSON, source) } func (d *Dispatcher) sendTestEmail(customerID string) { @@ -219,7 +240,7 @@ Dashboard: https://hub.felhom.eu/customers/%s`, customerID, customerID) // - Customer leg: gated by the PAIRING rule, not enabled_events — "recovery notifies exactly // whoever the down notified." Evidence = a customer-channel status=sent row for the paired // stale/down set newer than the last customer-channel sent recovery of this type. -func (d *Dispatcher) processRecovery(customerID, eventType, severity, message, detailsJSON, source string) { +func (d *Dispatcher) processRecovery(customerID, eventType, severity, message, messageCustomer, detailsJSON, source string) { d.processOperator(customerID, eventType, severity, message, detailsJSON, source) if d.store.IsCustomerBlocked(customerID) { @@ -263,7 +284,8 @@ func (d *Dispatcher) processRecovery(customerID, eventType, severity, message, d d.custCooldowns[cooldownKey] = time.Now() d.mu.Unlock() - subject, body := FormatCustomerEmail(customerID, eventType, severity, message, detailsJSON) + subject, body := FormatCustomerEmail(d.store.CustomerLanguage(customerID), + customerID, eventType, severity, message, messageCustomer, detailsJSON) if err := d.sendEmailFn(prefs.Email, subject, body, priorityHeaders(severity)); err != nil { d.logger.Printf("[ERROR] Customer recovery email failed for %s/%s: %v", customerID, eventType, err) d.store.LogNotification(customerID, eventType, severity, message, "failed", err.Error(), "customer") @@ -609,7 +631,7 @@ var operatorOnlyEvents = map[string]bool{ // Read-only: the register itself stays unexported so nothing can widen it at runtime. func IsOperatorOnly(eventType string) bool { return operatorOnlyEvents[eventType] } -func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, detailsJSON, source string) { +func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, messageCustomer, detailsJSON, source string) { // R-97c: operator-tier events stop here, BEFORE prefs are consulted — the point is that no // customer configuration can opt in. Logged rather than dropped, so the skip is visible in // notification_log instead of looking like a delivery that never happened. @@ -652,7 +674,8 @@ func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, d d.custCooldowns[cooldownKey] = time.Now() d.mu.Unlock() - subject, body := FormatCustomerEmail(customerID, eventType, severity, message, detailsJSON) + subject, body := FormatCustomerEmail(d.store.CustomerLanguage(customerID), + customerID, eventType, severity, message, messageCustomer, detailsJSON) if err := d.sendEmailFn(prefs.Email, subject, body, priorityHeaders(severity)); err != nil { d.logger.Printf("[ERROR] Customer email failed for %s/%s: %v", customerID, eventType, err) @@ -718,7 +741,7 @@ func (d *Dispatcher) SendClaimEmail(kind, customerID, email, domain, code string d.logger.Printf("[ERROR] claim %s email for %s NOT sent: no Resend API key configured", kind, customerID) return fmt.Errorf("notify: no resend api key") } - subject, body := FormatClaimEmail(kind, customerID, domain, code) + subject, body := FormatClaimEmail(d.store.CustomerLanguage(customerID), kind, customerID, domain, code) eventType := "claim_" + kind if err := d.sendEmailFn(email, subject, body, nil); err != nil { d.logger.Printf("[ERROR] claim %s email to customer %s failed: %v", kind, customerID, err) @@ -740,7 +763,7 @@ func (d *Dispatcher) SendSelfBindEmail(customerID, email, link string) error { d.logger.Printf("[ERROR] self-bind link email for %s NOT sent: no Resend API key configured", customerID) return fmt.Errorf("notify: no resend api key") } - subject, body := FormatSelfBindEmail(customerID, link) + subject, body := FormatSelfBindEmail(d.store.CustomerLanguage(customerID), customerID, link) if err := d.sendEmailFn(email, subject, body, nil); err != nil { d.logger.Printf("[ERROR] self-bind link email to customer %s failed: %v", customerID, err) d.store.LogNotification(customerID, "selfbind_link", "info", subject, "failed", err.Error(), "customer") diff --git a/hub/internal/notify/dispatcher_recovery_test.go b/hub/internal/notify/dispatcher_recovery_test.go index ada31be3..532c8b60 100644 --- a/hub/internal/notify/dispatcher_recovery_test.go +++ b/hub/internal/notify/dispatcher_recovery_test.go @@ -127,9 +127,9 @@ func TestRecovery_FlapDamping(t *testing.T) { d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0)) sent := captureSeam(d) - d.ProcessEvent("c1", "node_down", "error", "down", "{}", "hub") // down #1: cust+op mailed - d.ProcessEvent("c1", "node_recovered", "info", "up", "{}", "hub") // recovery #1: cust+op mailed - d.ProcessEvent("c1", "node_down", "error", "down", "{}", "hub") // down #2 10min later: customer + d.ProcessEvent("c1", "node_down", "error", "down", "{}", "hub") // down #1: cust+op mailed + d.ProcessEvent("c1", "node_recovered", "info", "up", "{}", "hub") // recovery #1: cust+op mailed + d.ProcessEvent("c1", "node_down", "error", "down", "{}", "hub") // down #2 10min later: customer // cooldown suppresses (op too, 1h) // Clear the recovery CUSTOMER cooldown so the pairing gate — not the cooldown — decides #2: d.mu.Lock() diff --git a/hub/internal/notify/dispatcher_test.go b/hub/internal/notify/dispatcher_test.go index ef070368..d8c9b130 100644 --- a/hub/internal/notify/dispatcher_test.go +++ b/hub/internal/notify/dispatcher_test.go @@ -56,7 +56,12 @@ func TestProcessEvent_PoolBoxScopeOperatorOnly(t *testing.T) { d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0)) var mu sync.Mutex var sent []string - d.sendEmailFn = func(to, _, _ string, _ map[string]string) error { mu.Lock(); defer mu.Unlock(); sent = append(sent, to); return nil } + d.sendEmailFn = func(to, _, _ string, _ map[string]string) error { + mu.Lock() + defer mu.Unlock() + sent = append(sent, to) + return nil + } d.ProcessEvent("pool-box", "offsite_box_fill", "warning", "Offsite pool box 82% full", `{"scope":"pool-box"}`, "hub") if len(sent) != 1 || sent[0] != "op@felhom.eu" { @@ -71,7 +76,12 @@ func TestProcessEvent_PBSDRBoxScopeOperatorOnly(t *testing.T) { d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0)) var mu sync.Mutex var sent []string - d.sendEmailFn = func(to, _, _ string, _ map[string]string) error { mu.Lock(); defer mu.Unlock(); sent = append(sent, to); return nil } + d.sendEmailFn = func(to, _, _ string, _ map[string]string) error { + mu.Lock() + defer mu.Unlock() + sent = append(sent, to) + return nil + } d.ProcessEvent("pbsdr-box", "pbsdr_box_fill", "warning", "PBS DR datastore 82% full", `{"scope":"pbsdr-box"}`, "hub") if len(sent) != 1 || sent[0] != "op@felhom.eu" { @@ -85,7 +95,12 @@ func TestProcessEvent_CriticalRoutes(t *testing.T) { d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0)) var mu sync.Mutex var sent []string - d.sendEmailFn = func(to, _, _ string, _ map[string]string) error { mu.Lock(); defer mu.Unlock(); sent = append(sent, to); return nil } + d.sendEmailFn = func(to, _, _ string, _ map[string]string) error { + mu.Lock() + defer mu.Unlock() + sent = append(sent, to) + return nil + } d.ProcessEvent("c1", "host_disk_critical", "critical", "root full", "{}", "hub") if len(sent) != 1 || sent[0] != "op@felhom.eu" { diff --git a/hub/internal/notify/language_test.go b/hub/internal/notify/language_test.go new file mode 100644 index 00000000..20b412a1 --- /dev/null +++ b/hub/internal/notify/language_test.go @@ -0,0 +1,125 @@ +package notify + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/i18n" +) + +// Localisation slice 3 (R-558) — the behaviour the bundle exists to produce. +// +// The goldens next door prove WHAT each mail says. These prove WHICH ONE a household gets, and that +// the operator's copy never moves. + +// S3 — the box's own sentence. +// +// Roughly a third of the customer mails carry a sentence the BOX composed, naming a drive or an app +// or a number. The hub cannot translate one, so the box sends a second copy in the household's +// language. This is the test that the second copy is actually preferred — and that its absence +// leaves the mail exactly as it was, which is the state of every box in the fleet until the +// controller half ships. +func TestMessageCustomerPreferredForTheHousehold(t *testing.T) { + const hunSentence = "A /mnt/adat lemez 91%-ig megtelt (12,4 GB szabad)." + const engSentence = "The /mnt/adat disk is 91% full (12.4 GB free)." + + // disk_warning has NO bundle entry on purpose, so the box's sentence IS the mail. + subject, body := FormatCustomerEmail("en", "c1", "disk_warning", "warning", hunSentence, engSentence, "") + + if !strings.Contains(body, engSentence) { + t.Errorf("the English household's mail does not carry the English sentence:\n%s", body) + } + if strings.Contains(body, hunSentence) { + t.Errorf("the English household's mail still carries the Hungarian sentence:\n%s", body) + } + if !strings.Contains(subject, engSentence) { + t.Errorf("the subject is not the English sentence: %q", subject) + } + + // The OPERATOR's mail is built from `message` and is untouched by any of this. + opSubject, opBody := FormatOperatorEmail("c1", "disk_warning", "warning", hunSentence, "") + if !strings.Contains(opBody, hunSentence) { + t.Errorf("the operator's mail lost the Hungarian sentence:\n%s", opBody) + } + if strings.Contains(opBody, engSentence) || strings.Contains(opSubject, engSentence) { + t.Error("the English sentence leaked into the operator's mail") + } +} + +// S3, the other half: an old controller sends no second sentence, and the mail must be byte-for-byte +// what it is today. Compared against the RENDER WITH NO NEW FIELD AT ALL, not against a retyped +// expectation — a retyped one would pass even if both sides changed together. +func TestMessageCustomerAbsentIsExactlyToday(t *testing.T) { + const hunSentence = "A /mnt/adat lemez 91%-ig megtelt (12,4 GB szabad)." + + subject, body := FormatCustomerEmail(i18n.Default, "c1", "disk_warning", "warning", hunSentence, "", "") + if !strings.Contains(body, hunSentence) || !strings.Contains(subject, hunSentence) { + t.Fatalf("an absent message_customer lost the box's sentence:\n%s\n%s", subject, body) + } + + // And a Hungarian household that DOES get a second sentence gets the Hungarian one, because the + // box sends the same text twice. The point is that nothing about the hu path depends on it. + s2, b2 := FormatCustomerEmail(i18n.Default, "c1", "disk_warning", "warning", hunSentence, hunSentence, "") + if s2 != subject || b2 != body { + t.Error("a Hungarian household's mail changed when the box sent the sentence twice") + } +} + +// The entry still WINS over the box's sentence, in both languages — the property that keeps the +// dynamic-message types (disk_warning and friends) working, stated from the other direction. +func TestEntryWinsAndTheBoxSentenceBecomesItsOwnLine(t *testing.T) { + const hun = "A cel nincs beallitva." + const eng = "The target is not set." + + _, body := FormatCustomerEmail("en", "c1", "backup_failed", "error", hun, eng, "") + headline := i18n.Shared().Msg("en", "mail.event.backup_failed") + if !strings.Contains(body, headline) { + t.Errorf("the English entry is not the headline:\n%s", body) + } + if !strings.Contains(body, eng) { + t.Errorf("the box's English sentence is not on its own line:\n%s", body) + } + if strings.Contains(body, hun) { + t.Errorf("the Hungarian sentence reached an English household:\n%s", body) + } + // The line label is translated too — an English mail must not say "Üzenet". + if strings.Contains(body, "Üzenet") { + t.Errorf("the English mail carries the Hungarian field label:\n%s", body) + } +} + +// Every mail kind actually differs between the two languages. Without this, a key that silently fell +// back to Hungarian would pass every other test here: the mail sends, it just sends in the wrong +// language, which is the exact defect this slice exists to remove. +func TestEveryCustomerMailDiffersBetweenLanguages(t *testing.T) { + for _, c := range customerMailCases("hu") { + if strings.HasPrefix(c.name, "operator_") { + continue // the operator's mails are English already and are not localised + } + name := c.name + huSubject, huBody := c.render() + var enSubject, enBody string + for _, e := range customerMailCases("en") { + if e.name == name { + enSubject, enBody = e.render() + } + } + if huBody == enBody { + t.Errorf("%s renders identically in both languages — it is not localised", name) + } + if huSubject == enSubject && !strings.Contains(huSubject, "[Felhom] Információ") { + t.Errorf("%s has the same subject in both languages: %q", name, huSubject) + } + } +} + +// An unsupported or absent language renders Hungarian rather than a blank or a key. +func TestUnknownLanguageRendersHungarian(t *testing.T) { + want, wantBody := FormatCustomerEmail("hu", "c1", "backup_completed", "info", "", "", "") + for _, lang := range []string{"", "de", "en-GB", " "} { + got, gotBody := FormatCustomerEmail(lang, "c1", "backup_completed", "info", "", "", "") + if got != want || gotBody != wantBody { + t.Errorf("language %q did not fall back to Hungarian", lang) + } + } +} diff --git a/hub/internal/notify/mail_golden_test.go b/hub/internal/notify/mail_golden_test.go new file mode 100644 index 00000000..a5eb024a --- /dev/null +++ b/hub/internal/notify/mail_golden_test.go @@ -0,0 +1,230 @@ +package notify + +import ( + "flag" + "os" + "path/filepath" + "sort" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/i18n" + "time" +) + +// Mail goldens — the measurement localisation slice 3 rests on (R-558). +// +// Every customer-facing mail the hub can send is rendered here and compared against bytes captured +// from the code as it stood BEFORE any string moved (felhom.eu 20aafc3dec20, hub v0.117.0). The +// claim "the Hungarian mails are unchanged" is then a diff, not a reading. +// +// THE RULE THAT MAKES THEM WORTH HAVING: a golden is never regenerated to make a change pass. It is +// regenerated only for a block that has been declared and measured, and the declaration says which +// mail and why. Slice 2 on the controller earned that sentence twice. +// +// Regenerate deliberately: go test ./internal/notify/ -run TestCustomerMailGoldens -update-mail-goldens + +var updateMailGoldens = flag.Bool("update-mail-goldens", false, + "rewrite the captured mail goldens (deliberate, declared blocks only)") + +// goldenClock is the instant every golden is stamped with. A fixed winter date, so the Budapest +// offset in the operator mail's `MST` field is stable too (CET, not CEST). +var goldenClock = time.Date(2026, 1, 15, 9, 30, 0, 0, time.UTC) + +// mailCase is one rendered mail: a name for its golden file, and the call that produces it. +type mailCase struct { + name string + render func() (string, string) // subject, body + comment string +} + +// customerMailCases enumerates every customer mail the hub can produce. +// +// The event-type cases are generated FROM `customerMessages` itself rather than from a list retyped +// here, so a new entry cannot be added without a golden appearing beside it — the list cannot drift +// out of date the way a hand-maintained one does (R-423's lesson, other side). +func customerMailCases(lang string) []mailCase { + var cases []mailCase + + keys := make([]string, 0, len(customerMessages)) + for k := range customerMessages { + keys = append(keys, k) + } + sort.Strings(keys) + + // 1. Every known event type, rendered with no box message and no details — the plain shape. + for _, k := range keys { + k := k + cases = append(cases, mailCase{ + name: "customer_event_" + k, + render: func() (string, string) { return FormatCustomerEmail(lang, "demo-fixture", k, "warning", "", "", "") }, + }) + } + + // 2. The shapes that are NOT one per event type: the fallbacks and the optional lines. + cases = append(cases, + mailCase{ + name: "customer_shape_unknown_type_uses_box_message", + comment: "an event type with no entry: the box's own Hungarian sentence IS the mail", + render: func() (string, string) { + return FormatCustomerEmail(lang, "demo-fixture", "disk_warning", "warning", + "A /mnt/adat lemez 91%-ig megtelt (12,4 GB szabad).", "", "") + }, + }, + mailCase{ + name: "customer_shape_box_message_beside_entry", + comment: "a known type AND a box message: the entry is the headline, the message is a line", + render: func() (string, string) { + return FormatCustomerEmail(lang, "demo-fixture", "backup_failed", "error", + "A cel nincs beallitva.", "", "") + }, + }, + mailCase{ + name: "customer_shape_with_details", + comment: "details render as the Megjegyzes line", + render: func() (string, string) { + return FormatCustomerEmail(lang, "demo-fixture", "app_deployed", "info", "", "", `{"app":"bentopdf"}`) + }, + }, + mailCase{ + name: "customer_shape_empty_details_object_is_omitted", + comment: "{} is not details", + render: func() (string, string) { + return FormatCustomerEmail(lang, "demo-fixture", "app_deployed", "info", "", "", "{}") + }, + }, + mailCase{ + name: "customer_shape_unknown_severity_prints_raw", + comment: "an unlabelled severity falls through to its own name", + render: func() (string, string) { + return FormatCustomerEmail(lang, "demo-fixture", "test", "verbose", "", "", "") + }, + }, + ) + for _, sev := range []string{"info", "warning", "error", "critical"} { + sev := sev + cases = append(cases, mailCase{ + name: "customer_severity_" + sev, + render: func() (string, string) { return FormatCustomerEmail(lang, "demo-fixture", "test", sev, "", "", "") }, + }) + } + + // 3. The claim arc and the self-bind link — the mails sent before any box reports. + for _, kind := range []string{"claim", "reset", "reenroll", "claimed"} { + kind := kind + cases = append(cases, mailCase{ + name: "claim_" + kind, + render: func() (string, string) { + return FormatClaimEmail(lang, kind, "demo-fixture", "fixture.example", "alma-korte-szilva") + }, + }) + } + cases = append(cases, mailCase{ + name: "selfbind", + render: func() (string, string) { + return FormatSelfBindEmail(lang, "demo-fixture", "https://hub.felhom.eu/bind/FIXTURETOKEN") + }, + }) + + // 4. The OPERATOR mails. They are English and this slice does not touch them — which is exactly + // why they are goldened: the claim "the operator's mail did not move" needs the same diff. + cases = append(cases, + mailCase{ + name: "operator_event_backup_failed", + render: func() (string, string) { + return FormatOperatorEmail("demo-fixture", "backup_failed", "error", "A mentes sikertelen.", "") + }, + }, + mailCase{ + name: "operator_event_recovered", + render: func() (string, string) { return FormatOperatorEmail("demo-fixture", "node_recovered", "info", "", "") }, + }, + mailCase{ + name: "operator_backup_run_failures_digest", + comment: "the R-182 digest — operator-only, already English, never localised", + render: func() (string, string) { + return FormatOperatorEmail("demo-fixture", "backup_run_failures", "error", "3 app failed", `{ + "run_id":"r1","run_kind":"nightly","failed":2,"attempted":4, + "target_path":"/mnt/backup","used_gb":81.5,"avail_gb":12.5,"total_gb":94.0, + "used_percent":86.7,"space_known":true, + "apps":[{"app":"bookstack","leg":"db","reason":"mysqldump exit 1 — /mnt/backup: 81.5/94.0 GB used (87%), 12.5 GB free"}, + {"app":"vaultwarden","leg":"volumes","reason":"no space left on device"}]}`) + }, + }, + ) + return cases +} + +func goldenDir(lang string) string { return filepath.Join("testdata", "mail_goldens", lang) } + +// renderGolden is the on-disk form: subject and body in one file, so a diff shows both. +func renderGolden(c mailCase) string { + subject, body := c.render() + out := "" + if c.comment != "" { + out += "# " + c.comment + "\n" + } + return out + "SUBJECT: " + subject + "\n---\n" + body + "\n" +} + +func TestCustomerMailGoldens(t *testing.T) { + old := nowFn + nowFn = func() time.Time { return goldenClock } + t.Cleanup(func() { nowFn = old }) + + for _, lang := range i18n.Supported { + t.Run(lang, func(t *testing.T) { runMailGoldens(t, lang) }) + } +} + +// runMailGoldens renders every mail in one language and diffs it against that language's captured +// bytes. Hungarian proves nothing moved; English proves what shipped is what was reviewed. +func runMailGoldens(t *testing.T, lang string) { + cases := customerMailCases(lang) + if len(cases) < 50 { + t.Fatalf("only %d mail cases enumerated — the table stopped covering the mails", len(cases)) + } + + if *updateMailGoldens { + if err := os.MkdirAll(goldenDir(lang), 0o755); err != nil { + t.Fatal(err) + } + for _, c := range cases { + p := filepath.Join(goldenDir(lang), c.name+".txt") + if err := os.WriteFile(p, []byte(renderGolden(c)), 0o644); err != nil { + t.Fatal(err) + } + } + t.Logf("wrote %d %s mail goldens to %s", len(cases), lang, goldenDir(lang)) + return + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p := filepath.Join(goldenDir(lang), c.name+".txt") + want, err := os.ReadFile(p) + if err != nil { + t.Fatalf("no golden for %s — capture it deliberately: %v", c.name, err) + } + got := renderGolden(c) + if got != string(want) { + t.Errorf("mail %s changed.\n--- golden ---\n%s\n--- now ---\n%s", c.name, want, got) + } + }) + } + + // Every golden on disk belongs to a case: a file left behind after a mail is deleted would + // otherwise sit there proving nothing about a mail that no longer exists. + entries, err := os.ReadDir(goldenDir(lang)) + if err != nil { + t.Fatal(err) + } + known := map[string]bool{} + for _, c := range cases { + known[c.name+".txt"] = true + } + for _, e := range entries { + if !known[e.Name()] { + t.Errorf("orphan golden %s — no case renders it", e.Name()) + } + } +} diff --git a/hub/internal/notify/node_liveness_cooldown_test.go b/hub/internal/notify/node_liveness_cooldown_test.go index c94bf722..7d62e90a 100644 --- a/hub/internal/notify/node_liveness_cooldown_test.go +++ b/hub/internal/notify/node_liveness_cooldown_test.go @@ -19,7 +19,12 @@ func TestOperatorCooldown_NodeLivenessBypassesQuietHour(t *testing.T) { d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0)) var mu sync.Mutex sent := 0 - d.sendEmailFn = func(string, string, string, map[string]string) error { mu.Lock(); defer mu.Unlock(); sent++; return nil } + d.sendEmailFn = func(string, string, string, map[string]string) error { + mu.Lock() + defer mu.Unlock() + sent++ + return nil + } count := func() int { mu.Lock(); defer mu.Unlock(); return sent } // The previous node_stale mail went 39 minutes ago. diff --git a/hub/internal/notify/templates.go b/hub/internal/notify/templates.go index 4457cff8..fbf3060b 100644 --- a/hub/internal/notify/templates.go +++ b/hub/internal/notify/templates.go @@ -5,11 +5,21 @@ import ( "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") @@ -35,7 +45,7 @@ func FormatOperatorEmail(customerID, eventType, severity, message, detailsJSON s subject := fmt.Sprintf("[Felhom] %s %s: %s", icon, customerID, eventType) - now := time.Now().In(budapest).Format("2006-01-02 15:04 MST") + now := nowFn().In(budapest).Format("2006-01-02 15:04 MST") body := fmt.Sprintf(`Customer: %s Event: %s Severity: %s @@ -67,144 +77,104 @@ Message: %s`, customerID, eventType, severity, now, message) // ────────────────────────────────────────────────────────────────────── // 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.", +// 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.") - // 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. +// severityLabels maps severity → the Hungarian label. Derived from the bundle, same reasoning. +var severityLabels = bundleMessages("mail.severity.") - // 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.", - // R-550 (v0.117.0): the box stopped while a restore was running. - "restore_interrupted": "Egy visszaállítás megszakadt, mert a doboz újraindult. Indítsd el újra a vezérlőpult Visszaállítás oldalán.", - // R-536 (controller v0.244.0): the pair that makes „telepítve" mean it. The started event is the - // acceptance `app_deployed` used to assert beside the 202; the failed one is what an interrupted - // install used to be — silence. A new type must enter allowedEventTypes AND this map together. - "app_deploy_started": "Alkalmazás telepítése elindult.", - "app_deploy_failed": "Egy alkalmazás telepítése nem fejeződött be.", - - // 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.", +// 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 } -// 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, 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) -// 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 + 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 } - // Use the per-event-type Hungarian message if available, otherwise fall back to message - hunMessage := customerMessages[eventType] - if hunMessage == "" { - hunMessage = message + // The box's sentence, in the household's language when the box sent one. + boxMessage := message + if messageCustomer != "" { + boxMessage = messageCustomer } - subject := fmt.Sprintf("[Felhom] %s: %s", label, hunMessage) + // 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) + } + if headline == "" { + headline = boxMessage + } - now := time.Now().In(budapest).Format("2006-01-02 15:04") - body := fmt.Sprintf(`Kedves Ügyfél! + subject := b.Msgf(lang, "mail.customer.subject", label, headline) -A Felhom rendszered a következő értesítést küldte: + now := nowFn().In(budapest).Format("2006-01-02 15:04") + body := b.Msgf(lang, "mail.customer.body", headline, customerID, now, label, eventType) -%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 boxMessage != "" && boxMessage != headline { + body += b.Msgf(lang, "mail.customer.line.message", boxMessage) } if detailsJSON != "" && detailsJSON != "{}" { - body += fmt.Sprintf("\n- Megjegyzés: %s", detailsJSON) + body += b.Msgf(lang, "mail.customer.line.note", detailsJSON) } - body += ` - -Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. - -Üdvözlettel, -Felhom.eu monitoring` + body += b.Msg(lang, "mail.customer.signoff") return subject, body } @@ -234,89 +204,25 @@ Felhom.eu monitoring` // 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) { +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": - // 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 + 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 @@ -341,30 +247,9 @@ Felhom.eu`, code, dashboardURL) // // Naming only: no acceptance logic moved, and the same phrase is still accepted. Pinned by // TestSelfBind_ThirdSecretNaming and TestSelfBindPassphrase_StillAcceptedAfterTheRename. -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 tulajdonosi jelmondatodat (az 5 szóból álló kifejezést), amelyet a - Felhom üzemeltetőjétől kaptál — személyesen vagy telefonon, e-mailben - soha. Ez igazolja, hogy a fiók a tiéd. - -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 +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) } // ────────────────────────────────────────────────────────────────────── diff --git a/hub/internal/notify/templates_offbox_test.go b/hub/internal/notify/templates_offbox_test.go index 9c7b3bb1..7f2ea546 100644 --- a/hub/internal/notify/templates_offbox_test.go +++ b/hub/internal/notify/templates_offbox_test.go @@ -3,6 +3,8 @@ package notify import ( "strings" "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/i18n" ) // TestFormatCustomerEmail_OffboxEnlargeBlockedRawMessageSurvives locks in the deliberate non-change @@ -19,7 +21,7 @@ func TestFormatCustomerEmail_OffboxEnlargeBlockedRawMessageSurvives(t *testing.T t.Fatal("offbox_enlarge_blocked must have NO customerMessages entry (it would discard the dynamic numbers)") } - subject, body := FormatCustomerEmail("c1", "offbox_enlarge_blocked", "warning", raw, "") + subject, body := FormatCustomerEmail(i18n.Default, "c1", "offbox_enlarge_blocked", "warning", raw, "", "") for _, want := range []string{"~42.0 GB", "20/50 GB"} { if !strings.Contains(body, want) { t.Errorf("email body must carry the dynamic figure %q (raw message survived): %s", want, body) diff --git a/hub/internal/notify/testdata/mail_goldens/en/claim_claim.txt b/hub/internal/notify/testdata/mail_goldens/en/claim_claim.txt new file mode 100644 index 00000000..48b74fd6 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/claim_claim.txt @@ -0,0 +1,19 @@ +SUBJECT: [Felhom] Your Felhom server is up — setup code +--- +Dear Customer, + +Your Felhom server is up. To use the dashboard for the first time, enter the +setup code below, then choose your own password: + +Setup code: alma-korte-szilva + +The code is valid for 72 hours and can be used once. You can reach the +dashboard here: + +https://felhom.fixture.example + +If it did not reach you in time, the dashboard's "Request a new code" button +gets you a fresh one — it always arrives at this e-mail address. + +Best regards, +Felhom.eu diff --git a/hub/internal/notify/testdata/mail_goldens/en/claim_claimed.txt b/hub/internal/notify/testdata/mail_goldens/en/claim_claimed.txt new file mode 100644 index 00000000..d08a5aec --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/claim_claimed.txt @@ -0,0 +1,13 @@ +SUBJECT: [Felhom] Your dashboard is now password protected +--- +Dear Customer, + +The setup of your Felhom dashboard is complete — your dashboard is now +password protected. You can sign in with the password you chose: + +https://felhom.fixture.example + +If you did not do this setup, contact your operator immediately. + +Best regards, +Felhom.eu diff --git a/hub/internal/notify/testdata/mail_goldens/en/claim_reenroll.txt b/hub/internal/notify/testdata/mail_goldens/en/claim_reenroll.txt new file mode 100644 index 00000000..3a51a852 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/claim_reenroll.txt @@ -0,0 +1,19 @@ +SUBJECT: [Felhom] New setup code — your server was reinstalled +--- +Dear Customer, + +Your Felhom server was reinstalled, so the sign-in to your dashboard has to be +set up again. Your earlier password is no longer valid. + +Setup code: alma-korte-szilva + +The code is valid for 72 hours and can be used once. Open your dashboard — the +"Set up the server" page will greet you — enter the code, then choose a new +password: + +https://felhom.fixture.example + +If you did not reinstall your server, contact your operator. + +Best regards, +Felhom.eu diff --git a/hub/internal/notify/testdata/mail_goldens/en/claim_reset.txt b/hub/internal/notify/testdata/mail_goldens/en/claim_reset.txt new file mode 100644 index 00000000..b98be70d --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/claim_reset.txt @@ -0,0 +1,17 @@ +SUBJECT: [Felhom] Setup code to reset your password +--- +Dear Customer, + +You asked to reset the password for your Felhom dashboard. + +Setup code: alma-korte-szilva + +The code is valid for 72 hours and can be used once. Enter it on the +"Forgotten password" page of your dashboard, then choose a new password: + +https://felhom.fixture.example + +If you did not ask for this, ignore it — your current password is unchanged. + +Best regards, +Felhom.eu diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_deploy_failed.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_deploy_failed.txt new file mode 100644 index 00000000..8299f58b --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_deploy_failed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: An app did not finish installing. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +An app did not finish installing. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: app_deploy_failed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_deploy_started.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_deploy_started.txt new file mode 100644 index 00000000..87cc98b2 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_deploy_started.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: An app has started installing. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +An app has started installing. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: app_deploy_started + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_deployed.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_deployed.txt new file mode 100644 index 00000000..177d9184 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_deployed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: An app was installed. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +An app was installed. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: app_deployed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_removed.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_removed.txt new file mode 100644 index 00000000..77ea5659 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_removed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: An app was removed. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +An app was removed. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: app_removed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_start_failed.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_start_failed.txt new file mode 100644 index 00000000..7d7af6c6 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_start_failed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: An installed app is not running — check the system monitor. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +An installed app is not running — check the system monitor. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: app_start_failed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_completed.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_completed.txt new file mode 100644 index 00000000..caa6204f --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_completed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The backup finished successfully. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The backup finished successfully. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: backup_completed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_failed.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_failed.txt new file mode 100644 index 00000000..c279404e --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_failed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The backup failed. Check your system. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The backup failed. Check your system. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: backup_failed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_integrity_failed.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_integrity_failed.txt new file mode 100644 index 00000000..56ff2c52 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_integrity_failed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The backup integrity check found a problem. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The backup integrity check found a problem. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: backup_integrity_failed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_integrity_ok.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_integrity_ok.txt new file mode 100644 index 00000000..2179b981 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_integrity_ok.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The backup integrity check passed. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The backup integrity check passed. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: backup_integrity_ok + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_target_absent.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_target_absent.txt new file mode 100644 index 00000000..c38f1138 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_target_absent.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The drive your whole-system backup is written to is not available. Until you reconnect it, no whole-system backup is made. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The drive your whole-system backup is written to is not available. Until you reconnect it, no whole-system backup is made. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: backup_target_absent + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_target_restored.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_target_restored.txt new file mode 100644 index 00000000..bd9f0341 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_backup_target_restored.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The drive your whole-system backup is written to is available again — backups continue. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The drive your whole-system backup is written to is available again — backups continue. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: backup_target_restored + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_claim_lockout.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_claim_lockout.txt new file mode 100644 index 00000000..3eeed8a6 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_claim_lockout.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: Too many wrong setup codes were entered — the setup page is locked for 15 minutes. If that was not you, tell your operator. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +Too many wrong setup codes were entered — the setup page is locked for 15 minutes. If that was not you, tell your operator. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: claim_lockout + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_controller_started.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_controller_started.txt new file mode 100644 index 00000000..13d8c83f --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_controller_started.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The controller has started. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The controller has started. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: controller_started + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_controller_updated.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_controller_updated.txt new file mode 100644 index 00000000..0de66e40 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_controller_updated.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The controller has been updated. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The controller has been updated. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: controller_updated + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_crossdrive_completed.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_crossdrive_completed.txt new file mode 100644 index 00000000..40d3defb --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_crossdrive_completed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The second copy of your backup finished successfully. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The second copy of your backup finished successfully. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: crossdrive_completed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_crossdrive_failed.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_crossdrive_failed.txt new file mode 100644 index 00000000..0e0053c8 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_crossdrive_failed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The second copy of your backup failed. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The second copy of your backup failed. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: crossdrive_failed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_db_dump_completed.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_db_dump_completed.txt new file mode 100644 index 00000000..b2ef116f --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_db_dump_completed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The database backup finished successfully. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The database backup finished successfully. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: db_dump_completed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_db_dump_failed.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_db_dump_failed.txt new file mode 100644 index 00000000..53e80e76 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_db_dump_failed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The database backup failed. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The database backup failed. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: db_dump_failed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_disaster_recovery_completed.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_disaster_recovery_completed.txt new file mode 100644 index 00000000..e455226e --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_disaster_recovery_completed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: Disaster recovery has finished. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +Disaster recovery has finished. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: disaster_recovery_completed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_disaster_recovery_started.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_disaster_recovery_started.txt new file mode 100644 index 00000000..00a92929 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_disaster_recovery_started.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: Disaster recovery has started. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +Disaster recovery has started. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: disaster_recovery_started + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_expected_backup_missed.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_expected_backup_missed.txt new file mode 100644 index 00000000..874d9e2a --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_expected_backup_missed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: Today's backup did not finish by its deadline. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +Today's backup did not finish by its deadline. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: expected_backup_missed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_expected_dbdump_missed.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_expected_dbdump_missed.txt new file mode 100644 index 00000000..cfb55a07 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_expected_dbdump_missed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: Today's database backup did not finish by its deadline. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +Today's database backup did not finish by its deadline. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: expected_dbdump_missed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_health_critical.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_health_critical.txt new file mode 100644 index 00000000..67edc764 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_health_critical.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: Your system's health is critical. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +Your system's health is critical. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: health_critical + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_health_degraded.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_health_degraded.txt new file mode 100644 index 00000000..0804faa3 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_health_degraded.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: Your system's health has got worse. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +Your system's health has got worse. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: health_degraded + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_health_recovered.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_health_recovered.txt new file mode 100644 index 00000000..70e90ca2 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_health_recovered.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: Your system's health is back to normal. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +Your system's health is back to normal. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: health_recovered + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_host_disk_critical.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_host_disk_critical.txt new file mode 100644 index 00000000..9369a91b --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_host_disk_critical.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The main disk of your home server's base system is critically full (95%+). Act now, or the system may start to fail. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The main disk of your home server's base system is critically full (95%+). Act now, or the system may start to fail. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: host_disk_critical + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_host_disk_warning.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_host_disk_warning.txt new file mode 100644 index 00000000..53514872 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_host_disk_warning.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The main disk of your home server's base system (the Proxmox host) is over 90% full — free up some space (old backups, for example). +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The main disk of your home server's base system (the Proxmox host) is over 90% full — free up some space (old backups, for example). + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: host_disk_warning + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_host_recovered.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_host_recovered.txt new file mode 100644 index 00000000..dc7ba04d --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_host_recovered.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: Your home server's base system (the Proxmox host) can be reached again. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +Your home server's base system (the Proxmox host) can be reached again. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: host_recovered + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_node_down.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_node_down.txt new file mode 100644 index 00000000..3ac692da --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_node_down.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: Your server cannot be reached. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +Your server cannot be reached. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: node_down + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_node_recovered.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_node_recovered.txt new file mode 100644 index 00000000..2bdae121 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_node_recovered.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: Your server can be reached again. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +Your server can be reached again. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: node_recovered + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_node_stale.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_node_stale.txt new file mode 100644 index 00000000..eab59251 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_node_stale.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: Your server has not sent a report recently. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +Your server has not sent a report recently. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: node_stale + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_offbox_repo_orphaned.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_offbox_repo_orphaned.txt new file mode 100644 index 00000000..558b90d8 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_offbox_repo_orphaned.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The remote backup store is orphaned: the backups inside it were made with an earlier key that is no longer available (usually after a reinstall). No new backup is made until the store is reset — open the Remote backup page. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The remote backup store is orphaned: the backups inside it were made with an earlier key that is no longer available (usually after a reinstall). No new backup is made until the store is reset — open the Remote backup page. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: offbox_repo_orphaned + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_offbox_repo_reset.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_offbox_repo_reset.txt new file mode 100644 index 00000000..713f2a0e --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_offbox_repo_reset.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The remote backup store has been reset: the old history was set aside (not deleted), and an empty new store was created with the current key. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The remote backup store has been reset: the old history was set aside (not deleted), and an empty new store was created with the current key. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: offbox_repo_reset + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_restore_interrupted.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_restore_interrupted.txt new file mode 100644 index 00000000..ef1bbd5e --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_restore_interrupted.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: A restore was interrupted because the box restarted. Start it again on the Restore page of your dashboard. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +A restore was interrupted because the box restarted. Start it again on the Restore page of your dashboard. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: restore_interrupted + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_storage_disconnected.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_storage_disconnected.txt new file mode 100644 index 00000000..98a5db6f --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_storage_disconnected.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: A drive was disconnected — backups may pause. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +A drive was disconnected — backups may pause. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: storage_disconnected + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_storage_fill_critical.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_storage_fill_critical.txt new file mode 100644 index 00000000..13ee6c65 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_storage_fill_critical.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: A drive on your home server is critically full (95%+). Backups and writes to it may fail. Act now. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +A drive on your home server is critically full (95%+). Backups and writes to it may fail. Act now. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: storage_fill_critical + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_storage_fill_warning.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_storage_fill_warning.txt new file mode 100644 index 00000000..8cb06f8b --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_storage_fill_warning.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: A drive on your home server is over 90% full — free up some space before it fills. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +A drive on your home server is over 90% full — free up some space before it fills. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: storage_fill_warning + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_storage_reconnected.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_storage_reconnected.txt new file mode 100644 index 00000000..04c23d42 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_storage_reconnected.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: The drive is connected again. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The drive is connected again. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: storage_reconnected + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_test.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_test.txt new file mode 100644 index 00000000..88b34644 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_test.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: This is a test notification. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +This is a test notification. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: test + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_severity_critical.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_severity_critical.txt new file mode 100644 index 00000000..e638b50e --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_severity_critical.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Critical error: This is a test notification. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +This is a test notification. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Critical error +- Type: test + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_severity_error.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_severity_error.txt new file mode 100644 index 00000000..bf08c1ef --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_severity_error.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Error: This is a test notification. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +This is a test notification. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Error +- Type: test + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_severity_info.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_severity_info.txt new file mode 100644 index 00000000..369f0494 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_severity_info.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Information: This is a test notification. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +This is a test notification. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Information +- Type: test + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_severity_warning.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_severity_warning.txt new file mode 100644 index 00000000..88b34644 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_severity_warning.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Warning: This is a test notification. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +This is a test notification. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: test + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_shape_box_message_beside_entry.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_shape_box_message_beside_entry.txt new file mode 100644 index 00000000..62b95a99 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_shape_box_message_beside_entry.txt @@ -0,0 +1,20 @@ +# a known type AND a box message: the entry is the headline, the message is a line +SUBJECT: [Felhom] Error: The backup failed. Check your system. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +The backup failed. Check your system. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Error +- Type: backup_failed +- Message: A cel nincs beallitva. + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_shape_empty_details_object_is_omitted.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_shape_empty_details_object_is_omitted.txt new file mode 100644 index 00000000..9a24bdc0 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_shape_empty_details_object_is_omitted.txt @@ -0,0 +1,19 @@ +# {} is not details +SUBJECT: [Felhom] Information: An app was installed. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +An app was installed. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Information +- Type: app_deployed + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_shape_unknown_severity_prints_raw.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_shape_unknown_severity_prints_raw.txt new file mode 100644 index 00000000..43b355f3 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_shape_unknown_severity_prints_raw.txt @@ -0,0 +1,19 @@ +# an unlabelled severity falls through to its own name +SUBJECT: [Felhom] verbose: This is a test notification. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +This is a test notification. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: verbose +- Type: test + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_shape_unknown_type_uses_box_message.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_shape_unknown_type_uses_box_message.txt new file mode 100644 index 00000000..530adecd --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_shape_unknown_type_uses_box_message.txt @@ -0,0 +1,19 @@ +# an event type with no entry: the box's own Hungarian sentence IS the mail +SUBJECT: [Felhom] Warning: A /mnt/adat lemez 91%-ig megtelt (12,4 GB szabad). +--- +Dear Customer, + +Your Felhom system sent the following notification: + +A /mnt/adat lemez 91%-ig megtelt (12,4 GB szabad). + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Warning +- Type: disk_warning + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_shape_with_details.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_shape_with_details.txt new file mode 100644 index 00000000..b57f2915 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_shape_with_details.txt @@ -0,0 +1,20 @@ +# details render as the Megjegyzes line +SUBJECT: [Felhom] Information: An app was installed. +--- +Dear Customer, + +Your Felhom system sent the following notification: + +An app was installed. + +Details: +- Server: demo-fixture +- Time: 2026-01-15 10:30 +- Level: Information +- Type: app_deployed +- Note: {"app":"bentopdf"} + +If you have any questions, contact your operator. + +Best regards, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/en/operator_backup_run_failures_digest.txt b/hub/internal/notify/testdata/mail_goldens/en/operator_backup_run_failures_digest.txt new file mode 100644 index 00000000..ac0e91aa --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/operator_backup_run_failures_digest.txt @@ -0,0 +1,20 @@ +# the R-182 digest — operator-only, already English, never localised +SUBJECT: [Felhom] 🔴 demo-fixture: 2 of 4 apps failed to back up (nightly run) +--- +Customer: demo-fixture +Event: backup_run_failures +Severity: error +Time: 2026-01-15 10:30 CET +Message: 3 app failed + +FAILED: 2 of 4 apps attempted in this nightly run. + + bookstack db mysqldump exit 1 + vaultwarden volumes no space left on device + +Filesystem: /mnt/backup — 81.5/94.0 GB used (87%), 12.5 GB free + +Every failure above is also recorded individually in the notification log, +whether or not this mail was sent. + +Dashboard: https://hub.felhom.eu/customers/demo-fixture diff --git a/hub/internal/notify/testdata/mail_goldens/en/operator_event_backup_failed.txt b/hub/internal/notify/testdata/mail_goldens/en/operator_event_backup_failed.txt new file mode 100644 index 00000000..a0bf17c2 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/operator_event_backup_failed.txt @@ -0,0 +1,9 @@ +SUBJECT: [Felhom] 🔴 demo-fixture: backup_failed +--- +Customer: demo-fixture +Event: backup_failed +Severity: error +Time: 2026-01-15 10:30 CET +Message: A mentes sikertelen. + +Dashboard: https://hub.felhom.eu/customers/demo-fixture diff --git a/hub/internal/notify/testdata/mail_goldens/en/operator_event_recovered.txt b/hub/internal/notify/testdata/mail_goldens/en/operator_event_recovered.txt new file mode 100644 index 00000000..a2f9748f --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/operator_event_recovered.txt @@ -0,0 +1,9 @@ +SUBJECT: [Felhom] ✅ demo-fixture: node_recovered +--- +Customer: demo-fixture +Event: node_recovered +Severity: info +Time: 2026-01-15 10:30 CET +Message: + +Dashboard: https://hub.felhom.eu/customers/demo-fixture diff --git a/hub/internal/notify/testdata/mail_goldens/en/selfbind.txt b/hub/internal/notify/testdata/mail_goldens/en/selfbind.txt new file mode 100644 index 00000000..7ae04720 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/selfbind.txt @@ -0,0 +1,23 @@ +SUBJECT: [Felhom] Link your Felhom box to your account +--- +Dear Customer, + +Your Felhom box is ready and waiting to be linked. You can link it to your +account yourself using the link below — no sign-in needed: + +https://hub.felhom.eu/bind/FIXTURETOKEN + +After you open the link you will need to enter two things: + + 1. The pairing code, shown on the box's own screen (the monitor). + 2. Your owner passphrase (the 5-word phrase) that you received from your + Felhom operator — in person or by telephone, never by e-mail. It proves + the account is yours. + +The link is valid for 7 days. For safety it locks after 5 failed attempts — +if that happens, contact support. + +If you did not ask for this, ignore this e-mail. + +Best regards, +Felhom.eu diff --git a/hub/internal/notify/testdata/mail_goldens/hu/claim_claim.txt b/hub/internal/notify/testdata/mail_goldens/hu/claim_claim.txt new file mode 100644 index 00000000..4999a68f --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/claim_claim.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Elindult a Felhom szervered — beállító kód +--- +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: alma-korte-szilva + +A kód 72 óráig érvényes, és egyszer használható fel. A vezérlőpultot itt éred el: + +https://felhom.fixture.example + +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 diff --git a/hub/internal/notify/testdata/mail_goldens/hu/claim_claimed.txt b/hub/internal/notify/testdata/mail_goldens/hu/claim_claimed.txt new file mode 100644 index 00000000..9973c6e5 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/claim_claimed.txt @@ -0,0 +1,13 @@ +SUBJECT: [Felhom] A vezérlőpultod mostantól jelszóval védett +--- +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: + +https://felhom.fixture.example + +Ha nem te végezted a beállítást, azonnal vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu diff --git a/hub/internal/notify/testdata/mail_goldens/hu/claim_reenroll.txt b/hub/internal/notify/testdata/mail_goldens/hu/claim_reenroll.txt new file mode 100644 index 00000000..6170ec70 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/claim_reenroll.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Új beállító kód — újratelepült a szervered +--- +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: alma-korte-szilva + +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: + +https://felhom.fixture.example + +Ha nem te telepítetted újra a szervered, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu diff --git a/hub/internal/notify/testdata/mail_goldens/hu/claim_reset.txt b/hub/internal/notify/testdata/mail_goldens/hu/claim_reset.txt new file mode 100644 index 00000000..1c0ace9d --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/claim_reset.txt @@ -0,0 +1,17 @@ +SUBJECT: [Felhom] Beállító kód a jelszavad visszaállításához +--- +Kedves Ügyfél! + +Jelszó-visszaállítást kértél a Felhom vezérlőpultodhoz. + +Beállító kód: alma-korte-szilva + +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: + +https://felhom.fixture.example + +Ha nem te kérted, hagyd figyelmen kívül — a jelenlegi jelszavad változatlan. + +Üdvözlettel, +Felhom.eu diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_deploy_failed.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_deploy_failed.txt new file mode 100644 index 00000000..8b4e491f --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_deploy_failed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: Egy alkalmazás telepítése nem fejeződött be. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Egy alkalmazás telepítése nem fejeződött be. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: app_deploy_failed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_deploy_started.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_deploy_started.txt new file mode 100644 index 00000000..831307ad --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_deploy_started.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: Alkalmazás telepítése elindult. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Alkalmazás telepítése elindult. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: app_deploy_started + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_deployed.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_deployed.txt new file mode 100644 index 00000000..7c86f79d --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_deployed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: Alkalmazás telepítve. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Alkalmazás telepítve. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: app_deployed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_removed.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_removed.txt new file mode 100644 index 00000000..cfefe7cc --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_removed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: Alkalmazás eltávolítva. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Alkalmazás eltávolítva. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: app_removed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_start_failed.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_start_failed.txt new file mode 100644 index 00000000..e008e657 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_start_failed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: Egy telepített alkalmazás nem fut — ellenőrizze a rendszermonitort. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Egy telepített alkalmazás nem fut — ellenőrizze a rendszermonitort. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: app_start_failed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_completed.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_completed.txt new file mode 100644 index 00000000..ae691433 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_completed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A biztonsági mentés sikeresen elkészült. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A biztonsági mentés sikeresen elkészült. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: backup_completed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_failed.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_failed.txt new file mode 100644 index 00000000..f66cc9f8 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_failed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A biztonsági mentés sikertelen! Kérjük, ellenőrizd a rendszert. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A biztonsági mentés sikertelen! Kérjük, ellenőrizd a rendszert. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: backup_failed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_integrity_failed.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_integrity_failed.txt new file mode 100644 index 00000000..7e09eab8 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_integrity_failed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A mentés integritás ellenőrzés hibát talált! +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A mentés integritás ellenőrzés hibát talált! + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: backup_integrity_failed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_integrity_ok.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_integrity_ok.txt new file mode 100644 index 00000000..b7289f5e --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_integrity_ok.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A mentés integritás ellenőrzés sikeres. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A mentés integritás ellenőrzés sikeres. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: backup_integrity_ok + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_target_absent.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_target_absent.txt new file mode 100644 index 00000000..80fe9266 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_target_absent.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A rendszermentés meghajtója nem érhető el — amíg vissza nem csatlakoztatod, a teljes rendszermentés nem készül el. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A rendszermentés meghajtója nem érhető el — amíg vissza nem csatlakoztatod, a teljes rendszermentés nem készül el. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: backup_target_absent + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_target_restored.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_target_restored.txt new file mode 100644 index 00000000..136cf111 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_backup_target_restored.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A rendszermentés meghajtója újra elérhető — a mentés folytatódik. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A rendszermentés meghajtója újra elérhető — a mentés folytatódik. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: backup_target_restored + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_claim_lockout.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_claim_lockout.txt new file mode 100644 index 00000000..2e9881a6 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_claim_lockout.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: 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. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +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. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: claim_lockout + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_controller_started.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_controller_started.txt new file mode 100644 index 00000000..4c32b93c --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_controller_started.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A vezérlő elindult. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A vezérlő elindult. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: controller_started + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_controller_updated.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_controller_updated.txt new file mode 100644 index 00000000..6247e0b9 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_controller_updated.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A vezérlő frissítve lett. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A vezérlő frissítve lett. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: controller_updated + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_crossdrive_completed.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_crossdrive_completed.txt new file mode 100644 index 00000000..aab65764 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_crossdrive_completed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A másodlagos mentés sikeresen elkészült. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A másodlagos mentés sikeresen elkészült. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: crossdrive_completed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_crossdrive_failed.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_crossdrive_failed.txt new file mode 100644 index 00000000..59290964 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_crossdrive_failed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A másodlagos mentés sikertelen! +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A másodlagos mentés sikertelen! + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: crossdrive_failed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_db_dump_completed.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_db_dump_completed.txt new file mode 100644 index 00000000..77afd414 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_db_dump_completed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: Az adatbázis mentés sikeresen elkészült. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Az adatbázis mentés sikeresen elkészült. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: db_dump_completed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_db_dump_failed.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_db_dump_failed.txt new file mode 100644 index 00000000..53d3176d --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_db_dump_failed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: Az adatbázis mentés sikertelen! +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Az adatbázis mentés sikertelen! + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: db_dump_failed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_disaster_recovery_completed.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_disaster_recovery_completed.txt new file mode 100644 index 00000000..d3d9fb75 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_disaster_recovery_completed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: Katasztrófa helyreállítás befejezve. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Katasztrófa helyreállítás befejezve. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: disaster_recovery_completed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_disaster_recovery_started.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_disaster_recovery_started.txt new file mode 100644 index 00000000..9fc1ef15 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_disaster_recovery_started.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: Katasztrófa helyreállítás elindítva. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Katasztrófa helyreállítás elindítva. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: disaster_recovery_started + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_expected_backup_missed.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_expected_backup_missed.txt new file mode 100644 index 00000000..f397e6cd --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_expected_backup_missed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A mai biztonsági mentés nem készült el a határidőig! +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A mai biztonsági mentés nem készült el a határidőig! + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: expected_backup_missed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_expected_dbdump_missed.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_expected_dbdump_missed.txt new file mode 100644 index 00000000..bc748cc0 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_expected_dbdump_missed.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A mai adatbázis mentés nem készült el a határidőig! +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A mai adatbázis mentés nem készült el a határidőig! + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: expected_dbdump_missed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_health_critical.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_health_critical.txt new file mode 100644 index 00000000..2276c4a6 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_health_critical.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A rendszer állapota kritikus! +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A rendszer állapota kritikus! + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: health_critical + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_health_degraded.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_health_degraded.txt new file mode 100644 index 00000000..00be9b3a --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_health_degraded.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A rendszer állapota romlott. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A rendszer állapota romlott. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: health_degraded + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_health_recovered.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_health_recovered.txt new file mode 100644 index 00000000..6158aba2 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_health_recovered.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A rendszer állapota helyreállt. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A rendszer állapota helyreállt. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: health_recovered + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_host_disk_critical.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_host_disk_critical.txt new file mode 100644 index 00000000..4d4e6c62 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_host_disk_critical.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: 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! +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +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! + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: host_disk_critical + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_host_disk_warning.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_host_disk_warning.txt new file mode 100644 index 00000000..53e038fa --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_host_disk_warning.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: 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). +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +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). + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: host_disk_warning + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_host_recovered.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_host_recovered.txt new file mode 100644 index 00000000..8a33910b --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_host_recovered.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A házszerver alaprendszere (Proxmox-gazda) újra elérhető. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A házszerver alaprendszere (Proxmox-gazda) újra elérhető. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: host_recovered + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_node_down.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_node_down.txt new file mode 100644 index 00000000..a188ed71 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_node_down.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A szerver nem elérhető! +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A szerver nem elérhető! + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: node_down + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_node_recovered.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_node_recovered.txt new file mode 100644 index 00000000..9d91bc89 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_node_recovered.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A szerver újra elérhető. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A szerver újra elérhető. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: node_recovered + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_node_stale.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_node_stale.txt new file mode 100644 index 00000000..7d6dfe80 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_node_stale.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A szerver nem küldött jelentést az elmúlt időszakban. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A szerver nem küldött jelentést az elmúlt időszakban. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: node_stale + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_offbox_repo_orphaned.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_offbox_repo_orphaned.txt new file mode 100644 index 00000000..1fcd45f9 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_offbox_repo_orphaned.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: 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. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +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. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: offbox_repo_orphaned + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_offbox_repo_reset.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_offbox_repo_reset.txt new file mode 100644 index 00000000..57fc9cf5 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_offbox_repo_reset.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: 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. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +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. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: offbox_repo_reset + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_restore_interrupted.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_restore_interrupted.txt new file mode 100644 index 00000000..fa64864a --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_restore_interrupted.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: Egy visszaállítás megszakadt, mert a doboz újraindult. Indítsd el újra a vezérlőpult Visszaállítás oldalán. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Egy visszaállítás megszakadt, mert a doboz újraindult. Indítsd el újra a vezérlőpult Visszaállítás oldalán. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: restore_interrupted + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_storage_disconnected.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_storage_disconnected.txt new file mode 100644 index 00000000..00aef334 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_storage_disconnected.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: Egy meghajtó leválasztva — a mentések szünetelhetnek. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Egy meghajtó leválasztva — a mentések szünetelhetnek. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: storage_disconnected + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_storage_fill_critical.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_storage_fill_critical.txt new file mode 100644 index 00000000..0ba638df --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_storage_fill_critical.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: 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! +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +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! + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: storage_fill_critical + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_storage_fill_warning.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_storage_fill_warning.txt new file mode 100644 index 00000000..0c3e67cc --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_storage_fill_warning.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: Egy tároló a házszerveren 90% felett telt — kérjük, szabadíts fel helyet, mielőtt megtelik. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Egy tároló a házszerveren 90% felett telt — kérjük, szabadíts fel helyet, mielőtt megtelik. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: storage_fill_warning + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_storage_reconnected.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_storage_reconnected.txt new file mode 100644 index 00000000..5885da60 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_storage_reconnected.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: A meghajtó újra csatlakoztatva. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A meghajtó újra csatlakoztatva. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: storage_reconnected + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_test.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_test.txt new file mode 100644 index 00000000..1c7a5ead --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_test.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: Ez egy teszt értesítés. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Ez egy teszt értesítés. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: test + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_severity_critical.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_severity_critical.txt new file mode 100644 index 00000000..55e0da5d --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_severity_critical.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Kritikus hiba: Ez egy teszt értesítés. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Ez egy teszt értesítés. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Kritikus hiba +- Típus: test + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_severity_error.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_severity_error.txt new file mode 100644 index 00000000..2c495e42 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_severity_error.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Hiba: Ez egy teszt értesítés. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Ez egy teszt értesítés. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Hiba +- Típus: test + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_severity_info.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_severity_info.txt new file mode 100644 index 00000000..1bfe7e94 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_severity_info.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Információ: Ez egy teszt értesítés. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Ez egy teszt értesítés. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Információ +- Típus: test + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_severity_warning.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_severity_warning.txt new file mode 100644 index 00000000..1c7a5ead --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_severity_warning.txt @@ -0,0 +1,18 @@ +SUBJECT: [Felhom] Figyelmeztetés: Ez egy teszt értesítés. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Ez egy teszt értesítés. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: test + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_box_message_beside_entry.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_box_message_beside_entry.txt new file mode 100644 index 00000000..9814a129 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_box_message_beside_entry.txt @@ -0,0 +1,20 @@ +# a known type AND a box message: the entry is the headline, the message is a line +SUBJECT: [Felhom] Hiba: A biztonsági mentés sikertelen! Kérjük, ellenőrizd a rendszert. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A biztonsági mentés sikertelen! Kérjük, ellenőrizd a rendszert. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Hiba +- Típus: backup_failed +- Üzenet: A cel nincs beallitva. + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_empty_details_object_is_omitted.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_empty_details_object_is_omitted.txt new file mode 100644 index 00000000..53ebd0d1 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_empty_details_object_is_omitted.txt @@ -0,0 +1,19 @@ +# {} is not details +SUBJECT: [Felhom] Információ: Alkalmazás telepítve. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Alkalmazás telepítve. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Információ +- Típus: app_deployed + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_unknown_severity_prints_raw.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_unknown_severity_prints_raw.txt new file mode 100644 index 00000000..34c19b23 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_unknown_severity_prints_raw.txt @@ -0,0 +1,19 @@ +# an unlabelled severity falls through to its own name +SUBJECT: [Felhom] verbose: Ez egy teszt értesítés. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Ez egy teszt értesítés. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: verbose +- Típus: test + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_unknown_type_uses_box_message.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_unknown_type_uses_box_message.txt new file mode 100644 index 00000000..81468c49 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_unknown_type_uses_box_message.txt @@ -0,0 +1,19 @@ +# an event type with no entry: the box's own Hungarian sentence IS the mail +SUBJECT: [Felhom] Figyelmeztetés: A /mnt/adat lemez 91%-ig megtelt (12,4 GB szabad). +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +A /mnt/adat lemez 91%-ig megtelt (12,4 GB szabad). + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Figyelmeztetés +- Típus: disk_warning + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_with_details.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_with_details.txt new file mode 100644 index 00000000..9d1bc3f0 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_with_details.txt @@ -0,0 +1,20 @@ +# details render as the Megjegyzes line +SUBJECT: [Felhom] Információ: Alkalmazás telepítve. +--- +Kedves Ügyfél! + +A Felhom rendszered a következő értesítést küldte: + +Alkalmazás telepítve. + +Részletek: +- Szerver: demo-fixture +- Időpont: 2026-01-15 10:30 +- Szint: Információ +- Típus: app_deployed +- Megjegyzés: {"app":"bentopdf"} + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring diff --git a/hub/internal/notify/testdata/mail_goldens/hu/operator_backup_run_failures_digest.txt b/hub/internal/notify/testdata/mail_goldens/hu/operator_backup_run_failures_digest.txt new file mode 100644 index 00000000..ac0e91aa --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/operator_backup_run_failures_digest.txt @@ -0,0 +1,20 @@ +# the R-182 digest — operator-only, already English, never localised +SUBJECT: [Felhom] 🔴 demo-fixture: 2 of 4 apps failed to back up (nightly run) +--- +Customer: demo-fixture +Event: backup_run_failures +Severity: error +Time: 2026-01-15 10:30 CET +Message: 3 app failed + +FAILED: 2 of 4 apps attempted in this nightly run. + + bookstack db mysqldump exit 1 + vaultwarden volumes no space left on device + +Filesystem: /mnt/backup — 81.5/94.0 GB used (87%), 12.5 GB free + +Every failure above is also recorded individually in the notification log, +whether or not this mail was sent. + +Dashboard: https://hub.felhom.eu/customers/demo-fixture diff --git a/hub/internal/notify/testdata/mail_goldens/hu/operator_event_backup_failed.txt b/hub/internal/notify/testdata/mail_goldens/hu/operator_event_backup_failed.txt new file mode 100644 index 00000000..a0bf17c2 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/operator_event_backup_failed.txt @@ -0,0 +1,9 @@ +SUBJECT: [Felhom] 🔴 demo-fixture: backup_failed +--- +Customer: demo-fixture +Event: backup_failed +Severity: error +Time: 2026-01-15 10:30 CET +Message: A mentes sikertelen. + +Dashboard: https://hub.felhom.eu/customers/demo-fixture diff --git a/hub/internal/notify/testdata/mail_goldens/hu/operator_event_recovered.txt b/hub/internal/notify/testdata/mail_goldens/hu/operator_event_recovered.txt new file mode 100644 index 00000000..a2f9748f --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/operator_event_recovered.txt @@ -0,0 +1,9 @@ +SUBJECT: [Felhom] ✅ demo-fixture: node_recovered +--- +Customer: demo-fixture +Event: node_recovered +Severity: info +Time: 2026-01-15 10:30 CET +Message: + +Dashboard: https://hub.felhom.eu/customers/demo-fixture diff --git a/hub/internal/notify/testdata/mail_goldens/hu/selfbind.txt b/hub/internal/notify/testdata/mail_goldens/hu/selfbind.txt new file mode 100644 index 00000000..fec4de16 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/selfbind.txt @@ -0,0 +1,23 @@ +SUBJECT: [Felhom] Kösd össze a Felhom dobozodat +--- +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: + +https://hub.felhom.eu/bind/FIXTURETOKEN + +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 tulajdonosi jelmondatodat (az 5 szóból álló kifejezést), amelyet a + Felhom üzemeltetőjétől kaptál — személyesen vagy telefonon, e-mailben + soha. Ez igazolja, hogy a fiók a tiéd. + +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 diff --git a/hub/internal/store/language_test.go b/hub/internal/store/language_test.go new file mode 100644 index 00000000..312fd204 --- /dev/null +++ b/hub/internal/store/language_test.go @@ -0,0 +1,152 @@ +package store + +import ( + "io" + "log" + "path/filepath" + "testing" +) + +func langStore(t *testing.T) *Store { + t.Helper() + s, err := New(filepath.Join(t.TempDir(), "lang.db"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func saveCfg(t *testing.T, s *Store, id, lang string) { + t.Helper() + if err := s.SaveCustomerConfig(&CustomerConfig{ + CustomerID: id, CustomerName: id, Domain: "x.example", + RetrievalPassword: "p", APIKey: "k-" + id, ConfigJSON: "{}", Language: lang, + }); err != nil { + t.Fatal(err) + } +} + +// S2 — the language order IS the design: last reported → created-with → Hungarian. +// +// Each step is checked on its own AND in combination, because the interesting failure is not "the +// wrong default" but "the reported one is ignored" — a household that switches their dashboard to +// English and keeps getting Hungarian mail would look exactly like the feature not shipping at all. +func TestCustomerLanguageOrder(t *testing.T) { + s := langStore(t) + + // 3. Neither: Hungarian. + if got := s.CustomerLanguage("nobody"); got != "hu" { + t.Errorf("an unknown customer = %q, want hu", got) + } + + // 2. Created-with, no report yet — the window the claim mail and the bind page live in. + saveCfg(t, s, "c-en", "en") + saveCfg(t, s, "c-hu", "hu") + if got := s.CustomerLanguage("c-en"); got != "en" { + t.Errorf("created-with-en, no report = %q, want en", got) + } + if got := s.CustomerLanguage("c-hu"); got != "hu" { + t.Errorf("created-with-hu, no report = %q, want hu", got) + } + + // 1. Reported WINS over created-with, in both directions. The household outranks the operator. + if err := s.SaveReport("c-en", []byte(`{"controller_version":"0.256.0","language":"hu"}`)); err != nil { + t.Fatal(err) + } + if got := s.CustomerLanguage("c-en"); got != "hu" { + t.Errorf("created en but reported hu = %q, want hu — the household's choice lost", got) + } + if err := s.SaveReport("c-hu", []byte(`{"controller_version":"0.256.0","language":"en"}`)); err != nil { + t.Fatal(err) + } + if got := s.CustomerLanguage("c-hu"); got != "en" { + t.Errorf("created hu but reported en = %q, want en — the household's choice lost", got) + } +} + +// A box that reports NOTHING (a controller older than 0.247.0) must not erase the creation-time +// language. This is the live case: three boxes in the fleet report no language at all. +func TestOldControllerReportDoesNotOverrideTheCreationLanguage(t *testing.T) { + s := langStore(t) + saveCfg(t, s, "old", "en") + if err := s.SaveReport("old", []byte(`{"controller_version":"0.245.0"}`)); err != nil { + t.Fatal(err) + } + if got := s.CustomerLanguage("old"); got != "en" { + t.Errorf("a report with no language = %q, want en — the creation default was erased", got) + } +} + +// Garbage on the wire is stored as "never told us", NOT as Hungarian. Storing it as Hungarian would +// make a later real choice indistinguishable from the absence of one. +func TestGarbageReportedLanguageIsNotStoredAsHungarian(t *testing.T) { + s := langStore(t) + saveCfg(t, s, "g", "en") + for _, bad := range []string{`"de"`, `"EN-gb"`, `""`, `"; DROP TABLE reports"`} { + if err := s.SaveReport("g", []byte(`{"language":`+bad+`}`)); err != nil { + t.Fatal(err) + } + if got := s.CustomerLanguage("g"); got != "en" { + t.Errorf("reported %s = %q, want en (the creation default, because the report said nothing usable)", bad, got) + } + } + // ...and a REAL one still lands. + if err := s.SaveReport("g", []byte(`{"language":"hu"}`)); err != nil { + t.Fatal(err) + } + if got := s.CustomerLanguage("g"); got != "hu" { + t.Errorf("a real reported language after garbage = %q, want hu", got) + } +} + +// The NEWEST report wins, not the first or an arbitrary one. +func TestNewestReportedLanguageWins(t *testing.T) { + s := langStore(t) + saveCfg(t, s, "n", "hu") + for _, lang := range []string{"hu", "en", "hu", "en"} { + if err := s.SaveReport("n", []byte(`{"language":"`+lang+`"}`)); err != nil { + t.Fatal(err) + } + } + if got := s.CustomerLanguage("n"); got != "en" { + t.Errorf("after hu,en,hu,en the language is %q, want en (the newest)", got) + } +} + +// A creation-time language that is not a real language stores as Hungarian rather than as itself: +// the form is operator-driven, and an unsupported value must never reach a mail renderer. +func TestCreationLanguageIsValidated(t *testing.T) { + s := langStore(t) + saveCfg(t, s, "bad", "klingon") + cfg, err := s.GetCustomerConfig("bad") + if err != nil { + t.Fatal(err) + } + if cfg.Language != "hu" { + t.Errorf("an unsupported creation language stored as %q, want hu", cfg.Language) + } +} + +// The language survives a round trip through the config record — the value configgen writes into +// controller.yaml comes from here. +func TestCustomerConfigLanguageRoundTrips(t *testing.T) { + s := langStore(t) + saveCfg(t, s, "rt", "en") + cfg, err := s.GetCustomerConfig("rt") + if err != nil { + t.Fatal(err) + } + if cfg.Language != "en" { + t.Errorf("GetCustomerConfig language = %q, want en", cfg.Language) + } + list, err := s.ListCustomerConfigs() + if err != nil { + t.Fatal(err) + } + for _, c := range list { + if c.CustomerID == "rt" && c.Language != "en" { + t.Errorf("ListCustomerConfigs language = %q, want en", c.Language) + } + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 944d1109..e7a94822 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -12,6 +12,8 @@ import ( "gitea.dooplex.hu/admin/felhom-hub/internal/semver" _ "modernc.org/sqlite" + + "gitea.dooplex.hu/admin/felhom-hub/internal/i18n" ) // Store handles SQLite persistence for customer reports. @@ -183,6 +185,29 @@ func (s *Store) migrate() error { // at 1, so an already-running box records that as its baseline on its next report without restarting. s.db.Exec("ALTER TABLE customer_configs ADD COLUMN config_version INTEGER NOT NULL DEFAULT 1") + // v0.118.0 (R-558, localisation slice 3): the language the hub writes this household's e-mails in. + // + // TWO COLUMNS BECAUSE THERE ARE TWO FACTS, and collapsing them would lose one: + // + // - customer_configs.language is what the OPERATOR chose when creating the customer. It is a + // DEFAULT — it seeds the box and it is what the claim mail and the bind page use, because + // both happen before any box has ever reported. Default 'hu': every existing customer is + // Hungarian, which is what they are today. + // - reports.language is what the BOX last reported, i.e. what the HOUSEHOLD actually chose on + // their own dashboard. It wins, because the household outranks the operator's default. + // Default '' — empty means "this box has never told us", which is NOT the same as "Hungarian" + // and must not be stored as it (a box on a controller older than 0.247.0 sends no language at + // all, and pretending it chose Hungarian would make a later real choice indistinguishable + // from the absence of one). + // + // ROLLBACK: none needed, and none is possible cleanly — SQLite drops columns only from 3.35 and + // the data is worth keeping anyway. Rolling BACK the hub image is the whole rollback: v0.117.0 + // and earlier neither write nor read either column, and both carry a DEFAULT, so every INSERT + // those versions perform still succeeds. Verified by running the previous image against a + // migrated DB before the sync. + s.db.Exec("ALTER TABLE reports ADD COLUMN language TEXT NOT NULL DEFAULT ''") + s.db.Exec("ALTER TABLE customer_configs ADD COLUMN language TEXT NOT NULL DEFAULT 'hu'") + // v0.51.0 (DR-tier-by-default): per-customer DR-tier flag. NEW customers default ON (the // create handler sets it); the column default 0 is the LEGACY initialization — an existing // customer is only flipped ON by the one-time backfill (initialize from reality: its host @@ -943,7 +968,11 @@ func (s *Store) SaveReport(customerID string, reportJSON []byte) error { var parsed struct { ControllerVersion string `json:"controller_version"` ControllerURL string `json:"controller_url"` - System struct { + // Language (controller v0.247.0) — the household's dashboard choice. Denormalised out of + // report_json so the mail path is one indexed read, not a JSON parse per send. An old + // controller omits it and it stores as "" — "never told us", not "Hungarian". + Language string `json:"language"` + System struct { CPUPercent float64 `json:"cpu_percent"` MemoryPercent float64 `json:"memory_percent"` } `json:"system"` @@ -971,17 +1000,71 @@ func (s *Store) SaveReport(customerID string, reportJSON []byte) error { _, err := s.db.Exec(` INSERT INTO reports (customer_id, report_json, health_status, cpu_percent, memory_percent, container_total, container_running, - backup_last_snapshot, controller_version, controller_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + backup_last_snapshot, controller_version, controller_url, language) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, customerID, string(reportJSON), parsed.Health.Status, parsed.System.CPUPercent, parsed.System.MemoryPercent, parsed.Containers.Total, parsed.Containers.Running, backupSnapshot, - parsed.ControllerVersion, parsed.ControllerURL, + parsed.ControllerVersion, parsed.ControllerURL, reportedLanguage(parsed.Language), ) return err } +// reportedLanguage keeps only a language the hub can actually write a mail in. +// +// It does NOT normalise to Hungarian: i18n.Normalize maps everything unknown to "hu", which is right +// at RENDER time and wrong at STORAGE time. Storing "hu" for a box that reported garbage — or +// reported nothing — would erase the difference between "this household chose Hungarian" and "we +// have never been told", and the second is what the creation-time default exists to answer. +func reportedLanguage(raw string) string { + l := strings.ToLower(strings.TrimSpace(raw)) + if i18n.IsSupported(l) { + return l + } + return "" +} + +// CustomerLanguage answers "which language do we write to this household in?". +// +// THE ORDER IS THE DESIGN (R-558): last reported → created-with → Hungarian. +// +// 1. What the BOX last reported is what the HOUSEHOLD chose on their own dashboard. It outranks +// everything: the operator's creation-time pick is a default, never an override. +// 2. The creation-time language covers the window before a box has ever reported — which is exactly +// when the claim mail and the bind page are sent, so it is not an edge case. +// 3. Hungarian, for every customer that predates all of this. +// +// A quiet-box alarm deliberately uses (1) even though the box is silent: the last thing it said is +// still the best thing known about the household, and a box being down is no reason to switch a +// family's language. +func (s *Store) CustomerLanguage(customerID string) string { + // ORDER BY id, NOT BY received_at. `received_at` is `datetime('now')` — SECOND granularity — so + // two reports that arrive in the same second TIE, and the winner among ties is whatever SQLite + // feels like returning. A box that reports every few seconds during a settle, or two reports + // racing at a restart, would then resolve to an ARBITRARY one of them, and a household that had + // just switched language would get the old language back at random. + // + // `id` is INTEGER PRIMARY KEY AUTOINCREMENT: strictly increasing, never reused, and it is the + // real insertion order. Caught by TestNewestReportedLanguageWins, which failed on the + // received_at version — the same second-granularity trap the dispatcher already carries a note + // about for its recovery pairing. + var reported string + err := s.db.QueryRow(`SELECT language FROM reports WHERE customer_id = ? + ORDER BY id DESC LIMIT 1`, customerID).Scan(&reported) + if err == nil && i18n.IsSupported(reported) { + return reported + } + + var created string + err = s.db.QueryRow(`SELECT language FROM customer_configs WHERE customer_id = ?`, + customerID).Scan(&created) + if err == nil && i18n.IsSupported(created) { + return created + } + return i18n.Default +} + // reportOffsitePresence is the minimal parse for "does this controller report show an offsite tier // that is actually CONFIGURED on the box" — the R-70 delivery-state signal, i.e. applied-on-the-box. // @@ -1345,7 +1428,13 @@ type CustomerConfig struct { // rows were initialized from reality by the one-time backfill (enabled descriptor → ON). // OFF = zero Felhom-side cost (no ep0 namespace is provisioned) and offsite provisioning is // refused (the escrow ceremony depends on the PBS key — drill F-6, closed by policy). - DRTier bool + DRTier bool + // Language (v0.118.0, R-558) is the language this customer was CREATED with — the operator's + // pick on the create form, "hu" or "en". It is a DEFAULT, never an override: it seeds the box + // (configgen writes it as `customer.language`) and it is what the claim mail and the public bind + // page use, because both happen before any box has ever reported. The moment the household picks + // a language on their own dashboard, the reported one wins. See Store.CustomerLanguage. + Language string CreatedAt time.Time UpdatedAt time.Time } @@ -1359,8 +1448,8 @@ type CustomerConfig struct { func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error { _, err := s.db.Exec(` INSERT INTO customer_configs (customer_id, customer_name, domain, email, - retrieval_password, api_key, config_json, min_controller_version, dr_tier, config_version, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now')) + retrieval_password, api_key, config_json, min_controller_version, dr_tier, language, config_version, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now')) ON CONFLICT(customer_id) DO UPDATE SET customer_name = excluded.customer_name, domain = excluded.domain, @@ -1370,26 +1459,40 @@ func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error { config_json = excluded.config_json, min_controller_version = excluded.min_controller_version, dr_tier = excluded.dr_tier, + language = excluded.language, config_version = customer_configs.config_version + 1, updated_at = datetime('now')`, cfg.CustomerID, cfg.CustomerName, cfg.Domain, cfg.Email, cfg.RetrievalPassword, cfg.APIKey, cfg.ConfigJSON, cfg.MinControllerVersion, cfg.DRTier, + createdLanguage(cfg.Language), ) return err } +// createdLanguage keeps only a language the hub can write mail in, defaulting to Hungarian. +// +// Unlike reportedLanguage this one DOES default: an absent creation-time pick is genuinely +// "Hungarian", because that is what every customer created before v0.118.0 is and what the form +// offers first. The distinction the two functions draw is the point — see reportedLanguage. +func createdLanguage(raw string) string { + if i18n.IsSupported(raw) { + return raw + } + return i18n.Default +} + // GetCustomerConfig returns a customer configuration by ID, or nil if not found. func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) { var cfg CustomerConfig var createdAt, updatedAt string err := s.db.QueryRow(` SELECT customer_id, customer_name, domain, email, - retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, config_version, created_at, updated_at + retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, language, config_version, created_at, updated_at FROM customer_configs WHERE customer_id = ?`, customerID, ).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email, &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion, - &cfg.DRTier, &cfg.ConfigVersion, &createdAt, &updatedAt) + &cfg.DRTier, &cfg.Language, &cfg.ConfigVersion, &createdAt, &updatedAt) if err == sql.ErrNoRows { return nil, nil } @@ -1541,7 +1644,7 @@ func (s *Store) SetOneTimeSecretTimesForTest(customerID, createdAt, consumedAt s func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) { rows, err := s.db.Query(` SELECT customer_id, customer_name, domain, email, - retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, config_version, created_at, updated_at + retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, language, config_version, created_at, updated_at FROM customer_configs ORDER BY customer_id`) if err != nil { return nil, err @@ -1554,7 +1657,7 @@ func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) { var createdAt, updatedAt string if err := rows.Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email, &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion, - &cfg.DRTier, &cfg.ConfigVersion, &createdAt, &updatedAt); err != nil { + &cfg.DRTier, &cfg.Language, &cfg.ConfigVersion, &createdAt, &updatedAt); err != nil { return nil, err } cfg.CreatedAt = parseSQLiteTime(createdAt) @@ -1605,12 +1708,12 @@ func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error var createdAt, updatedAt string err := s.db.QueryRow(` SELECT customer_id, customer_name, domain, email, - retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, config_version, created_at, updated_at + retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, language, config_version, created_at, updated_at FROM customer_configs WHERE api_key = ?`, apiKey, ).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email, &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion, - &cfg.DRTier, &cfg.ConfigVersion, &createdAt, &updatedAt) + &cfg.DRTier, &cfg.Language, &cfg.ConfigVersion, &createdAt, &updatedAt) if err == sql.ErrNoRows { return nil, nil } diff --git a/hub/internal/web/bind_language_test.go b/hub/internal/web/bind_language_test.go new file mode 100644 index 00000000..d16610b6 --- /dev/null +++ b/hub/internal/web/bind_language_test.go @@ -0,0 +1,117 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/i18n" + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// The public bind page in the household's language (R-558), and the property that constrains it. + +func bindFixture(t *testing.T, lang string) (*Server, string) { + t.Helper() + s, st := newTestServer(t) + if err := st.SaveCustomerConfig(&store.CustomerConfig{ + CustomerID: "acme", CustomerName: "Acme", Domain: "acme.example", + RetrievalPassword: "p", APIKey: "k", ConfigJSON: "{}", Language: lang, + }); err != nil { + t.Fatal(err) + } + token := "0123456789abcdef0123456789abcdef" + if err := st.MintSelfBindToken("acme", selfBindHash(token), 7*24*time.Hour); err != nil { + t.Fatal(err) + } + return s, token +} + +func getBind(t *testing.T, s *Server, token string) string { + t.Helper() + rec := httptest.NewRecorder() + s.handleBind(rec, httptest.NewRequest(http.MethodGet, "/bind/"+token, nil)) + return rec.Body.String() +} + +// The page follows the CUSTOMER'S CREATION-TIME language, because no box has reported at bind time. +func TestBindPageFollowsTheCreationLanguage(t *testing.T) { + b := i18n.Shared() + + sHU, tokHU := bindFixture(t, "hu") + huPage := getBind(t, sHU, tokHU) + if !strings.Contains(huPage, b.Msg("hu", "bind.label.pairing")) { + t.Errorf("the Hungarian bind page does not carry the Hungarian pairing label") + } + if !strings.Contains(huPage, ``) { + t.Error("the Hungarian page does not declare lang=hu") + } + + sEN, tokEN := bindFixture(t, "en") + enPage := getBind(t, sEN, tokEN) + if !strings.Contains(enPage, b.Msg("en", "bind.label.pairing")) { + t.Errorf("the English bind page does not carry the English pairing label:\n%s", enPage) + } + if !strings.Contains(enPage, ``) { + t.Error("the English page does not declare lang=en — a screen reader would read English aloud in Hungarian") + } + // NEGATIVE CONTROL: the English page must not still carry the Hungarian sentence. + if strings.Contains(enPage, b.Msg("hu", "bind.lead")) { + t.Error("the English page still carries the Hungarian lead") + } + if huPage == enPage { + t.Fatal("both languages rendered the same page — the control proves nothing") + } +} + +// THE NO-ORACLE PROPERTY. This page folds an unknown token into "expired" so a stranger cannot learn +// whether a link was ever real. The LANGUAGE must not answer what the TEXT refuses to: if a real +// English customer's expired token rendered in English while an unknown token rendered in Hungarian, +// the page would confirm the token for every non-Hungarian customer. +func TestBindExpiredIsAlwaysDefaultLanguage(t *testing.T) { + s, _ := bindFixture(t, "en") + + // A real token belonging to an ENGLISH customer, expired. + expiredTok := "ffffffffffffffffffffffffffffffff" + if err := mintExpired(t, s, "acme", expiredTok); err != nil { + t.Fatal(err) + } + realExpired := getBind(t, s, expiredTok) + + // A token that was never real. + unknown := getBind(t, s, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + + if realExpired != unknown { + t.Errorf("an expired REAL token and an unknown token render differently — the page is an "+ + "oracle for whether a link existed.\n--- real ---\n%s\n--- unknown ---\n%s", realExpired, unknown) + } + b := i18n.Shared() + if !strings.Contains(realExpired, b.Msg(i18n.Default, "bind.invalid.lead")) { + t.Errorf("the expired page is not in the default language:\n%s", realExpired) + } + if strings.Contains(realExpired, b.Msg("en", "bind.invalid.lead")) { + t.Error("the expired page rendered in the customer's language — it leaks that the token is real") + } +} + +func mintExpired(t *testing.T, s *Server, customerID, token string) error { + t.Helper() + // A negative TTL mints a token that is already past its expiry. + return s.store.MintSelfBindToken(customerID, selfBindHash(token), -time.Hour) +} + +// Every state of the page renders in both languages without a marker or a blank escaping into it. +func TestBindPageHasNoUnsubstitutedMarkers(t *testing.T) { + for _, lang := range i18n.Supported { + s, tok := bindFixture(t, lang) + page := getBind(t, s, tok) + if strings.Contains(page, "{{T ") || strings.Contains(page, "!bind.") { + t.Errorf("%s page carries an unsubstituted marker or a missing key:\n%s", lang, page) + } + if strings.Contains(page, "") { + t.Errorf("%s page has an empty title", lang) + } + } +} diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index b45ea336..e7c44816 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -17,6 +17,8 @@ import ( "gitea.dooplex.hu/admin/felhom-hub/internal/offsite" "gitea.dooplex.hu/admin/felhom-hub/internal/semver" "gitea.dooplex.hu/admin/felhom-hub/internal/store" + + "gitea.dooplex.hu/admin/felhom-hub/internal/i18n" ) var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`) @@ -732,6 +734,9 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) { // v0.51.0: the DR-tier flag (the form checkbox defaults ON for new customers). Set // BEFORE applyOffsite — offsite provisioning is refused without the DR tier (F-6). DRTier: formBool(r, "dr_tier"), + // v0.118.0 (R-558): the customer-mail language. An absent or unknown value is Hungarian + // (createdLanguage), so an operator page that never posts the field keeps today's behaviour. + Language: strings.TrimSpace(r.FormValue("language")), } // Offsite provisioning (fail-closed): a provisioning error must NOT save a half-enabled config. @@ -795,6 +800,10 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust // v0.51.0: the DR-tier flag — set BEFORE applyOffsite (offsite requires the tier) and // applyPBSDR (which converges the descriptor toward it). cfg.DRTier = formBool(r, "dr_tier") + // v0.118.0 (R-558): the customer-mail language default. Editing it changes the mails sent + // before a box reports (claim, bind); it does NOT overrule a household that has chosen on + // their own dashboard — CustomerLanguage prefers the reported one. + cfg.Language = strings.TrimSpace(r.FormValue("language")) // Server-side twin of the form's required attributes (v0.48.0 — B3). The error re-render is // the STANDALONE page and carries the SUBMITTED overrides, so nothing the operator typed is @@ -1446,6 +1455,10 @@ func (s *Server) handleCreateConfigFromReport(w http.ResponseWriter, r *http.Req RetrievalPassword: retrievalPassword, APIKey: apiKey, ConfigJSON: "{}", + // No operator is present on this path (a config invented from a box's own report), so the + // language is the default. Stated rather than left blank: createdLanguage would supply the + // same value, and a reader should not have to know that to see what a box gets here. + Language: i18n.Default, } if err := s.store.SaveCustomerConfig(cfg); err != nil { diff --git a/hub/internal/web/passphrase_handover_test.go b/hub/internal/web/passphrase_handover_test.go index 37ca96e7..4bb0fd1b 100644 --- a/hub/internal/web/passphrase_handover_test.go +++ b/hub/internal/web/passphrase_handover_test.go @@ -40,7 +40,7 @@ func TestCustomerPage_CreatedFlashTellsTheOperatorToHandOverThePhrase(t *testing } func TestSelfBindEmail_SaysWhoHandedOverThePhrase(t *testing.T) { - _, body := notify.FormatSelfBindEmail("acme", "https://hub.example/bind/tok") + _, body := notify.FormatSelfBindEmail("hu", "acme", "https://hub.example/bind/tok") if strings.Contains(body, "beállításkor kaptál") { t.Error("the mail still says the customer received the phrase „a beállításkor” — nothing delivers it then") @@ -59,7 +59,7 @@ func TestSelfBindEmail_SaysWhoHandedOverThePhrase(t *testing.T) { // the link text or a helper. func TestSelfBindEmail_NeverCarriesThePhrase(t *testing.T) { const phrase = "alma korte szilva barack meggy" - _, body := notify.FormatSelfBindEmail("acme", "https://hub.example/bind/tok") + _, body := notify.FormatSelfBindEmail("hu", "acme", "https://hub.example/bind/tok") for _, w := range strings.Fields(phrase) { if strings.Contains(body, w) { t.Errorf("the self-bind mail contains a passphrase word %q", w) diff --git a/hub/internal/web/selfbind.go b/hub/internal/web/selfbind.go index 34fac3fa..2ce6382f 100644 --- a/hub/internal/web/selfbind.go +++ b/hub/internal/web/selfbind.go @@ -4,12 +4,15 @@ import ( "crypto/subtle" "html/template" "net/http" + "regexp" "strings" "sync" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" "gitea.dooplex.hu/admin/felhom-hub/internal/store" + + "gitea.dooplex.hu/admin/felhom-hub/internal/i18n" ) // selfbind.go — the CUSTOMER side of self-bind (v0.66.0, R-27 slice 1): the PUBLIC /bind/ page. @@ -97,14 +100,50 @@ type bindPageData struct { State string // "form" | "success" | "expired" | "consumed" | "locked" Token string // echoed into the form action (the capability itself; already in the URL) Failed bool // generic factor-check failure (form state only) + // Lang is the language to render in. It is the CUSTOMER'S CREATION-TIME language and nothing + // else: no box has reported yet at bind time, and this page deliberately offers no switch — + // the person opening it is a stranger holding a capability URL, exactly like the guest share + // pages, and a language control here would be a setting a stranger could touch. + Lang string } -var bindTemplate = template.Must(template.New("bind").Parse(bindPageHTML)) +// bindTemplates holds ONE PARSED TEMPLATE PER LANGUAGE, with the bundle's text substituted into the +// markup BEFORE html/template parses it. +// +// This is the controller's design (10-localisation.md rule 3) and it is chosen for the same reason: +// the Hungarian set is parsed from exactly the bytes the page carried before it was converted, in +// the same escaping contexts, so the Hungarian page is byte-identical by construction rather than by +// inspection. A runtime T function would route every string through the contextual escaper and move +// bytes (`—`, quotes) in ways nobody would notice until a customer saw them. +var bindTemplates = buildBindTemplates() + +func buildBindTemplates() map[string]*template.Template { + out := make(map[string]*template.Template, len(i18n.Supported)) + b := i18n.Shared() + for _, lang := range i18n.Supported { + html := i18nMarkerRe.ReplaceAllStringFunc(bindPageHTML, func(m string) string { + return b.Msg(lang, i18nMarkerRe.FindStringSubmatch(m)[1]) + }) + // Must: a template that fails to parse is a broken build, not a broken request. It fails + // at startup and in every test, rather than serving a stranger a blank page. + out[lang] = template.Must(template.New("bind-" + lang).Parse(html)) + } + return out +} + +// i18nMarkerRe matches a {{T "key"}} marker. Strict on purpose: a malformed marker is NOT +// substituted, so it reaches html/template as a call to an undefined function and the build fails +// loudly — never a marker shown to a customer. +var i18nMarkerRe = regexp.MustCompile(`\{\{\s*T\s+"([A-Za-z0-9_.\-]+)"\s*\}\}`) func (s *Server) renderBind(w http.ResponseWriter, status int, data bindPageData) { + tmpl, ok := bindTemplates[i18n.Normalize(data.Lang)] + if !ok { + tmpl = bindTemplates[i18n.Default] + } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) - if err := bindTemplate.Execute(w, data); err != nil { + if err := tmpl.Execute(w, data); err != nil { s.logger.Printf("[ERROR] rendering /bind page: %v", err) } } @@ -114,14 +153,14 @@ func (s *Server) renderBind(w http.ResponseWriter, status int, data bindPageData // customer self-bind). Reached only via isPublicBindPath — auth + CSRF exempt at the gate sites. func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) { if s.bindLimiter != nil && !s.bindLimiter.allow(bindClientIP(r)) { - s.renderBind(w, http.StatusTooManyRequests, bindPageData{State: "expired"}) + s.renderBind(w, http.StatusTooManyRequests, bindPageData{State: "expired", Lang: i18n.Default}) return } token := strings.TrimPrefix(r.URL.Path, "/bind/") // A trailing segment only — reject anything with further path structure (defence in depth atop // the ServeMux path-clean; the token is a flat hex string). if token == "" || strings.Contains(token, "/") { - s.renderBind(w, http.StatusNotFound, bindPageData{State: "expired"}) + s.renderBind(w, http.StatusNotFound, bindPageData{State: "expired", Lang: i18n.Default}) return } hash := selfBindHash(token) @@ -133,22 +172,40 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) { } now := time.Now() + // THE LANGUAGE IS ITSELF AN ORACLE, so it is resolved with the same care as the text (R-558). + // + // This page folds an UNKNOWN token into "expired" precisely so a stranger cannot learn whether a + // link was ever real. If the page then rendered a real customer's token in English and an unknown + // one in Hungarian, the LANGUAGE would answer the question the TEXT refuses to — for every + // customer who is not Hungarian. So: + // + // - the "expired" state ALWAYS renders in the default language, because that is the state an + // unknown token lands in and the two must be indistinguishable; + // - every other state already discloses that the token is real (its text says so), so those + // may follow the customer. + // + // Pinned by TestBindExpiredIsAlwaysDefaultLanguage. + lang := i18n.Default + if tok != nil { + lang = s.store.CustomerLanguage(tok.CustomerID) + } + // Terminal link states — identical for GET and POST, no factor check attempted. An unknown token // (nil) is folded into "expired": no oracle for "was this link ever real". switch { case tok == nil || tok.Expired(now): - s.renderBind(w, http.StatusOK, bindPageData{State: "expired"}) + s.renderBind(w, http.StatusOK, bindPageData{State: "expired", Lang: i18n.Default}) return case tok.Consumed(): - s.renderBind(w, http.StatusOK, bindPageData{State: "consumed"}) + s.renderBind(w, http.StatusOK, bindPageData{State: "consumed", Lang: lang}) return case tok.Locked: - s.renderBind(w, http.StatusOK, bindPageData{State: "locked"}) + s.renderBind(w, http.StatusOK, bindPageData{State: "locked", Lang: lang}) return } if r.Method != http.MethodPost { - s.renderBind(w, http.StatusOK, bindPageData{State: "form", Token: token}) + s.renderBind(w, http.StatusOK, bindPageData{State: "form", Token: token, Lang: lang}) return } @@ -181,10 +238,10 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) { // COUNTS only — never which factor failed, never the secrets, never the raw token. s.logger.Printf("[WARN] self-bind attempt %d/%d failed for token %s… (customer %s)", attempts, store.SelfBindMaxAttempts, hash[:8], tok.CustomerID) if locked { - s.renderBind(w, http.StatusOK, bindPageData{State: "locked"}) + s.renderBind(w, http.StatusOK, bindPageData{State: "locked", Lang: lang}) return } - s.renderBind(w, http.StatusOK, bindPageData{State: "form", Token: token, Failed: true}) + s.renderBind(w, http.StatusOK, bindPageData{State: "form", Token: token, Failed: true, Lang: lang}) return } @@ -197,14 +254,14 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) { } if !consumed { // Lost the race (a concurrent request consumed it) — it is already being bound. - s.renderBind(w, http.StatusOK, bindPageData{State: "consumed"}) + s.renderBind(w, http.StatusOK, bindPageData{State: "consumed", Lang: lang}) return } if err := s.store.BindAppliance(appliance.ID, tok.CustomerID, "appliance", ""); err != nil { // Rare: the appliance became unbindable (operator discarded it) between lookup and bind. The // token is spent; surface a neutral generic failure rather than an appliance-state oracle. s.logger.Printf("[WARN] self-bind: BindAppliance %d → %s failed after factor match: %v", appliance.ID, tok.CustomerID, err) - s.renderBind(w, http.StatusOK, bindPageData{State: "form", Failed: true}) + s.renderBind(w, http.StatusOK, bindPageData{State: "form", Failed: true, Lang: lang}) return } if _, err := s.store.SaveEvent(tok.CustomerID, "appliance_bound", "info", @@ -212,7 +269,7 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) { s.logger.Printf("[WARN] self-bind: save event for %s: %v", tok.CustomerID, err) } s.logger.Printf("[INFO] self-bind SUCCESS: appliance %d bound to customer %s by customer self-service (token %s…)", appliance.ID, tok.CustomerID, hash[:8]) - s.renderBind(w, http.StatusOK, bindPageData{State: "success"}) + s.renderBind(w, http.StatusOK, bindPageData{State: "success", Lang: lang}) } // bindPageHTML is the self-contained public page. It CANNOT link /style.css (that route is @@ -220,12 +277,12 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) { // surface, 2px radius, hairline rules, exception color for the failure banner. Hungarian, adult tone, // no emoji. It renders NO appliance data in any state. const bindPageHTML = ` - + -Felhom — Doboz összekötése +{{T "bind.title"}}