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" } } }