Files
mesh-catalog/modules/betterbird
jochen f1f866050e
mesh/merge-gate head f1f866050eee merged into trunk e209d46faf3d — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head f1f866050eee merged into trunk e209d46faf3d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Revert the showmissed lock of #277: the operator wants no special handling in betterbird's config (hq issue 640)
The fix is the approved settings layer (proposal s09bf8c99da7ac8b9:
calendar.alarms.showmissed false), pushed to g14 and shanks.
2026-10-13 16:54:51 +02:00
..

betterbird

Betterbird, the mail and calendar client of g14 and shanks (a Thunderbird fork, with the operator's Exchange calendar through the Owl add-on), as a module (novox/hq issue 345, issue 586, ADR 0208, ADR 0316, ADR 0317, ADR 0329, to-be 56). It requires x11-display, so it is assigned only where a display server is held on the same machine. It installs Betterbird from a pinned upstream release, ships Owl with it through Betterbird's enterprise policy, and owns Betterbird's whole configuration as settings: the preferences, the mail accounts without their credentials, the identities with their signatures, the CalDAV calendars, the CardDAV address books, the filters and the tags.

It exists because a symptom had no tools: on a workstation, a calendar reminder appeared as a transparent window with nothing in it (2026-10-09). Nothing in the mesh could say which window that was, how the compositor treats it, or which preferences Betterbird ran with. The diagnosis is in hq issue 345.

Owns

what where class
Betterbird, the upstream release (archive, pinned by URL and sha256) /opt/betterbird owned, whole
the enterprise policy (install/distribution/policies.json) /opt/betterbird/distribution owned, whole
Owl's xpi (owl, pinned by the vendor's sha256) /opt/betterbird/owl owned, whole
the module's defaults (install/defaults-pref/mesh-defaults.js: the preferences kept at Betterbird's own default, no value of the operator's) and the spelling dictionaries' default (vendor-prefs.js) /opt/betterbird/defaults/pref owned: its two files
the tools' program, for the start's preparation /usr/local/lib/mesh-betterbird owned, whole
the start /usr/local/bin/betterbird owned, whole
the launcher entry /usr/local/share/applications/eu.betterbird.Betterbird.desktop owned, whole
the module's directory, with the settings as the node-engine writes them (prefs.json, accounts.json, configuration.json, tags.json), the records of what was adopted or created, and the filter files it replaced (kept/) ~/.config/betterbird/ owned, whole
the profile's user.js, placed by the tools the profile the module's, or never touched
the lists in the profile's prefs.js (accounts, outgoing servers, an adopted account's identities, the calendars' order), and the preferences of what the module created and the settings no longer hold the profile written into: the module's keys only, only while Betterbird is closed
each declared account's msgFilterRules.dat (setting filters) the account's directory in the profile owned, whole, only while Betterbird is closed
the profile's own values of the preferences the module manages, in prefs.js the profile cleared only by betterbird_config_apply with apply, Betterbird quit
the profile: mail, address books, calendars, logins ~/.thunderbird/ found (ADR 0182)

The community package betterbird-bin is declared absent (ADR 0180), before anything is placed in /opt/betterbird: the node-engine removes it with pacman -R, then unpacks the release where the package had it. Betterbird chooses its profile by its installation's path, so the same profile opens after the switch. A package left in place (its removal failed) holds the path, and the release is refused there by the node-engine rather than mixed in. The module creates no link: the start, a launcher entry naming the icon in the installation, and the dictionaries as a default preference replace the package's links.

Every new build waits for the operator (upgrade: record): the first push to a node removes the package and replaces what a running Betterbird has open, so the operator quits Betterbird, then pushes. An upgrade is a change to the pins, reviewed and delivered like any other (ADR 0316 §5).

