Betterbird keeps the active theme in its add-on database, and extensions.activeThemeID only mirrors it. So clearing the preference changed nothing, and the drift report then hid that. The theme is now read from extensions.json against default-theme@mozilla.org. Another theme is drift, and its remedy is the add-on manager: config_apply never clears it and keep refuses it. A credential's, an account's or a server's name in the defaults file is refused by code: it is never answered or cleared, and the check names it. Apply also refuses while the profile's lock is there, and syncs prefs.js before the rename. A saved default of a built-in preference is no longer drift.
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, ADR 0208, ADR 0316, ADR 0317,
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 declares the operator's mail accounts without their credentials.
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) 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 its user.js (setting prefs), accounts.json (setting accounts) and the record of adopted and created accounts |
~/.config/betterbird/ |
owned, whole |
the profile's user.js, placed by the tools |
the profile | the module's, or never touched |
the account lists in the profile's prefs.js |
the profile | written into: the module's keys only |
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 the created accounts into the account lists; 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 configuration this module ships
Betterbird's configuration comes in three layers (novox/hq issue 345). The operator approved the set on 2026-10-12. It is the laptop's setup, with the start page locked off, reminders missed while asleep shown at the next start, and chat off.
| layer | file | 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 |
| default | /opt/betterbird/defaults/pref/mesh-defaults.js (install/defaults-pref/) |
a catalogue change; or the operator in Betterbird's settings, where the change lands in the profile's prefs.js |
the operator's setup: what a fresh node gets without anyone touching it |
| node | the setting prefs, written to ~/.config/betterbird/user.js and placed in the profile's user.js |
the operator, per node, through the settings layers | a node's exceptions, such as a font size for a larger screen |
A locked value wins over everything. A node layer (user.js) wins over the profile's prefs.js, and
prefs.js wins over a default.
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). The Preferences policy accepts only some prefixes. privacy. and toolkit. are not
among them, so such a preference can only be a default.
Default (mesh-defaults.js, each line commented with why):
- display: compact density (
mail.uidensity0), 14-point interface text, and message bodies not darkened; - layout: the vertical view with multi-line rows, the list as cards, threads grouped;
- no confirmation on Shift+Delete;
- new mail: no pop-up, no sound, no tray icon, and alerts through the desktop's notifier if a node turns them on;
- calendar: the day from 09:00 to 20:00, deleting without asking, the node's time zone, reminders shown, and reminders missed while asleep shown at the next start;
- chat off;
- remote content blocked, and Do Not Track sent.
A line // keeps Betterbird's default: <name> names a preference the module manages without a value of its own.
For these the operator's setup is Betterbird's own default: the theme, system colours, always-underlined
links, manual spam marking, and the composer's attachment pane.
Drift. A default does not beat a value the profile saved in prefs.js. That value may be a choice made in
the settings window, or one the profile already held. betterbird_config shows each managed preference through
the layers, and flags drift: a saved value that beats the module's default without a node layer saying so.
betterbird_check reports it. There are two remedies:
betterbird_config_applywithapplytakes the drifting values out ofprefs.js, so the module's value holds at the next start. It acts only while Betterbird is quit (no main process, and nolockin the profile), because Betterbird rewritesprefs.jswhen it quits, and it says what it cleared. Withoutapplyit lists what it would clear.- The theme is the exception. Betterbird keeps the active theme in its add-on database (
extensions.json), andextensions.activeThemeIDonly mirrors it. So the tools read the theme from the add-on database, and the module's value isdefault-theme@mozilla.org. Another theme is drift whose remedy is to enable the default theme in the add-on manager.betterbird_config_applynever clears it, andkeeprefuses it.
Never an account. A name the tools withhold (a credential's, an account's, a server's), or an account's own
preference, is never managed, even when the defaults file lists it. betterbird_config leaves it out and names it
in refused, betterbird_config_apply never clears it, and betterbird_check says to take it out of the file.
betterbird_configwithkeepproposes, through the controller's settings verb, a node layer ofprefsthat holds the profile's value for this machine. The operator approves it on a channel that proves who answers. The tool never writesuser.jsitself. The setting is one string, so the layer carries whatprefssays on this machine today, with the kept values added.
The user.js: how a preference is declared
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 node-engine writes
~/.config/betterbird/user.jsfrom the module's settingprefs, one line ofuser_pref("name", value);statements (default: a comment, no preference); - the module's tools copy that file whole into the profile:
betterbird_user_jswithapply, orbetterbird_restart, which places it before it starts Betterbird again; - Betterbird reads it at that start.
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.
On the workstation measured these differ: Profile1 is marked Default=1, and the installation's
default, the one in use, is the other.
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 tools refuse a declared file that holds anything but comments and user_pref
statements, or that sets a preference whose name can hold a credential.
The file is trusted ("trusted": true): a preference can weaken how the mail client checks a server,
so the setting is changed only at the controller's terminal or proposed and set on the operator's warrant
(novox/hq issue 339, ADR 0277). An account's preferences (mail.account., mail.identity., mail.server.,
mail.smtpserver. and the account lists) are refused in prefs: an account is declared in accounts, so
it has one owner (ADR 0316 §11).
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):
- kept by its recorded keys, when the module recorded it on this node for this profile;
- its creation, when it is under the module's own keys (
account_mesh_<name>,id_mesh_<name>,server_mesh_<name>,smtp_mesh_<name>); - 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; - 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;
- created under the module's own keys when no account has it: its keys are added at the end of
mail.accountmanager.accountsandmail.smtpserversinprefs.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 through the controller's settings verb;
the operator reads it whole at their desk (settings proposals <id>) and approves on a channel that proves
who answers. Its answer to its caller holds counts and kinds only.
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).
Tools
They are served by the node's runtime as the operator account (ADR 0175). No answer carries mail, a calendar entry, an address, a host, an account's name or a credential: accounts are said by count, place and key.
| tool | does |
|---|---|
betterbird_status (r) |
whether it runs (pid, since, scope, children); what is installed and from where (the mesh's release, or the community package before the switch), its version and build beside the newest published build (Betterbird's own versions.txt); 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); the declared accounts by count and, per place, adopted, created or refused with their keys; the add-ons in the profile (ids, versions); the preferences that decide how its windows and notifications look |
betterbird_prefs (r) |
named preferences or prefixes (ending in . or *, at most 200; an empty name, * or . is refused): value and source (the policy's lock, user.js, prefs.js or the default), and the default itself with the file that gives it. Betterbird's own defaults are read from omni.ja (greprefs.js, defaults/pref/*.js) and the installation's defaults/pref, the module's from its files there. Of several files, the one first by name holds, as Gecko reads them. A preference compiled into Betterbird is said as built in. Withheld without its value: a name that can hold a credential (password, token, oauth, signon, …); one that names an account, server, calendar, recent folder or person (mail.server., mail.identity., mail.last_msg…, messenger.account., mail.cloud_files., calendar.registry., and of mailnews.* only a name that holds a server, account, identity or tag); and every extensions.* but the theme's id (extensions.activeThemeID). In every value answered, the home is ~, and a mail address, a user%40domain@host URI and a host name are masked |
betterbird_config (r; a with keep) |
each managed preference: its lock, the module's default (or Betterbird's, where the module keeps that), the node layer, the profile's saved value, the value in effect, and drift; with keep, a node layer holding the profile's values proposed for this machine |
betterbird_config_apply (r; a with apply) |
the drifting values listed; with apply, while Betterbird is quit, taken out of prefs.js, each said with the value it had and the one that now holds |
betterbird_user_js (r; a with apply) |
the declared user.js against the profile's; apply places it |
betterbird_log (r) |
the last lines of the journal of mesh-betterbird (or of the reminder test), addresses and credentials masked (the value after password=, token: and the like, and the token after Bearer/Basic). A Betterbird the module did not start prints elsewhere, and the answer says where |
betterbird_restart (a) |
ends Betterbird (asked to quit, forced after 8 s), places the user.js where allowed, starts it as mesh-betterbird. Refused while a composer window is open (X instance Msgcompose, whatever its class), because the unsent message would be lost, unless discard_unsent is true, which only the operator's yes justifies. It answers within the runtime's 30 s: a start not yet seen after 4 s is said in note, and betterbird_status shows it |
betterbird_check (r) |
installed from the mesh's release and not behind the newest published build; the policy placed, and the module's defaults with it; no managed preference drifting from them; no ask at the start to be the default mail client; Owl at its pin and active; one running; profile found; the user.js on the machine, valid, placed, in effect; the declared accounts in the list, none refused, each signed in once; its output read |
betterbird_accounts (r; a with propose) |
the profile's accounts by count and kind (declarable, Owl's, others); with propose, the setting accounts built from them and proposed to the operator (ADR 0317 §9) |
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_windowswith classCalendaranswers the reminder window: roleAlarmWindow, typeDialog, its depth (32: a visual with alpha, which the compositor blends), its map state;xorg_screenshotwith 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/ |
user.js, accounts.json (no account until the setting accounts 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 module's defaults wherever the profile saved no value of its own; a saved value is drift, said by betterbird_check, until betterbird_config_apply or a node layer |
Relies on
- A display server on the same machine (
x11-display, ADR 0208 §3). xorgforxorg_windowsandxorg_screenshot, which look at Betterbird's windows. This module carries no window code of its own.systemd-runandjournalctlof 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
settingsverb, forbetterbird_accountswithpropose(invokes).