hub v0.120.0: app_update_undone/held reach the household, per app, in its language
gates / gates (push) Successful in 26s
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+18
@@ -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
|
||||
+18
@@ -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
|
||||
Vendored
+21
@@ -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
|
||||
Vendored
+21
@@ -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
|
||||
+18
@@ -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
|
||||
+18
@@ -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
|
||||
Vendored
+21
@@ -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
|
||||
Vendored
+21
@@ -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
|
||||
Reference in New Issue
Block a user