The pins. The release is betterbird-153.4.0esr-bb10.en-US.linux-x86_64.tar.xz (81,620,684 bytes) and Owl is 1.5.6.1 (430,201 bytes), each pinned by sha256 in module.json. The build seat fetches each once, refuses it when the digest differs, keeps it under its sha256, and packs it into the artifact store; a node receives the mesh's archive by digest and never asks the vendor. Owl is never in this repository: its licence forbids copying it to third parties, and its vendor serves only its newest version, at one address. Keeping the pinned copy in the mesh's private artifact store for the operator's own machines was the operator's call (2026-10-12, ADR 0316 §6).

Owl's range. Owl declares which Betterbird versions it supports (strict_min_version to strict_max_version: 115 to 156 for 1.5.6.1). owlSupports in manifest_test.go holds the range read from each pinned xpi, and the module's tests refuse a Betterbird pin outside it: moving Owl's pin means reading the new range there (ADR 0316 §8).

The policy turns Betterbird's own updater off (DisableAppUpdate, which Betterbird's update check honours) and installs Owl into every profile from file:///opt/betterbird/owl/owl.xpi as force_installed, with updates_disabled. A profile that already runs another Owl keeps it: the policy installs the pinned one where Owl is missing. A newer Owl in the profile (g14 runs 1.5.7) is said as a fact by betterbird_status; Owl installed by policy cannot be removed in Betterbird, so the remedy is moving the pin. An older one is a finding of betterbird_check, with the same remedy.

Betterbird never asks to be the default client. The policy locks mail.shell.checkDefaultClient off (Preferences, Status: locked): Betterbird's own default (all-thunderbird.js) is to ask at every start, with a window to make it the default for mail, news, feeds and calendar files, while that preference is true (messenger.js). Betterbird 153 has no policy of its own for it (its policies-schema.json), and its Preferences policy accepts the mail. prefix. A lock, not a default: the window's own "always check" box writes the preference into the profile, which wins over a default; a lock wins over the profile and greys the box out in the settings. betterbird_check says when Betterbird would ask (novox/hq issue 345). Owl's licence stays the person's: the mesh holds no licence, key or payment (ADR 0316 §9).

No start of its own. Betterbird is started by the operator from the launcher. The start (/usr/local/bin/betterbird) first runs betterbird-tools prepare, which, with no Betterbird of the account running, places the user.js and writes what Betterbird rewrites itself (the lists, the removals, the filter files); then it starts Betterbird either way. betterbird_restart starts it as the transient user unit mesh-betterbird, so its output then goes to the journal.

The preferences: locks, the setting prefs, and Betterbird's own defaults

Betterbird's preferences come in three layers (novox/hq issue 345, issue 586). The operator's own choices live in the module's configuration, never in the catalogue: the module ships only its policy's locks and the names it keeps at Betterbird's own default.

layer where who changes it holds
locked /opt/betterbird/distribution/policies.json (install/distribution/) only a reviewed catalogue change what must never differ on a node; Betterbird's settings show it greyed out
declared the setting prefs, mesh-wide with a node's layer over it, written to ~/.config/betterbird/prefs.json and placed in the profile's user.js the operator, through the settings layers (trusted: the terminal or the operator's warrant) the operator's setup, and a node's exceptions, such as a font size for a larger screen
Betterbird's default Betterbird's own defaults; mesh-defaults.js names those the module manages there a catalogue change the preferences kept at Betterbird's default

A locked value wins over everything; a declared one (in user.js) wins over the profile's prefs.js at every start; prefs.js wins over a default.

The setting prefs is one entry per preference (issue 586): {"prefs": {"mail.uifontsize": 14, …}}, the 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 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.

The order that leaves no node on Betterbird's own defaults (issue 586): merge this build (the record upgrade policy holds it back from the nodes); propose the mesh-wide layer through the controller's settings verb with propose, carrying the current accounts verbatim and prefs with the 21 values; the operator approves; only then push the desktop nodes. Caveat: between that approval and a node's push, any other send to a desktop node writes the map's text into the old build's user.js, and betterbird_check there says the user.js is 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: 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).

Kept at Betterbird's default (// keeps Betterbird's default: <name> in mesh-defaults.js): the theme, system colours, always-underlined links, manual spam marking, and the composer's attachment pane. A value the profile saved for one of them is drift.

