hub v0.120.0: app_update_undone/held reach the household, per app, in its language
gates / gates (push) Successful in 26s

Allowlisted, not operator-only, seeded for new households and added
once (add-only) to every existing enabled_events row. mail.event entries
in hu and en name the app from details.stack_name. Per-app cooldown on
both the operator and the household leg.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-23 13:47:13 +02:00
parent 05ea21e918
commit d3b284863e
22 changed files with 563 additions and 5 deletions
@@ -0,0 +1,67 @@
package notify
import (
"io"
"log"
"strings"
"testing"
)
// v0.120.0 — the household hears about an update ONCE per app per event (R-629: a storm is a
// defect), and two apps on one night are two mails.
//
// COMPANION RED-PROOF (REPORT.md): drop the perAppCustomerCooldownEvents suffix in processCustomer —
// the second app's mail is swallowed by the first app's cooldown and this fails on the count.
func TestAppUpdateEvents_OneMailPerAppPerEvent(t *testing.T) {
st := opOnlyStore(t)
if err := st.SaveNotificationPrefs("c1", "customer@example.com",
[]string{"app_update_undone", "app_update_held"}, 6); err != nil {
t.Fatal(err)
}
var subjects []string
d := NewDispatcher(st, "test-key", "from@felhom.eu", "operator@felhom.eu", true, log.New(io.Discard, "", 0))
d.sendEmailFn = func(to, subject, _ string, _ map[string]string) error {
if to == "customer@example.com" {
subjects = append(subjects, subject)
}
return nil
}
fire := func(et, app string) {
d.ProcessEvent("c1", et, "warning", "A(z) "+app+" frissitese nem sikerult.",
`{"app":"`+app+`","stack_name":"`+app+`"}`, "controller")
}
fire("app_update_undone", "docmost")
fire("app_update_undone", "romm") // another app, same night → its own mail
fire("app_update_undone", "docmost") // the same app again → no second mail
fire("app_update_held", "docmost") // another EVENT for the same app → its own mail
if len(subjects) != 3 {
t.Fatalf("want 3 household mails (docmost undone, romm undone, docmost held), got %d: %q", len(subjects), subjects)
}
for i, app := range []string{"docmost", "romm", "docmost"} {
if !strings.Contains(subjects[i], app+":") {
t.Errorf("mail %d must name %s in the subject, got %q", i, app, subjects[i])
}
}
}
// The other types keep their customer-side grain: the new register is not a blanket change.
func TestAppUpdateEvents_OtherTypesKeepTheirCustomerGrain(t *testing.T) {
st := opOnlyStore(t)
if err := st.SaveNotificationPrefs("c1", "customer@example.com", []string{"app_start_failed"}, 6); err != nil {
t.Fatal(err)
}
n := 0
d := NewDispatcher(st, "test-key", "from@felhom.eu", "operator@felhom.eu", true, log.New(io.Discard, "", 0))
d.sendEmailFn = func(to, _, _ string, _ map[string]string) error {
if to == "customer@example.com" {
n++
}
return nil
}
d.ProcessEvent("c1", "app_start_failed", "error", "x", `{"stack_name":"a"}`, "controller")
d.ProcessEvent("c1", "app_start_failed", "error", "x", `{"stack_name":"b"}`, "controller")
if n != 1 {
t.Fatalf("app_start_failed's household cooldown stays per TYPE (unchanged by v0.120.0), got %d mails", n)
}
}
+22
View File
@@ -389,6 +389,18 @@ var perAppCooldownEvents = map[string]bool{
// per-app is the only grain available that does not lose alarms. Measured 2026-08-23: two apps
// four minutes apart produced one mail and one suppression.
"app_start_failed": true,
// v0.120.0: an update outcome is per app with no digest — two apps on one night are two alarms.
"app_update_undone": true,
"app_update_held": true,
}
// perAppCustomerCooldownEvents is the CUSTOMER-leg sibling of perAppCooldownEvents (v0.120.0). The
// household's cooldown was keyed `customer:type` for every type, so two apps undone on the same night
// would reach the household as ONE mail. Its own register, deliberately — putting app_start_failed's
// customer leg on a per-app key would change a type nobody asked to change.
var perAppCustomerCooldownEvents = map[string]bool{
"app_update_undone": true,
"app_update_held": true,
}
// cooldownStackSuffix returns ":"+stack_name when the event's details carry a non-empty `stack_name`
@@ -417,6 +429,12 @@ func cooldownStackSuffix(eventType, detailsJSON string) string {
if !perAppCooldownEvents[eventType] {
return ""
}
return cooldownStackSuffixFor(detailsJSON)
}
// cooldownStackSuffixFor is the payload half of cooldownStackSuffix, register-free — callers decide
// which register applies (operator: perAppCooldownEvents; customer: perAppCustomerCooldownEvents).
func cooldownStackSuffixFor(detailsJSON string) string {
if detailsJSON == "" || !strings.Contains(detailsJSON, "\"stack_name\"") {
return ""
}
@@ -677,9 +695,13 @@ func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, m
cooldownDur := time.Duration(cooldownHours) * time.Hour
cooldownKey := customerID + ":" + eventType
if perAppCustomerCooldownEvents[eventType] {
cooldownKey += cooldownStackSuffixFor(detailsJSON)
}
d.mu.Lock()
if last, ok := d.custCooldowns[cooldownKey]; ok && time.Since(last) < cooldownDur {
d.mu.Unlock()
d.logger.Printf("[INFO] Customer mail skipped for %s/%s — cooldown (key %s)", customerID, eventType, cooldownKey)
return
}
d.custCooldowns[cooldownKey] = time.Now()
+18
View File
@@ -85,6 +85,24 @@ func customerMailCases(lang string) []mailCase {
return FormatCustomerEmail(lang, "demo-fixture", "app_deployed", "info", "", "", `{"app":"bentopdf"}`)
},
},
mailCase{
name: "customer_shape_app_update_undone_names_the_app",
comment: "v0.120.0: the entry names the app from details.stack_name; the box's sentence is the line",
render: func() (string, string) {
return FormatCustomerEmail(lang, "demo-fixture", "app_update_undone", "warning",
"A(z) docmost frissitese nem sikerult.", "",
`{"app":"docmost","stack_name":"docmost","from":{"docmost":"docmost:0.95.0"},"to":{"docmost":"docmost:0.96.0"}}`)
},
},
mailCase{
name: "customer_shape_app_update_held_names_the_app",
comment: "v0.120.0: the held entry names the app; the hold sentence is the line",
render: func() (string, string) {
return FormatCustomerEmail(lang, "demo-fixture", "app_update_held", "error",
"A frissites nem sikerult, es az automatikus visszaallitas sem.", "",
`{"app":"vikunja","stack_name":"vikunja","copy_tier":2}`)
},
},
mailCase{
name: "customer_shape_empty_details_object_is_omitted",
comment: "{} is not details",
@@ -61,15 +61,24 @@ func TestR389_StackSuffixIsAllowListedAndFailSoft(t *testing.T) {
}
}
// The register must stay narrow. A second entry is a deliberate act and should fail this until
// The register must stay narrow. A new entry is a deliberate act and should fail this until
// someone changes it on purpose, having read the fence.
//
// v0.120.0 widened it ON PURPOSE, by the brief "the undo reaches the fleet" (`09` §3 decision 15):
// an update outcome is one app's event, with no digest behind it — two apps undone on one night are
// two alarms. The backup family stays coarse, as the fence says.
func TestR389_TheAllowListHasExactlyOneMember(t *testing.T) {
if len(perAppCooldownEvents) != 1 || !perAppCooldownEvents["app_start_failed"] {
want := []string{"app_start_failed", "app_update_held", "app_update_undone"}
ok := len(perAppCooldownEvents) == len(want)
for _, w := range want {
ok = ok && perAppCooldownEvents[w]
}
if !ok {
var got []string
for k := range perAppCooldownEvents {
got = append(got, k)
}
t.Fatalf("perAppCooldownEvents = %v, want exactly [app_start_failed]. Adding a member is the "+
t.Fatalf("perAppCooldownEvents = %v, want exactly [app_start_failed app_update_held app_update_undone]. Adding a member is the "+
"fenced act: the backup family's cooldown is coarse ON PURPOSE (R-97a, R-182) so one full "+
"disk sends one digest, not one mail per app. Read the fence before widening this.", got)
}
+35
View File
@@ -156,6 +156,20 @@ func FormatCustomerEmail(lang, customerID, eventType, severity, message, message
headline := ""
if b.Has(i18n.Default, "mail.event."+eventType) {
headline = b.Msg(lang, "mail.event."+eventType)
// v0.120.0: an entry that NAMES THE APP (`%s`) is rendered with the details' stack_name — the
// subject then reads "docmost: …" rather than a sentence that could be about any app. No
// stack_name → the box's own sentence; neither → "?" in the app's place. Never a literal "%s"
// in a household's inbox, never an empty subject.
if appNamedMailEvents[eventType] {
switch app := stackNameOf(detailsJSON); {
case app != "":
headline = b.Msgf(lang, "mail.event."+eventType, app)
case boxMessage != "":
headline = ""
default:
headline = b.Msgf(lang, "mail.event."+eventType, "?")
}
}
}
if headline == "" {
headline = boxMessage
@@ -363,3 +377,24 @@ func trimRepeatedUsage(reason, targetPath string) string {
}
return strings.TrimSpace(reason[:i])
}
// appNamedMailEvents are the types whose `mail.event.*` entry carries the app's name as `%s`
// (v0.120.0). A named register, like operatorOnlyEvents: an entry gains an argument only on purpose.
var appNamedMailEvents = map[string]bool{
"app_update_undone": true,
"app_update_held": true,
}
// stackNameOf reads `stack_name` from an event's details, "" when absent or unreadable.
func stackNameOf(detailsJSON string) string {
if detailsJSON == "" {
return ""
}
var d struct {
StackName string `json:"stack_name"`
}
if json.Unmarshal([]byte(detailsJSON), &d) != nil {
return ""
}
return d.StackName
}
@@ -0,0 +1,18 @@
SUBJECT: [Felhom] Warning: ?: the app is stopped and needs a restore
---
Dear Customer,
Your Felhom system sent the following notification:
?: the app is stopped and needs a restore
Details:
- Server: demo-fixture
- Time: 2026-01-15 10:30
- Level: Warning
- Type: app_update_held
If you have any questions, contact your operator.
Best regards,
Felhom.eu monitoring
@@ -0,0 +1,18 @@
SUBJECT: [Felhom] Warning: ?: the update did not work; the app runs on its previous version
---
Dear Customer,
Your Felhom system sent the following notification:
?: the update did not work; the app runs on its previous version
Details:
- Server: demo-fixture
- Time: 2026-01-15 10:30
- Level: Warning
- Type: app_update_undone
If you have any questions, contact your operator.
Best regards,
Felhom.eu monitoring
@@ -0,0 +1,21 @@
# v0.120.0: the held entry names the app; the hold sentence is the line
SUBJECT: [Felhom] Error: vikunja: the app is stopped and needs a restore
---
Dear Customer,
Your Felhom system sent the following notification:
vikunja: the app is stopped and needs a restore
Details:
- Server: demo-fixture
- Time: 2026-01-15 10:30
- Level: Error
- Type: app_update_held
- Message: A frissites nem sikerult, es az automatikus visszaallitas sem.
- Note: {"app":"vikunja","stack_name":"vikunja","copy_tier":2}
If you have any questions, contact your operator.
Best regards,
Felhom.eu monitoring
@@ -0,0 +1,21 @@
# v0.120.0: the entry names the app from details.stack_name; the box's sentence is the line
SUBJECT: [Felhom] Warning: docmost: the update did not work; the app runs on its previous version
---
Dear Customer,
Your Felhom system sent the following notification:
docmost: the update did not work; the app runs on its previous version
Details:
- Server: demo-fixture
- Time: 2026-01-15 10:30
- Level: Warning
- Type: app_update_undone
- Message: A(z) docmost frissitese nem sikerult.
- Note: {"app":"docmost","stack_name":"docmost","from":{"docmost":"docmost:0.95.0"},"to":{"docmost":"docmost:0.96.0"}}
If you have any questions, contact your operator.
Best regards,
Felhom.eu monitoring
@@ -0,0 +1,18 @@
SUBJECT: [Felhom] Figyelmeztetés: ?: az alkalmazás leállítva, visszaállítás szükséges
---
Kedves Ügyfél!
A Felhom rendszered a következő értesítést küldte:
?: az alkalmazás leállítva, visszaállítás szükséges
Részletek:
- Szerver: demo-fixture
- Időpont: 2026-01-15 10:30
- Szint: Figyelmeztetés
- Típus: app_update_held
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu monitoring
@@ -0,0 +1,18 @@
SUBJECT: [Felhom] Figyelmeztetés: ?: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut
---
Kedves Ügyfél!
A Felhom rendszered a következő értesítést küldte:
?: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut
Részletek:
- Szerver: demo-fixture
- Időpont: 2026-01-15 10:30
- Szint: Figyelmeztetés
- Típus: app_update_undone
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu monitoring
@@ -0,0 +1,21 @@
# v0.120.0: the held entry names the app; the hold sentence is the line
SUBJECT: [Felhom] Hiba: vikunja: az alkalmazás leállítva, visszaállítás szükséges
---
Kedves Ügyfél!
A Felhom rendszered a következő értesítést küldte:
vikunja: az alkalmazás leállítva, visszaállítás szükséges
Részletek:
- Szerver: demo-fixture
- Időpont: 2026-01-15 10:30
- Szint: Hiba
- Típus: app_update_held
- Üzenet: A frissites nem sikerult, es az automatikus visszaallitas sem.
- Megjegyzés: {"app":"vikunja","stack_name":"vikunja","copy_tier":2}
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu monitoring
@@ -0,0 +1,21 @@
# v0.120.0: the entry names the app from details.stack_name; the box's sentence is the line
SUBJECT: [Felhom] Figyelmeztetés: docmost: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut
---
Kedves Ügyfél!
A Felhom rendszered a következő értesítést küldte:
docmost: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut
Részletek:
- Szerver: demo-fixture
- Időpont: 2026-01-15 10:30
- Szint: Figyelmeztetés
- Típus: app_update_undone
- Üzenet: A(z) docmost frissitese nem sikerult.
- Megjegyzés: {"app":"docmost","stack_name":"docmost","from":{"docmost":"docmost:0.95.0"},"to":{"docmost":"docmost:0.96.0"}}
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu monitoring