From 73404a3c3f2152bf16099501fe0c7a956c49cf4a Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 16:32:17 +0200 Subject: [PATCH] betterbird: lock the missed-reminder pass off, so a passed meeting's reminder does not come back (hq issue 640) With calendar.alarms.showmissed on, Betterbird's alarm service fires every reminder of the past month that carries no later acknowledgement at each load of a calendar. Owl never sends a dismissal to Exchange and keeps it only in Betterbird's local cache, so on g14 September's Exchange meetings came back about every ten minutes, Dismiss or not. The policy now locks the pass off whatever the setting or profile says, and betterbird_check says when the running Betterbird still has it on beside an Owl calendar. --- modules/betterbird/README.md | 22 +++++- .../cmd/betterbird-tools/betterbird.go | 3 + .../cmd/betterbird-tools/config_test.go | 8 +- .../cmd/betterbird-tools/install.go | 69 ++++++++++++++++- .../cmd/betterbird-tools/install_test.go | 76 +++++++++++++++++++ .../install/distribution/policies.json | 4 + 6 files changed, 172 insertions(+), 10 deletions(-) diff --git a/modules/betterbird/README.md b/modules/betterbird/README.md index 4e2a2acd..3b814646 100644 --- a/modules/betterbird/README.md +++ b/modules/betterbird/README.md @@ -97,7 +97,7 @@ start; `prefs.js` wins over a default. value true, false, a whole number or a string. The node-engine merges the layers key by key, so a node's layer overrides one preference, or takes one out with `null`. The operator's setup approved on 2026-10-12 (compact density, 14-point text, the vertical view with multi-line cards and threads, no new-mail pop-up, sound or tray -icon, the calendar's day from 09:00 to 20:00, reminders missed while asleep shown, chat off, remote content +icon, the calendar's day from 09:00 to 20:00, reminders missed while asleep shown (since locked off: issue 640), chat off, remote content blocked, Do Not Track; 21 preferences) moved from the catalogue's defaults file into the mesh-wide layer of `prefs`, set on the operator's Approve. @@ -109,10 +109,24 @@ desktop node writes the map's text into the old build's user.js, and `betterbird refused until the push. **Locked** (the policy): Betterbird's updater off (`DisableAppUpdate`); telemetry off (`DisableTelemetry`); -add-ons change only through the mesh's pins (`ExtensionUpdate` false); and three preferences locked off: +add-ons change only through the mesh's pins (`ExtensionUpdate` false); and four preferences locked off: `mail.shell.checkDefaultClient` (the ask to be the default client), `mail.inappnotifications.enabled` (the -vendor's in-app notices), and `mailnews.start_page.enabled` (the start page, which otherwise loads from the -vendor at every start). +vendor's in-app notices), `mailnews.start_page.enabled` (the start page, which otherwise loads from the +vendor at every start), and `calendar.alarms.showmissed` (the reminders of meetings that have passed, below). + +**A passed meeting's reminder is not shown again** (novox/hq issue 640). With `calendar.alarms.showmissed` on, +Betterbird's alarm service fires, at every load of a calendar (each start, each refresh, and each time Owl +registers its calendar after signing in), every reminder of the past month whose item carries no later +acknowledgement (`X-MOZ-LASTACK`). Off, Betterbird's own default, a past reminder fires only while its meeting +has not ended. A dismissal is that acknowledgement, and Owl never sends it to Exchange (its changes carry the +reminder's offset alone): it lives only in Betterbird's local calendar cache, which Owl empties when it +resynchronises a calendar from scratch. With the pass on, Exchange meetings of the past month came back about +every ten minutes on g14 from the start that placed the setting's `true`, Dismiss or not. So the policy locks the +pass off, whatever the setting `prefs` or a profile says; a setting's value for it has no effect (`betterbird_config` +shows the lock). What it costs: a reminder that fell due while the laptop slept is not shown once the meeting +is over; one for a meeting still running is. `betterbird_check` says when the running Betterbird still has the +pass on beside an Owl calendar, with the remedy (the module's push, or `betterbird_restart` once the policy is +placed). **Kept at Betterbird's default** (`// keeps Betterbird's default: ` in `mesh-defaults.js`): the theme, system colours, always-underlined links, manual spam marking, and the composer's attachment pane. A value the diff --git a/modules/betterbird/cmd/betterbird-tools/betterbird.go b/modules/betterbird/cmd/betterbird-tools/betterbird.go index a76148e3..900d3c5d 100644 --- a/modules/betterbird/cmd/betterbird-tools/betterbird.go +++ b/modules/betterbird/cmd/betterbird-tools/betterbird.go @@ -981,6 +981,9 @@ func (m *Machine) Check() (CheckAnswer, error) { "take them out of install/defaults-pref/mesh-defaults.js in a reviewed catalogue change: an account is declared in the setting accounts") } } + if what, do := m.missedReminders(profilePath); what != "" { + add(what, do) + } if why := m.defaultClientAsk(profilePath); why != "" && in.From != "not installed" { add("Betterbird asks at its start to be the default mail client: "+why, "push the module to this node: its policy locks "+defaultClientPref+" off (novox/hq issue 345)") diff --git a/modules/betterbird/cmd/betterbird-tools/config_test.go b/modules/betterbird/cmd/betterbird-tools/config_test.go index c92a7288..da612459 100644 --- a/modules/betterbird/cmd/betterbird-tools/config_test.go +++ b/modules/betterbird/cmd/betterbird-tools/config_test.go @@ -249,8 +249,8 @@ func TestTheConfigShowsTheLayersAndTheDrift(t *testing.T) { f.withDefaults() f.betterbirdsOwn() links(t, nil) - f.declare(`user_pref("mail.uifontsize", 16); user_pref("mail.biff.show_alert", false); user_pref("calendar.alarms.showmissed", true);`) - f.write(profileDir+"/user.js", mark+")\n"+`user_pref("mail.uifontsize", 16);`+"\n"+`user_pref("calendar.alarms.showmissed", true);`+"\n") + f.declare(`user_pref("mail.uifontsize", 16); user_pref("mail.biff.show_alert", false); user_pref("calendar.alarms.playsound", false);`) + f.write(profileDir+"/user.js", mark+")\n"+`user_pref("mail.uifontsize", 16);`+"\n"+`user_pref("calendar.alarms.playsound", false);`+"\n") f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+ `user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`+"\n"+ `user_pref("mail.uifontsize", 12);`+"\n"+ @@ -266,7 +266,7 @@ func TestTheConfigShowsTheLayersAndTheDrift(t *testing.T) { for _, p := range c.Prefs { by[p.Name] = p } - if len(c.Prefs) != 3+3+len(shippedKeepsWant) || !strings.HasPrefix(c.Defaults, "placed") { + if len(c.Prefs) != len(lockedOff)+3+len(shippedKeepsWant) || !strings.HasPrefix(c.Defaults, "placed") { t.Fatalf("every lock, declared preference and kept default: %d, %s", len(c.Prefs), c.Defaults) } if !reflect.DeepEqual(c.Drifting, []string{"extensions.activeThemeID", "mail.spam.manualMark", "mail.biff.show_alert"}) { @@ -293,7 +293,7 @@ func TestTheConfigShowsTheLayersAndTheDrift(t *testing.T) { if p := by["mailnews.start_page.enabled"]; p.Layer != "locked" || p.Effective != "false" || p.Drift != "" { t.Errorf("a lock wins over the profile: %+v", p) } - if p := by["calendar.alarms.showmissed"]; p.Effective != "true" || p.Layer != "declared" { + if p := by["calendar.alarms.playsound"]; p.Effective != "false" || p.Layer != "declared" { t.Errorf("%+v", p) } // T5: the check says the drift, with both remedies. diff --git a/modules/betterbird/cmd/betterbird-tools/install.go b/modules/betterbird/cmd/betterbird-tools/install.go index e9826e07..ba71bc0a 100644 --- a/modules/betterbird/cmd/betterbird-tools/install.go +++ b/modules/betterbird/cmd/betterbird-tools/install.go @@ -18,6 +18,7 @@ import ( "fmt" "io" "net/http" + "os" "path/filepath" "strings" "time" @@ -172,8 +173,8 @@ func (m *Machine) policyState() string { return "placed, and " + why } return "placed: Betterbird's updater, its telemetry and add-on updates off, Owl installed into every profile from " + - "the mesh's xpi with its updates off, and the ask to be the default mail client, the vendor's in-app notices " + - "and the start page locked off" + "the mesh's xpi with its updates off, and the ask to be the default mail client, the vendor's in-app notices, " + + "the start page and the reminders of meetings that have passed locked off" } // lockedOff are the preferences the module's policy locks to false (novox/hq issue 345), each with what it is. @@ -181,6 +182,70 @@ var lockedOff = []struct{ Name, What string }{ {defaultClientPref, "the ask to be the default mail client"}, {"mail.inappnotifications.enabled", "the vendor's in-app notices"}, {"mailnews.start_page.enabled", "the start page, loaded from the vendor at every start"}, + {missedPref, "the reminders of meetings that have passed (novox/hq issue 640)"}, +} + +// missedPref is Betterbird's pass over missed reminders. With it on, the calendar's alarm service fires, at +// every load of a calendar (each start, each refresh, each time Owl registers its calendar after signing in), +// every reminder of the past month whose item carries no later acknowledgement (X-MOZ-LASTACK); with it off +// (Betterbird's own default), a past reminder fires only while its meeting has not ended. Owl never sends a +// dismissal to Exchange (its changes carry the reminder's offset alone) and keeps it only in Betterbird's local +// cache, which a resynchronisation from scratch empties: a passed meeting's reminder then comes back at every +// reload, Dismiss or not (novox/hq issue 640). The policy locks it off, so no setting and no profile can turn the +// pass back on; betterbird_check says when the running Betterbird still has it on beside an Owl calendar. +const missedPref = "calendar.alarms.showmissed" + +// missedReminders says why the running Betterbird shows a passed meeting's reminder again at each reload of an +// Owl calendar, with the remedy, or two empty strings: Betterbird's pass over missed reminders on (the profile's +// user.js, which wins at the start, else its prefs.js; Betterbird's default is off) while the profile holds an +// Owl calendar and the placed policy did not lock it off before Betterbird started. +func (m *Machine) missedReminders(profile string) (string, string) { + if profile == "" { + return "", "" + } + prefs := mergedPrefs(profile) + owl := 0 + for _, c := range profileCalendars(prefs) { + if c.Kind == "owl" { + owl++ + } + } + if owl == 0 { + return "", "" + } + value, from := "false", "Betterbird's default" + for _, f := range []string{"user.js", "prefs.js"} { + b, err := readBounded(filepath.Join(profile, f)) + if err != nil { + continue + } + if v, ok := statementsOf(b)[missedPref]; ok { + value, from = v, "the profile's "+f + break + } + } + locked := m.locks()[missedPref] == "false" + ps := m.mainProcs() + if locked { + if len(ps) == 0 { + return "", "" + } + st, err := os.Stat(m.path(policyFile)) + t, terr := time.Parse(time.RFC3339, ps[0].Since) + if err != nil || terr != nil || !st.ModTime().After(t) { + return "", "" + } + } + if value != "true" || len(ps) == 0 { + return "", "" + } + what := fmt.Sprintf("Betterbird shows the reminders of meetings that have passed again at every reload of its %d Owl "+ + "calendar(s): %s is true (%s), and Owl keeps a dismissal only in Betterbird's local cache, never on the Exchange "+ + "server (novox/hq issue 640)", owl, missedPref, from) + if locked { + return what, "betterbird_restart: the module's policy locks " + missedPref + " off, and the running Betterbird started before it was placed" + } + return what, "push the module to this node: its policy locks " + missedPref + " off; then betterbird_restart" } // policyProblem is everything the policy fails to say of what ADR 0316 and issue 345 ask, or "". diff --git a/modules/betterbird/cmd/betterbird-tools/install_test.go b/modules/betterbird/cmd/betterbird-tools/install_test.go index ffbb2884..2a80c2ab 100644 --- a/modules/betterbird/cmd/betterbird-tools/install_test.go +++ b/modules/betterbird/cmd/betterbird-tools/install_test.go @@ -229,3 +229,79 @@ func mustRead(t *testing.T, f *fake, p string) []byte { } return raw } + +// owlCalendar is an Owl calendar in the profile's prefs.js, as Owl registers its account's calendar. +func (f *fake) owlCalendar(extra string) { + f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+ + `user_pref("calendar.registry.45b5.type", "owl");`+"\n"+ + `user_pref("calendar.registry.45b5.uri", "owl://someone%40example%2Eorg@outlook.example.org/");`+"\n"+ + `user_pref("calendar.registry.45b5.name", "someone@example.org");`+"\n"+extra) +} + +// A passed meeting's reminder shows at most once: the policy locks Betterbird's pass over missed reminders off, +// whatever the setting prefs or the profile say, because Owl keeps a dismissal only in Betterbird's local cache +// and the pass fires every unacknowledged reminder of the past month again at each reload (novox/hq issue 640). +func TestAPassedMeetingsReminderIsNotShownAgainAtEachReloadOfAnOwlCalendar(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "install", "distribution", "policies.json")) + if err != nil { + t.Fatal(err) + } + if p := policyPrefsOf(raw)[missedPref]; p.Value != "false" || !p.Locked { + t.Fatalf("the module's policy locks %s off: %+v", missedPref, p) + } + before := strings.Replace(string(raw), `"calendar.alarms.showmissed"`, `"calendar.alarms.unrelated"`, 1) + if why := policyProblem([]byte(before)); !strings.Contains(why, missedPref) { + t.Fatalf("a policy that leaves the missed reminders on is said: %q", why) + } + + f := newFake(t) + f.betterbird() + f.placedByTheMesh() + links(t, nil) + nag := func() Finding { + t.Helper() + c, err := f.Check() + if err != nil { + t.Fatal(err) + } + for _, x := range c.Findings { + if strings.Contains(x.What, "Owl calendar") { + return x + } + } + return Finding{} + } + // g14 on 2026-10-13: the module's user.js turns the pass on, an Owl calendar is in the profile, and the + // placed policy does not lock it. + f.write(policyFile, before) + f.owlCalendar("") + f.write(profileDir+"/user.js", mark+")\n"+`user_pref("calendar.alarms.showmissed", true);`+"\n") + if x := nag(); !strings.Contains(x.What, "1 Owl calendar") || !strings.Contains(x.What, "user.js") || + !strings.Contains(x.Do, "push the module") { + t.Fatalf("the nag is a finding with its remedy: %+v", x) + } + // The policy placed after the running Betterbird started: in effect from its next start. + f.write(policyFile, string(raw)) + later := time.Unix(1_900_000_000, 0) + os.Chtimes(filepath.Join(f.Root, policyFile), later, later) + if x := nag(); !strings.Contains(x.Do, "betterbird_restart") || strings.Contains(x.Do, "push") { + t.Fatalf("a lock placed after the start waits for a restart: %+v", x) + } + // The lock in effect: nothing the profile says turns the pass back on. + f.write(policyFile, string(raw)) + f.owlCalendar(`user_pref("calendar.alarms.showmissed", true);` + "\n") + if x := nag(); x.What != "" { + t.Fatalf("the lock holds whatever the profile says: %+v", x) + } + // Without the lock, the pass off is no nag, and neither is the pass on without an Owl calendar. + f.write(policyFile, before) + f.write(profileDir+"/user.js", mark+")\n"+`user_pref("calendar.alarms.showmissed", false);`+"\n") + if x := nag(); x.What != "" { + t.Fatalf("the pass off: %+v", x) + } + f.write(profileDir+"/user.js", mark+")\n"+`user_pref("calendar.alarms.showmissed", true);`+"\n") + f.write(profileDir+"/prefs.js", `user_pref("calendar.registry.c1.type", "caldav");`+"\n"+`user_pref("calendar.registry.c1.uri", "https://dav.example.org/c1/");`+"\n") + if x := nag(); x.What != "" { + t.Fatalf("a CalDAV calendar keeps its dismissals on its server: %+v", x) + } +} diff --git a/modules/betterbird/install/distribution/policies.json b/modules/betterbird/install/distribution/policies.json index 4edb6abe..600b9c7a 100644 --- a/modules/betterbird/install/distribution/policies.json +++ b/modules/betterbird/install/distribution/policies.json @@ -22,6 +22,10 @@ "mailnews.start_page.enabled": { "Value": false, "Status": "locked" + }, + "calendar.alarms.showmissed": { + "Value": false, + "Status": "locked" } } }