Drift. betterbird_config shows each managed preference (locked, declared, kept) through the layers, and flags drift: a declared value the profile's user.js does not hold yet (remedy: betterbird_user_js with apply, then betterbird_restart), or a value the profile saved where the module keeps Betterbird's default. betterbird_check reports it.

  • betterbird_config_apply with apply takes a kept preference's saved value out of prefs.js, so Betterbird's default holds at the next start. It acts only while Betterbird is quit (no main process, and no lock in the profile), and says what it cleared.
  • The theme is the exception. Betterbird keeps the active theme in its add-on database (extensions.json), and extensions.activeThemeID only mirrors it. Another theme is drift whose remedy is to enable the default theme in the add-on manager. betterbird_config_apply never clears it, and keep refuses it.
  • betterbird_config with keep proposes this node's layer of prefs holding the profile's values of the named preferences beside what the layer holds, through the controller's settings verb, for the operator's Approve.

One owner per value. prefs refuses a preference whose name can hold a credential, an account's (mail.account., mail.identity., mail.server., mail.smtpserver. and the account lists, ADR 0316 §11), and those the settings below own: calendar.registry., calendar.list., ldap_2.servers. and mailnews.tags. (ADR 0329 §6).

The user.js

Betterbird reads user.js in its profile when it starts, and a preference set there wins over the one it saved in prefs.js. The profile's directory has a random name per machine (<random>.default-default), which a manifest cannot name. So the tools compose the user.js from the settings — prefs, then the declared accounts, then the declared identities, calendars, address books and tags under the keys each resolved to — and place it in the profile: at the start from the launcher, betterbird_user_js with apply, or betterbird_restart.

The profile is found as the one the running Betterbird holds open; with none running, as the installation's default in profiles.ini ([Install…] Default), else the profile marked Default=1.

A user.js the module did not write is never overwritten. The module's begins with the line // Betterbird's user.js (module betterbird; any other is the operator's, and apply refuses with what to do.

The accounts: declared without credentials (ADR 0316 §10–§16, ADR 0317)

The setting accounts reaches the machine as ~/.config/betterbird/accounts.json, a trusted mergeable file: a map of accounts by name, each with address, from-name and label (optional), incoming (kind imap or pop3, host, port, security tls, starttls or none, sign-in password, encrypted-password, oauth, kerberos, ntlm or tls-certificate, user) and outgoing (the same, without a kind; its sign-in may be none), and, only in a node's own layer, key. Never a password or a token: the whole file is refused when any key in it is shaped like one, and an account signs in at its first use, in Betterbird's own window, where Betterbird keeps the result in its login store. The work account through Owl is never declared: the person creates it in Owl's setup (ADR 0316 §13).

At every apply (the start from the launcher, betterbird_user_js with apply, betterbird_restart), each declared account, in its place in the setting, is (ADR 0317 §1):

  1. kept by its recorded keys, when the module recorded it on this node for this profile;
  2. its creation, when it is under the module's own keys (account_mesh_<name>, id_mesh_<name>, server_mesh_<name>, smtp_mesh_<name>);
  3. adopted by the key a node's layer names (key), when that account is in the list, its default identity has the address and it is not Owl's;
  4. adopted by its address, when exactly one account's default identity has it: its keys (account, default identity, incoming server, and the outgoing server that identity names) become the module's, and the account lists are left as they are;
  5. created under the module's own keys when no account has it: its keys are added at the end of mail.accountmanager.accounts and mail.smtpservers in prefs.js, every other key kept in its place, only while Betterbird is not running.

Refused, for that account alone and with the remedy (§7): two accounts with the address (remove the duplicate in Betterbird, or name the key), Owl's account at the address, a named key that does not qualify, an account of another kind than declared (never converted), an identity that names no outgoing server, and an outgoing server that another identity, neither adopted nor the module's, also uses (Betterbird lets identities share one, and the declared values would change that other account's sending; ADR 0317 §2 leaves the identity's server and the lists as they are, so the module refuses rather than give it a server of its own). A recorded adoption follows the identity's current outgoing server at every apply, under the same rule. The other accounts go ahead; the start and betterbird_check say it. betterbird_user_js answers an account's preferences by name with their values withheld.

The declared fields are written in the user.js under the account's keys, so they win at every start; only the fields the setting names, never the account's directory, folders, filters, caches, mail or kept sign-in, so an adopted account keeps its sign-in (§5). The apply names, by preference name only, each one whose value it changed (§4). What was adopted or created is recorded in ~/.config/betterbird/accounts-record.json. Leaving the setting releases an adopted account (its lines and its record go, the account stays the person's) and takes a created account's keys out of the lists (its mail stays in the profile).

The value is derived, not retyped (§9): betterbird_accounts with propose builds accounts from the profile (every account but Owl's, no credential) and proposes it in the mesh-wide layer as it stands, through the controller's settings verb; the operator approves on a channel that proves who answers. Its answer shows the value it proposed (ADR 0329 §10).

A node with no profile yet. The preparation needs the profile Betterbird makes at its first start: on a node set up from nothing, the declared accounts are written at the second start from the launcher (or at betterbird_restart).

Identities, calendars, address books, filters and tags (ADR 0329)

Five more settings, mesh-wide with a node's layer over each (an entry set to null in a node's layer is not on that node). identities, calendars, address-books and filters reach the machine as ~/.config/betterbird/configuration.json, trusted as accounts is; tags as tags.json, not trusted (§5). No value holds a password, a token or a cookie: a key shaped like one is refused, and so is an address whose user part carries a password (§2). A setting holds no line break, so a signature is a list of its lines.

setting an entry matched in the profile by
identities by a name: account (its name in accounts), and for another identity than the account's default address and name; reply-to, organization, signature (lines), signature-html, signature-above-quote; key in a node's layer the account's default (no address), or an identity of that account with the address
calendars by a name: name, url (CalDAV), color, read-only, reminders, user, invitations (an identities entry); key in a node's layer its address
address-books by a name: name, url (CardDAV), user; key in a node's layer its address
tags by its key: name, color, order its key
filters by an account's name: the ordered list, each with name, enabled, when (new-mail, manual, after-junk, sending, archiving, periodic), match (all, any, every-message), conditions (field, op, value, in Betterbird's words) and actions (action in Betterbird's words; folder as a path within the account, or value) owned whole per account

Adopted as accounts are (§7): an entry recorded on this node is kept by its key; one under the module's own key (ident_mesh_<name>, mesh_<name> for a calendar or an address book) is its creation; a key in the node's layer is adopted when the profile holds it and it matches; else one match is adopted, none is created under the module's key, and several are refused with the remedy. The declared fields go into the user.js under that key. An entry leaving the setting releases what was adopted (its lines go) and removes the preferences of what was created, never a calendar's events, an address book's contacts or a cache. An entry no setting declares stays the person's. Owl's accounts and calendars, an ICS calendar (its address is often the secret), local calendars and address books are never declared (§13).

Filters are owned whole per declared account (§8): the module writes that account's msgFilterRules.dat from the list, each folder's path turned into its address on this node, so a filter added in Betterbird is gone at the next apply (drift shows it first). Before its first write it keeps the replaced file in ~/.config/betterbird/kept/<profile>/; when the account leaves filters, the module stops writing and the file stays the person's; betterbird_filters says where the kept copy is. A filter a setting cannot hold as it is (a folder of another account, an add-on's action, a news filter) is named and not derived.

Written only while Betterbird is closed (§9): the lists in prefs.js and the filter files, because Betterbird rewrites them as it runs and quits; the user.js is placed as before. betterbird_check counts per kind the changes that wait.

Derived, then proposed one layer at a time (§10): betterbird_derive asks every desktop node holding Betterbird for what its profile holds. An entry on every such node goes into the mesh-wide layer with the value of the node the derive was called on, and the answer lists each field another node holds differently (which then shows as drift there); one on some nodes only goes into those nodes' own layers. An account whose filter file holds no filter is not put in filters. With propose, the next layer (the mesh-wide one, then each node's) is proposed with the layer as it stands beside the derived kinds; never while a proposal of the module's waits, and never while a desktop node does not answer. prefs on the call carries values of the setting prefs into the mesh-wide layer. A key is never proposed for the mesh-wide layer.

Tools

They are served by the node's runtime as the operator account (ADR 0175). They show the operator's own configuration with its values, and never a credential (ADR 0329 §1, §2): every answer passes the module's credential check, which withholds a field whose name says password, token, secret or cookie and the password part of any address. No answer carries mail, a calendar entry or a contact.

tool does
betterbird_status (r) whether it runs (pid, since, scope, children); what is installed and from where, its version beside the newest published build; the policy; Owl's version against the pin and whether it is active; where its output goes; the profile and how it was found; the user.js (declared, placed, in effect, with every declared entry as it resolved); the declared accounts by count; the add-ons; the preferences that decide how its windows and notifications look
betterbird_prefs (r) named preferences or prefixes: value and source, and the default with the file that gives it. Withheld without its value: a name that can hold a credential, one that names an account, server, calendar or person, and every extensions.* but the theme's id. Values masked (the home as ~, addresses and hosts)
betterbird_config (r; a with keep) each managed preference: its lock, the declared value, Betterbird's default where the module keeps that, the profile's saved value, the value in effect, and drift; with keep, this node's layer of prefs proposed with the profile's values
betterbird_config_apply (r; a with apply) a kept preference's drifting values listed; with apply, while Betterbird is quit, taken out of prefs.js
betterbird_user_js (r; a with apply) the user.js composed from the settings against the profile's; apply places it, writes what Betterbird rewrites while it is closed, and counts what waits
betterbird_log (r) the last lines of the journal of mesh-betterbird (or of the reminder test), addresses and credentials masked
betterbird_restart (a) ends Betterbird (asked to quit, forced after 8 s), places the user.js and writes what waited, starts it as mesh-betterbird. Refused while a composer window is open, unless discard_unsent is true, which only the operator's yes justifies
betterbird_check (r) installed from the mesh's release; the policy and the module's defaults placed; no managed preference drifting; no ask to be the default mail client; Owl at its pin and active; one running; profile found; the user.js valid, placed, in effect; the declared accounts in the list, none refused, each signed in once; per kind, the declared entries drifting or refused and the changes waiting for Betterbird to close; its output read
betterbird_accounts (r; a with propose) the profile's accounts with their values and the declared account each is, Owl's by name and address and marked; with propose, accounts built from them and proposed in the mesh-wide layer as it stands
betterbird_identities, betterbird_calendars, betterbird_address_books, betterbird_filters, betterbird_tags (r) the profile's entries with their keys and values beside the declared ones and how each resolved (adopted, created or refused with why); an ICS calendar by its name only, Owl's marked; betterbird_filters also where the kept filter files are
betterbird_derive (r; a with propose) the five kinds derived from the desktop nodes' profiles, per layer, with the fields another node holds differently and what was left out; with propose, the next layer proposed; here answers this node's profile, as another node asks it
betterbird_drift (r) per kind and declared entry: holds, differs (each field with both values), lacks, or refused; and the profile's entries nothing declares
betterbird_reminder_test (a) a reminder on demand without the operator's calendar (below)

A reminder on demand

It opens windows on the operator's screen, and its description says so first, so a caller tells the operator before calling it. On 2026-10-12 at 12:05 a test Betterbird with an empty profile carrying the operator's theme looked like the operator's configuration lost; so the test is visibly a test: its profile carries a userChrome.css that frames every window of the test Betterbird in red and runs a red band reading MESH TEST across its top, and its event is called "MESH TEST reminder (a throw-away Betterbird of the mesh, not your calendar)". The reminder window itself is left unstyled, because how Betterbird draws it is what the test looks at; its title is the event's. A window title cannot be set by a preference, so the marking is the band, the frame and the event's name.

betterbird_reminder_test starts a second Betterbird with --no-remote in a throw-away profile under /run/user/<uid>/mesh-betterbird-reminder-test/, as the user unit mesh-betterbird-reminder-test (ended after 10 minutes at most). The profile holds the module's user.js (the setting prefs; never a declared account) and one calendar: a local .ics file with one event, "MESH TEST reminder", whose reminder is due after seconds from the call (default 40). carry copies named preferences of the operator's profile into it (a theme, a colour switch), under the same limits as betterbird_prefs. The test's own preferences come last in its user.js, so nothing declared or carried overrides them; one of them turns Betterbird's update check off (betterbird.update.checkIntervalDays 0), because a fresh profile checks at its start and, when a newer build is published, opens the About window on the operator's screen (seen 2026-10-12). Then:

  • xorg_windows with class Calendar answers the reminder window: role AlarmWindow, type Dialog, its depth (32: a visual with alpha, which the compositor blends), its map state;
  • xorg_screenshot with that window shows it as the screen shows it.

The unit removes its profile when it ends, however it ends (ExecStopPost): at its 10 minutes, on stop, or when the test window is closed. The test Betterbird is never counted as the operator's: its command line names its own profile, and status, check, restart and the user.js leave it out.

The running Betterbird, its profile and its calendars are not touched: --no-remote keeps the two apart. profiles.ini and installs.ini are not rewritten either, measured on the workstation (2026-10-09): their content and modification time (June 2023) were the same before a --profile start, while it ran, and after it ended.

The reminder window, as measured (2026-10-09, workstation, i3 and picom with glx)

X class / instance eu.betterbird.Betterbird / Calendar
role, type AlarmWindow, _NET_WM_WINDOW_TYPE_DIALOG (i3 floats it)
depth 32 (ARGB visual), like every Betterbird window there
size 600×300, kept in the profile's xulstore.json

A window of depth 32 that its program has not drawn is transparent under a compositor, not black: that is why the symptom looks the way it does. Why Betterbird did not draw it is hq issue 345's question.

What changes when it is pushed

The module is assigned to shanks today (look-only, from issue 345's first part); g14 runs Betterbird outside the mesh. With this version, a push to a node (each waits for the operator: upgrade: record):

on a node with the community package (g14, shanks)
the package betterbird-bin removed (quit Betterbird first)
/opt/betterbird the mesh's 153.4.0esr-bb10, in the same place, so the same profile opens
the policy, Owl placed; Owl installed into the profile by Betterbird at its next start where it is missing
/usr/local the start, the launcher entry, the tools' program
~/.config/betterbird/ prefs.json, accounts.json, configuration.json, tags.json (each empty until its setting is approved)
the profile untouched until the next start from the launcher, betterbird_user_js with apply, or betterbird_restart
the configuration the policy's locks at Betterbird's next start; the settings in the user.js from the next start. Pushed in the order above, the operator's 21 preferences are in prefs before the build arrives; pushed before that layer is approved, Betterbird runs with its own defaults for them until it is

Relies on

  • A display server on the same machine (x11-display, ADR 0208 §3).
  • xorg for xorg_windows and xorg_screenshot, which look at Betterbird's windows. This module carries no window code of its own.
  • systemd-run and journalctl of the account's user manager.
  • The build seat's archive pinned by URL and sha256 (mesh-controller, ADR 0316 §2): a controller that does not know it refuses this manifest.
  • The controller's settings verb, for every proposal and to read a layer as it stands; its nodes and node verbs, for which machines hold the module; and the module's own betterbird_derive on those machines (invokes).