Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46c46c955b | ||
|
|
1ca26c944a | ||
|
|
96e1792881 | ||
|
|
cb9b66e628 |
+147
-32
@@ -1,8 +1,10 @@
|
||||
# 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). It requires
|
||||
`x11-display`, so it is assigned only where a display server is held on the same machine.
|
||||
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
|
||||
@@ -11,24 +13,57 @@ issue 345.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the module's directory | `~/.config/betterbird/` |
|
||||
| the module's `user.js`, filled from the setting `prefs` | `~/.config/betterbird/user.js` |
|
||||
| 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 spelling dictionaries' default (`install/defaults-pref/vendor-prefs.js`) | `/opt/betterbird/defaults/pref` | owned: its one file |
|
||||
| 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: mail, address books, calendars, logins | `~/.thunderbird/` | found (ADR 0182) |
|
||||
|
||||
Nothing else. It holds no seat and adds no start.
|
||||
**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. **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.
|
||||
|
||||
- **No package.** g14 and shanks both run `betterbird-bin` 153.1.0esr-1, installed explicitly from the
|
||||
AUR: `pacman` counts it as foreign. The node-engine installs packages from the official
|
||||
repositories only, so the module does not declare it, and **installing and upgrading Betterbird stay
|
||||
the operator's** (an AUR helper). `betterbird_status` says the version and that it is from the AUR;
|
||||
`betterbird_check` says when it is missing.
|
||||
- **No start.** Betterbird is started by the operator from the launcher (on the workstation measured,
|
||||
it runs in the login session's scope, started by hand, with no autostart entry and no line in i3's
|
||||
configuration). `betterbird_restart` starts it as the transient user unit `mesh-betterbird`, so its
|
||||
output then goes to the journal.
|
||||
- **The profile is Betterbird's** (ADR 0182): `~/.thunderbird/` holds the mail, the address books,
|
||||
the calendars' caches, the logins and the keys. The module declares none of it.
|
||||
|
||||
## The user.js: how a preference is declared
|
||||
|
||||
@@ -52,33 +87,104 @@ default, the one in use, is the other.
|
||||
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** (it carries no `"trusted": false`): a preference can weaken how the mail client
|
||||
checks a server, so the setting is changed only at the controller's terminal, never through a verb
|
||||
(novox/hq issue 339).
|
||||
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):
|
||||
|
||||
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 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 account, an address or a credential.**
|
||||
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), the package and version and that it is from the AUR, where its output goes, the profile and how it was found, the user.js (declared, placed, in effect), the add-ons the operator installed (ids), and the preferences that decide how its windows and notifications look |
|
||||
| `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 (user.js, prefs.js or the default). 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 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_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; one running; profile found; the user.js on the machine, valid, placed, in effect; its output read |
|
||||
| `betterbird_check` (r) | installed from the mesh's release and not behind the newest published build; the policy placed; 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 and one calendar: a local `.ics` file with one event, "Mesh reminder test", whose reminder is
|
||||
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`. Then:
|
||||
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;
|
||||
@@ -106,13 +212,19 @@ A window of depth 32 that its program has not drawn is transparent under a compo
|
||||
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 assigned
|
||||
## What changes when it is pushed
|
||||
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| files | `~/.config/betterbird/` and its `user.js` (no preference) | the same |
|
||||
| the profile | untouched until `betterbird_user_js` with `apply` or `betterbird_restart` | the same; the profile has no user.js today, so `apply` may place one |
|
||||
| Betterbird | keeps running as started | keeps running as started (since 2026-10-04, its output on the session's pipe nobody reads: `betterbird_check` names it until `betterbird_restart`) |
|
||||
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` |
|
||||
|
||||
## Relies on
|
||||
|
||||
@@ -120,3 +232,6 @@ question.
|
||||
- **`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 `betterbird_accounts` with `propose` (`invokes`).
|
||||
|
||||
@@ -0,0 +1,811 @@
|
||||
package main
|
||||
|
||||
// The operator's mail accounts, declared without credentials (novox/hq ADR 0316 §10–§14, to-be 56 §4).
|
||||
//
|
||||
// The setting `accounts` reaches the machine as ~/.config/betterbird/accounts.json, a file the node-engine
|
||||
// writes from the module's settings layers. Per account it names the address, the incoming server and the
|
||||
// outgoing server; never a password or a token, and the whole file is refused when any key in it is shaped
|
||||
// like one. Betterbird's accounts are preferences, so a declared account becomes:
|
||||
//
|
||||
// - its declared fields as preferences in the user.js the module places, where they win at every start
|
||||
// (§11): under the keys of the account the profile already holds at that address, which the module adopts
|
||||
// (novox/hq ADR 0317), or, where the profile holds none, under keys of the module's own that end in
|
||||
// mesh_<name> (account_mesh_<name>, id_mesh_<name>, server_mesh_<name>, smtp_mesh_<name>);
|
||||
// - for an account the module created, its key in the account list (mail.accountmanager.accounts) and in the
|
||||
// list of outgoing servers (mail.smtpservers) of the profile's prefs.js: written into, never owned, and
|
||||
// only while Betterbird is not running, because it rewrites prefs.js when it quits (§12). Every other key
|
||||
// keeps its place, and an adopted account's keys are in the lists already.
|
||||
//
|
||||
// Which account is adopted, created or refused is adoption.go's.
|
||||
//
|
||||
// Nobody's password passes through here: an account signs in at its first use, in Betterbird's own window,
|
||||
// and Betterbird keeps the result in its login store (§14). Of that store the module reads only the server
|
||||
// names each entry is kept for, which Betterbird writes in the clear, to count the declared accounts that
|
||||
// have not signed in yet (§16); never a user name or a password, which are encrypted there anyway.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// accountsFile is the setting accounts on the machine, written by the node-engine.
|
||||
const accountsFile = ".config/betterbird/accounts.json"
|
||||
|
||||
// Account is one declared mail account, in the shape of the setting accounts: keyed by its name.
|
||||
type Account struct {
|
||||
Address string `json:"address"`
|
||||
// FromName is the name the account's mail is sent under. Absent: Betterbird's, none.
|
||||
FromName string `json:"from-name,omitempty"`
|
||||
// Label is what Betterbird shows the account as. Absent: its address.
|
||||
Label string `json:"label,omitempty"`
|
||||
Incoming Server `json:"incoming"`
|
||||
Outgoing Server `json:"outgoing"`
|
||||
// Key is the profile's account key to adopt, given only in a node's own layer to settle an address the
|
||||
// profile holds more than once (novox/hq ADR 0317 §8).
|
||||
Key string `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
// Server is an account's incoming or outgoing server: never a password or a token.
|
||||
type Server struct {
|
||||
// Kind is imap or pop3, for the incoming server; the outgoing one is smtp and says nothing.
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Security string `json:"security"`
|
||||
SignIn string `json:"sign-in"`
|
||||
User string `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// The words a server's connection security and sign-in method are said in, and Betterbird's numbers for
|
||||
// them (socketType / try_ssl, authMethod).
|
||||
var (
|
||||
securities = map[string]int{"none": 0, "starttls": 2, "tls": 3}
|
||||
signIns = map[string]int{"none": 1, "password": 3, "encrypted-password": 4, "kerberos": 5, "ntlm": 6,
|
||||
"tls-certificate": 7, "oauth": 10}
|
||||
)
|
||||
|
||||
func wordFor(words map[string]int, n int) string {
|
||||
for w, v := range words {
|
||||
if v == n {
|
||||
return w
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
accountName = regexp.MustCompile(`^[a-z][a-z0-9]{0,30}$`)
|
||||
addressShape = regexp.MustCompile(`^[^\s@"\\]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$`)
|
||||
hostShape = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9.\-]*[A-Za-z0-9])?$`)
|
||||
profileKey = regexp.MustCompile(`^[A-Za-z0-9_]{1,64}$`)
|
||||
// plainText is a value written into a preference: no quote, backslash or line break.
|
||||
plainText = regexp.MustCompile(`^[^"\\\n\r]*$`)
|
||||
// secretKey is a key the accounts file may never carry, at any depth: the setting holds no credential.
|
||||
secretKey = regexp.MustCompile(`(?i)(pass|secret|token|oauth.?refresh|refresh|credential|api.?key|private.?key|cookie|bearer)`)
|
||||
)
|
||||
|
||||
// keysOf are the module's own keys for a declared account: each ends in mesh_<name>.
|
||||
func keysOf(name string) (account, identity, server, smtp string) {
|
||||
return "account_mesh_" + name, "id_mesh_" + name, "server_mesh_" + name, "smtp_mesh_" + name
|
||||
}
|
||||
|
||||
// ownKey is a key of an account list the module wrote, and the name it was declared under.
|
||||
var ownKey = regexp.MustCompile(`^(?:account|smtp)_mesh_([a-z][a-z0-9]{0,30})$`)
|
||||
|
||||
// readAccounts reads the declared accounts. A missing file is no account (the module is older here); a file
|
||||
// that carries a key shaped like a credential, an unknown field or a value that cannot be written is refused
|
||||
// whole, naming the key and never a value.
|
||||
func (m *Machine) readAccounts() (map[string]Account, bool, error) {
|
||||
raw, err := readBounded(m.home(accountsFile))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return map[string]Account{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
accounts, err := parseAccounts(raw)
|
||||
return accounts, true, err
|
||||
}
|
||||
|
||||
func parseAccounts(raw []byte) (map[string]Account, error) {
|
||||
var tree any
|
||||
if err := json.Unmarshal(raw, &tree); err != nil {
|
||||
return nil, fmt.Errorf("~/%s is not JSON: %v", accountsFile, err)
|
||||
}
|
||||
if where := secretKeyIn(tree, ""); where != "" {
|
||||
// An account's name is said by nothing: its segment of the path is withheld.
|
||||
if parts := strings.SplitN(where, ".", 3); len(parts) == 3 && parts[0] == "accounts" {
|
||||
where = "accounts.<an account>." + parts[2]
|
||||
}
|
||||
return nil, fmt.Errorf("the setting accounts carries %s, a key shaped like a credential: an account is declared "+
|
||||
"without its password or token, which it signs in with at its first use (novox/hq ADR 0316 §10, §14). "+
|
||||
"Take it out at the controller's terminal", where)
|
||||
}
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &top); err != nil {
|
||||
return nil, fmt.Errorf("~/%s is not an object: %v", accountsFile, err)
|
||||
}
|
||||
accounts := map[string]Account{}
|
||||
if body, ok := top["accounts"]; ok && string(body) != "null" {
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&accounts); err != nil {
|
||||
return nil, fmt.Errorf("the setting accounts is not a map of accounts by name: %v", err)
|
||||
}
|
||||
}
|
||||
var problems []string
|
||||
names := sortedNames(accounts)
|
||||
for i, name := range names {
|
||||
problems = append(problems, accountProblems(fmt.Sprintf("declared account %d of %d", i+1, len(names)),
|
||||
name, accounts[name])...)
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return nil, fmt.Errorf("the setting accounts is refused: %s", strings.Join(problems, "; "))
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
// secretKeyIn is the path of the first key shaped like a credential in a JSON tree, or "".
|
||||
func secretKeyIn(node any, at string) string {
|
||||
switch v := node.(type) {
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
here := k
|
||||
if at != "" {
|
||||
here = at + "." + k
|
||||
}
|
||||
if secretKey.MatchString(k) {
|
||||
return here
|
||||
}
|
||||
if w := secretKeyIn(v[k], here); w != "" {
|
||||
return w
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for i, x := range v {
|
||||
if w := secretKeyIn(x, fmt.Sprintf("%s[%d]", at, i)); w != "" {
|
||||
return w
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortedNames(accounts map[string]Account) []string {
|
||||
names := make([]string, 0, len(accounts))
|
||||
for n := range accounts {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// accountProblems is what is wrong with one declared account, said of it by its place (label) and never by a
|
||||
// value of it, its name included (novox/hq ADR 0316 §16).
|
||||
func accountProblems(label, name string, a Account) []string {
|
||||
var out []string
|
||||
say := func(format string, args ...any) { out = append(out, label+": "+fmt.Sprintf(format, args...)) }
|
||||
if !accountName.MatchString(name) {
|
||||
say("an account's name is lower-case letters and digits, starting with a letter (it names the module's keys, mesh_<name>)")
|
||||
}
|
||||
if !addressShape.MatchString(a.Address) {
|
||||
say("the address is not a mail address")
|
||||
}
|
||||
if a.Key != "" && !profileKey.MatchString(a.Key) {
|
||||
say("key is an account key of the profile, such as account2")
|
||||
}
|
||||
for field, v := range map[string]string{"from-name": a.FromName, "label": a.Label} {
|
||||
if !plainText.MatchString(v) {
|
||||
say("%s holds a quote, a backslash or a line break", field)
|
||||
}
|
||||
}
|
||||
for side, s := range map[string]Server{"incoming": a.Incoming, "outgoing": a.Outgoing} {
|
||||
switch {
|
||||
case side == "incoming" && s.Kind != "imap" && s.Kind != "pop3":
|
||||
say("the incoming server's kind is imap or pop3")
|
||||
case side == "outgoing" && s.Kind != "" && s.Kind != "smtp":
|
||||
say("the outgoing server is smtp")
|
||||
}
|
||||
if !hostShape.MatchString(s.Host) {
|
||||
say("the %s server's host is not a host name", side)
|
||||
}
|
||||
if s.Port < 0 || s.Port > 65535 {
|
||||
say("the %s server's port is not a port", side)
|
||||
}
|
||||
if _, ok := securities[s.Security]; !ok {
|
||||
say("the %s server's security is tls, starttls or none", side)
|
||||
}
|
||||
if _, ok := signIns[s.SignIn]; !ok || (side == "incoming" && s.SignIn == "none") {
|
||||
say("the %s server's sign-in is password, encrypted-password, oauth, kerberos, ntlm or tls-certificate%s",
|
||||
side, map[bool]string{true: ", or none", false: ""}[side == "outgoing"])
|
||||
}
|
||||
if !plainText.MatchString(s.User) {
|
||||
say("the %s server's user holds a quote, a backslash or a line break", side)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// quoted is a preference's string value as user.js writes it.
|
||||
func quoted(s string) string { return strconv.Quote(s) }
|
||||
|
||||
// declaredPrefs are a declared account's fields as preferences under the keys it resolved to, name to value
|
||||
// as user.js writes it, in a fixed order. Only the fields the setting names (novox/hq ADR 0317 §5): never
|
||||
// the account's directory, folders, filters or caches. An adopted account keeps its kind, the outgoing server
|
||||
// its identity names, and its own name and sender's name where the declaration says none; a created one
|
||||
// gets every field.
|
||||
func declaredPrefs(k keySet, a Account, adopted bool) [][2]string {
|
||||
label := a.Label
|
||||
if label == "" && !adopted {
|
||||
label = a.Address
|
||||
}
|
||||
inPort := a.Incoming.Port
|
||||
if inPort == 0 {
|
||||
inPort = defaultPort(a.Incoming.Kind, a.Incoming.Security)
|
||||
}
|
||||
outPort := a.Outgoing.Port
|
||||
if outPort == 0 {
|
||||
outPort = defaultPort("smtp", a.Outgoing.Security)
|
||||
}
|
||||
var out [][2]string
|
||||
add := func(key, value string) { out = append(out, [2]string{key, value}) }
|
||||
if !adopted {
|
||||
add("mail.account."+k.Account+".identities", quoted(k.Identity))
|
||||
add("mail.account."+k.Account+".server", quoted(k.Server))
|
||||
}
|
||||
if a.FromName != "" || !adopted {
|
||||
add("mail.identity."+k.Identity+".fullName", quoted(a.FromName))
|
||||
}
|
||||
add("mail.identity."+k.Identity+".useremail", quoted(a.Address))
|
||||
if !adopted {
|
||||
add("mail.identity."+k.Identity+".smtpServer", quoted(k.SMTP))
|
||||
add("mail.identity."+k.Identity+".valid", "true")
|
||||
add("mail.server."+k.Server+".type", quoted(a.Incoming.Kind))
|
||||
}
|
||||
add("mail.server."+k.Server+".hostname", quoted(a.Incoming.Host))
|
||||
add("mail.server."+k.Server+".port", strconv.Itoa(inPort))
|
||||
add("mail.server."+k.Server+".socketType", strconv.Itoa(securities[a.Incoming.Security]))
|
||||
add("mail.server."+k.Server+".authMethod", strconv.Itoa(signIns[a.Incoming.SignIn]))
|
||||
add("mail.server."+k.Server+".userName", quoted(a.Incoming.User))
|
||||
if label != "" {
|
||||
add("mail.server."+k.Server+".name", quoted(label))
|
||||
}
|
||||
add("mail.smtpserver."+k.SMTP+".hostname", quoted(a.Outgoing.Host))
|
||||
add("mail.smtpserver."+k.SMTP+".port", strconv.Itoa(outPort))
|
||||
add("mail.smtpserver."+k.SMTP+".try_ssl", strconv.Itoa(securities[a.Outgoing.Security]))
|
||||
add("mail.smtpserver."+k.SMTP+".authMethod", strconv.Itoa(signIns[a.Outgoing.SignIn]))
|
||||
add("mail.smtpserver."+k.SMTP+".username", quoted(a.Outgoing.User))
|
||||
if label != "" {
|
||||
add("mail.smtpserver."+k.SMTP+".description", quoted(label))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// defaultPort is the port a server listens on when the declaration says none.
|
||||
func defaultPort(kind, security string) int {
|
||||
switch {
|
||||
case kind == "imap" && security == "tls":
|
||||
return 993
|
||||
case kind == "imap":
|
||||
return 143
|
||||
case kind == "pop3" && security == "tls":
|
||||
return 995
|
||||
case kind == "pop3":
|
||||
return 110
|
||||
case security == "tls":
|
||||
return 465
|
||||
case security == "starttls":
|
||||
return 587
|
||||
}
|
||||
return 25
|
||||
}
|
||||
|
||||
// composeUserJS is the user.js the module places in the profile: the declared file (the setting prefs), then
|
||||
// each adopted or created account's declared fields under its keys. A refused account has none.
|
||||
func composeUserJS(declaredText string, accounts map[string]Account, resolved []resolvedAccount) string {
|
||||
var lines []string
|
||||
for _, r := range resolved {
|
||||
if r.How == refused {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("// declared account %d: %s under %s", r.Place, r.How, r.Keys.Account))
|
||||
for _, p := range declaredPrefs(r.Keys, accounts[r.name], r.How == adopted) {
|
||||
lines = append(lines, fmt.Sprintf("user_pref(%s, %s);", quoted(p[0]), p[1]))
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return declaredText
|
||||
}
|
||||
return strings.TrimRight(declaredText, "\n") + "\n\n" +
|
||||
"// The accounts the setting accounts declares (novox/hq ADR 0316 §11, ADR 0317): the fields it names, under\n" +
|
||||
"// the keys of the account adopted or created. No password or token: an account signs in at its first use.\n" +
|
||||
strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
// profileAccount is one account the profile holds, as its preferences say it.
|
||||
type profileAccount struct {
|
||||
Key, Server, Kind string
|
||||
// Identities are its identities, the default first, and Addresses their addresses in that order.
|
||||
Identities []string
|
||||
Addresses []string
|
||||
Owl, Ours bool
|
||||
}
|
||||
|
||||
// profileAccounts reads the accounts the profile's list names, from prefs.js and user.js (user.js wins).
|
||||
func profileAccounts(prefs map[string]string) []profileAccount {
|
||||
var out []profileAccount
|
||||
for _, key := range listOf(prefs["mail.accountmanager.accounts"]) {
|
||||
a := profileAccount{Key: key, Server: unquote(prefs["mail.account."+key+".server"])}
|
||||
a.Kind = unquote(prefs["mail.server."+a.Server+".type"])
|
||||
a.Owl = strings.HasPrefix(a.Kind, "owl")
|
||||
a.Ours = ownKey.MatchString(key)
|
||||
for _, id := range listOf(prefs["mail.account."+key+".identities"]) {
|
||||
a.Identities = append(a.Identities, id)
|
||||
a.Addresses = append(a.Addresses, unquote(prefs["mail.identity."+id+".useremail"]))
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergedPrefs are a profile's preferences as Betterbird starts with them: prefs.js, then user.js over it.
|
||||
func mergedPrefs(profile string) map[string]string {
|
||||
prefsJS, _ := readBounded(filepath.Join(profile, "prefs.js"))
|
||||
userJS, _ := readBounded(filepath.Join(profile, "user.js"))
|
||||
out := readPrefs(prefsJS)
|
||||
for k, v := range readPrefs(userJS) {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listOf reads a comma-separated list preference's value.
|
||||
func listOf(v string) []string {
|
||||
var out []string
|
||||
for _, k := range strings.Split(unquote(v), ",") {
|
||||
if k = strings.TrimSpace(k); k != "" {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unquote is a preference's string value without its quotes; any other value as written.
|
||||
func unquote(v string) string {
|
||||
if s, err := strconv.Unquote(v); err == nil {
|
||||
return s
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// writeAccountLists writes the module's keys into the profile's account list and list of outgoing servers
|
||||
// (§12): the declared accounts' keys added at the end, the module's keys of accounts no longer declared taken
|
||||
// out, every other key and line kept as it was. Only while Betterbird is not running. Says what changed.
|
||||
func (m *Machine) writeAccountLists(profile string, resolved []resolvedAccount) (string, error) {
|
||||
if ps := m.mainProcs(); len(ps) > 0 {
|
||||
return "", fmt.Errorf("Betterbird is running (pid %d), and it rewrites prefs.js when it quits: the account "+
|
||||
"list is written at its next start from the launcher, or by betterbird_restart", ps[0].PID)
|
||||
}
|
||||
path := filepath.Join(profile, "prefs.js")
|
||||
raw, err := readBounded(path)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
var wantAccounts, wantSMTP []string
|
||||
for _, r := range resolved {
|
||||
if r.How == created {
|
||||
wantAccounts, wantSMTP = append(wantAccounts, r.Keys.Account), append(wantSMTP, r.Keys.SMTP)
|
||||
}
|
||||
}
|
||||
out, changed := withListed(string(raw), map[string][]string{
|
||||
"mail.accountmanager.accounts": wantAccounts, "mail.smtpservers": wantSMTP})
|
||||
if !changed {
|
||||
return "unchanged", nil
|
||||
}
|
||||
mode := os.FileMode(0o600)
|
||||
if st, err := os.Stat(path); err == nil {
|
||||
mode = st.Mode().Perm()
|
||||
}
|
||||
tmp := path + ".mesh-new"
|
||||
if err := os.WriteFile(tmp, []byte(out), mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("written: %d account(s) the module created in the account list", len(wantAccounts)), nil
|
||||
}
|
||||
|
||||
// withListed is prefs.js with each list preference holding the module's wanted keys: its own keys that are
|
||||
// not wanted taken out, the wanted ones not there added at its end, everything else as it was. A list the
|
||||
// file does not hold yet is added as a line, when anything is wanted in it.
|
||||
func withListed(text string, want map[string][]string) (string, bool) {
|
||||
changed := false
|
||||
done := map[string]bool{}
|
||||
var lines []string
|
||||
s := bufio.NewScanner(strings.NewReader(text))
|
||||
s.Buffer(make([]byte, 64<<10), 4<<20)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
if mm := prefLine.FindStringSubmatch(line); mm != nil {
|
||||
if keys, ok := want[mm[1]]; ok && !done[mm[1]] {
|
||||
done[mm[1]] = true
|
||||
next := listedWith(listOf(mm[2]), keys)
|
||||
if next != strings.Join(listOf(mm[2]), ",") {
|
||||
line = fmt.Sprintf("user_pref(%s, %s);", quoted(mm[1]), quoted(next))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
names := make([]string, 0, len(want))
|
||||
for n := range want {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
if !done[n] && len(want[n]) > 0 {
|
||||
lines = append(lines, fmt.Sprintf("user_pref(%s, %s);", quoted(n), quoted(strings.Join(want[n], ","))))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
out := strings.Join(lines, "\n")
|
||||
if text == "" || strings.HasSuffix(text, "\n") {
|
||||
out += "\n"
|
||||
}
|
||||
return out, changed
|
||||
}
|
||||
|
||||
// listedWith is a list with the module's own keys made the wanted ones, every other key in its place.
|
||||
func listedWith(have, wanted []string) string {
|
||||
want := map[string]bool{}
|
||||
for _, k := range wanted {
|
||||
want[k] = true
|
||||
}
|
||||
var out []string
|
||||
there := map[string]bool{}
|
||||
for _, k := range have {
|
||||
if ownKey.MatchString(k) && !want[k] {
|
||||
continue
|
||||
}
|
||||
out = append(out, k)
|
||||
there[k] = true
|
||||
}
|
||||
for _, k := range wanted {
|
||||
if !there[k] {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// AccountCount is what status and check say of the accounts: numbers, places and keys, never an address, a
|
||||
// host or a name (novox/hq ADR 0316 §16, ADR 0317 §3).
|
||||
type AccountCount struct {
|
||||
Declared int `json:"declared"`
|
||||
// InList are the declared accounts the profile's account list names, adopted or created.
|
||||
InList int `json:"declared_in_the_list"`
|
||||
// Others are the accounts the profile holds that no declaration names (the work account in Owl among them).
|
||||
Others int `json:"other_accounts"`
|
||||
// WithoutSignIn are the declared accounts with no sign-in kept in Betterbird's login store yet.
|
||||
WithoutSignIn int `json:"declared_without_a_sign_in,omitempty"`
|
||||
// Each is every declared account by its place in the setting: adopted, created or refused, and its keys.
|
||||
Each []resolvedAccount `json:"each,omitempty"`
|
||||
Problem string `json:"problem,omitempty"`
|
||||
}
|
||||
|
||||
// countAccounts counts the declared accounts against the profile's.
|
||||
func (m *Machine) countAccounts(profile, profileDir string) AccountCount {
|
||||
var c AccountCount
|
||||
accounts, _, err := m.readAccounts()
|
||||
if err != nil {
|
||||
c.Problem = err.Error()
|
||||
return c
|
||||
}
|
||||
c.Declared = len(accounts)
|
||||
prefs := mergedPrefs(profile)
|
||||
have := profileAccounts(prefs)
|
||||
c.Each = resolveAccounts(accounts, prefs, m.readRecord(profileDir))
|
||||
mine := map[string]bool{}
|
||||
refusedN := 0
|
||||
for _, r := range c.Each {
|
||||
if r.How == refused {
|
||||
refusedN++
|
||||
continue
|
||||
}
|
||||
mine[r.Keys.Account] = true
|
||||
}
|
||||
for _, a := range have {
|
||||
switch {
|
||||
case mine[a.Key]:
|
||||
c.InList++
|
||||
case a.Kind != "none":
|
||||
c.Others++
|
||||
}
|
||||
}
|
||||
if refusedN > 0 {
|
||||
c.Problem = fmt.Sprintf("%d declared account(s) are refused: each says why and what to do", refusedN)
|
||||
}
|
||||
c.WithoutSignIn = len(withoutSignIn(accounts, keptSignIns(profile)))
|
||||
return c
|
||||
}
|
||||
|
||||
// keptSignIns are the server names Betterbird's login store keeps a sign-in for: the `hostname` of each
|
||||
// entry of logins.json, which Betterbird writes in the clear. Nothing else of the file is read.
|
||||
func keptSignIns(profile string) map[string]bool {
|
||||
raw, err := readBounded(filepath.Join(profile, "logins.json"))
|
||||
out := map[string]bool{}
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
var store struct {
|
||||
Logins []struct {
|
||||
Hostname string `json:"hostname"`
|
||||
} `json:"logins"`
|
||||
}
|
||||
if json.Unmarshal(raw, &store) != nil {
|
||||
return out
|
||||
}
|
||||
for _, l := range store.Logins {
|
||||
out[strings.ToLower(l.Hostname)] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// oauthIssuers are the sign-in services of the providers whose servers sign in through OAuth, by a suffix of
|
||||
// the server's host: their kept sign-in is under the service's name, not the server's.
|
||||
var oauthIssuers = map[string]string{"gmail.com": "oauth://accounts.google.com",
|
||||
"googlemail.com": "oauth://accounts.google.com", "office365.com": "oauth://login.microsoftonline.com",
|
||||
"outlook.com": "oauth://login.microsoftonline.com"}
|
||||
|
||||
// withoutSignIn are the declared accounts whose incoming server has no sign-in kept yet. A server signed in
|
||||
// to without a password (none, kerberos, ntlm, a certificate) keeps nothing and is never counted.
|
||||
func withoutSignIn(accounts map[string]Account, kept map[string]bool) []string {
|
||||
var out []string
|
||||
for _, name := range sortedNames(accounts) {
|
||||
in := accounts[name].Incoming
|
||||
switch in.SignIn {
|
||||
case "password", "encrypted-password":
|
||||
if !kept[strings.ToLower(in.Kind+"://"+in.Host)] {
|
||||
out = append(out, name)
|
||||
}
|
||||
case "oauth":
|
||||
issuer := ""
|
||||
for suffix, i := range oauthIssuers {
|
||||
if strings.HasSuffix(strings.ToLower(in.Host), suffix) {
|
||||
issuer = i
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for k := range kept {
|
||||
if (issuer != "" && k == issuer) || (issuer == "" && strings.HasPrefix(k, "oauth://")) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AccountsAnswer is what betterbird_accounts answers its caller: counts and kinds, never an address, a host or
|
||||
// a name (novox/hq ADR 0317 §9). The value itself goes only to the operator, in a proposal they read at
|
||||
// their own desk.
|
||||
type AccountsAnswer struct {
|
||||
Profile string `json:"profile"`
|
||||
// Declarable are the profile's mail accounts a declaration can hold (IMAP and POP3), by kind.
|
||||
Declarable map[string]int `json:"declarable"`
|
||||
// KeptByOwl are the accounts Owl keeps (the work account through Exchange): never declared (ADR 0316 §13).
|
||||
KeptByOwl int `json:"kept_by_owl"`
|
||||
// NotDeclarable are accounts of another kind (local folders, feeds, news, chat), by kind.
|
||||
NotDeclarable map[string]int `json:"not_declarable,omitempty"`
|
||||
// AlreadyDeclared are the declarable accounts under the module's own keys.
|
||||
AlreadyDeclared int `json:"under_the_modules_keys"`
|
||||
// Proposed is the controller's answer to the proposal, with every address and host masked.
|
||||
Proposed string `json:"proposed,omitempty"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// profileDeclarable reads the profile's accounts: the value a declaration of them would be, by name, and
|
||||
// what is answered of them.
|
||||
func (m *Machine) profileDeclarable() (map[string]Account, AccountsAnswer, error) {
|
||||
p, err := m.profile()
|
||||
if err != nil {
|
||||
return nil, AccountsAnswer{}, err
|
||||
}
|
||||
prefs := mergedPrefs(p.path)
|
||||
a := AccountsAnswer{Profile: p.Dir, Declarable: map[string]int{}, NotDeclarable: map[string]int{}}
|
||||
value := map[string]Account{}
|
||||
used := map[string]bool{}
|
||||
for _, pa := range profileAccounts(prefs) {
|
||||
switch {
|
||||
case pa.Owl:
|
||||
a.KeptByOwl++
|
||||
continue
|
||||
case pa.Kind != "imap" && pa.Kind != "pop3":
|
||||
kind := pa.Kind
|
||||
if kind == "none" {
|
||||
kind = "local folders"
|
||||
}
|
||||
a.NotDeclarable[kind]++
|
||||
continue
|
||||
}
|
||||
a.Declarable[pa.Kind]++
|
||||
if pa.Ours {
|
||||
a.AlreadyDeclared++
|
||||
}
|
||||
address := ""
|
||||
if len(pa.Addresses) > 0 {
|
||||
address = pa.Addresses[0]
|
||||
}
|
||||
name := nameFor(pa, address, used)
|
||||
used[name] = true
|
||||
value[name] = readAccount(prefs, pa)
|
||||
}
|
||||
if len(a.NotDeclarable) == 0 {
|
||||
a.NotDeclarable = nil
|
||||
}
|
||||
return value, a, nil
|
||||
}
|
||||
|
||||
// asker calls a verb of the mesh as this module, through the runtime: a variable so a test answers.
|
||||
var asker = stdio.Ask
|
||||
|
||||
// ProfileAccounts says, by count and kind, which accounts the profile in use holds; with propose it builds
|
||||
// the setting accounts from them (every account but Owl's, without any credential) and proposes it through
|
||||
// the controller's settings verb for the operator's Approve (novox/hq ADR 0317 §9, ADR 0277). The value
|
||||
// never comes back to the caller: the operator reads it whole at their desk (`settings proposals <id>`) and
|
||||
// checks the fingerprint the ask shows.
|
||||
func (m *Machine) ProfileAccounts(propose bool) (AccountsAnswer, error) {
|
||||
value, a, err := m.profileDeclarable()
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Note = "counts and kinds only: no address, host or name leaves through this answer. With propose, the " +
|
||||
"setting accounts is built from these accounts (never Owl's, never a password or token) and proposed to " +
|
||||
"the operator, who reads it whole at their desk before approving (novox/hq ADR 0317 §9)"
|
||||
if !propose {
|
||||
return a, nil
|
||||
}
|
||||
if len(value) == 0 {
|
||||
return a, errors.New("the profile holds no IMAP or POP3 account to declare: nothing was proposed")
|
||||
}
|
||||
var problems []string
|
||||
for i, name := range sortedNames(value) {
|
||||
if p := accountProblems("", name, value[name]); len(p) > 0 {
|
||||
problems = append(problems, fmt.Sprintf("account %d of %d: %d field(s) cannot be declared as the profile "+
|
||||
"holds them", i+1, len(value), len(p)))
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return a, fmt.Errorf("nothing was proposed: %s; betterbird_prefs shows the account's preferences by name",
|
||||
strings.Join(problems, "; "))
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"accounts": value})
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
raw, err := asker("seat:mesh-controller.settings", map[string]any{"module": "betterbird", "values": string(body),
|
||||
"propose": "true"})
|
||||
if err != nil {
|
||||
return a, fmt.Errorf("the controller's settings verb did not answer: %v", err)
|
||||
}
|
||||
said, ok := verbSaid(raw)
|
||||
a.Proposed = m.maskValue(said)
|
||||
if !ok {
|
||||
return a, fmt.Errorf("the controller did not take the proposal: %s", a.Proposed)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// verbSaid is a verb's words and whether it went ahead, whatever wraps them: the controller's {ok, output},
|
||||
// or the protocol's content list.
|
||||
func verbSaid(raw json.RawMessage) (string, bool) {
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return verbSaid(json.RawMessage(s))
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return string(raw), true
|
||||
}
|
||||
if content, has := m["content"]; has {
|
||||
var items []struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
var e struct {
|
||||
IsError bool `json:"isError"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &e)
|
||||
if json.Unmarshal(content, &items) == nil && len(items) > 0 {
|
||||
out, ok := verbSaid(json.RawMessage(items[0].Text))
|
||||
return out, ok && !e.IsError
|
||||
}
|
||||
}
|
||||
if okRaw, has := m["ok"]; has {
|
||||
var ok bool
|
||||
_ = json.Unmarshal(okRaw, &ok)
|
||||
var output string
|
||||
_ = json.Unmarshal(m["output"], &output)
|
||||
return output, ok
|
||||
}
|
||||
return string(raw), true
|
||||
}
|
||||
|
||||
// readAccount reads one profile account's preferences into the shape of the setting.
|
||||
func readAccount(prefs map[string]string, pa profileAccount) Account {
|
||||
num := func(key string, def int) int {
|
||||
if n, err := strconv.Atoi(prefs[key]); err == nil {
|
||||
return n
|
||||
}
|
||||
return def
|
||||
}
|
||||
text := func(key string) string { return unquote(prefs[key]) }
|
||||
srv := "mail.server." + pa.Server + "."
|
||||
acc := Account{Incoming: Server{Kind: pa.Kind, Host: text(srv + "hostname"), User: text(srv + "userName"),
|
||||
Security: wordFor(securities, num(srv+"socketType", 0)), SignIn: wordFor(signIns, num(srv+"authMethod", 3))}}
|
||||
acc.Incoming.Port = num(srv+"port", defaultPort(pa.Kind, acc.Incoming.Security))
|
||||
ids := listOf(prefs["mail.account."+pa.Key+".identities"])
|
||||
if len(ids) > 0 {
|
||||
id := "mail.identity." + ids[0] + "."
|
||||
acc.Address, acc.FromName = text(id+"useremail"), text(id+"fullName")
|
||||
smtp := text(id + "smtpServer")
|
||||
if smtp == "" {
|
||||
smtp = text("mail.smtp.defaultserver")
|
||||
}
|
||||
if smtp != "" {
|
||||
s := "mail.smtpserver." + smtp + "."
|
||||
acc.Outgoing = Server{Host: text(s + "hostname"), User: text(s + "username"),
|
||||
Security: wordFor(securities, num(s+"try_ssl", 0)), SignIn: wordFor(signIns, num(s+"authMethod", 3))}
|
||||
acc.Outgoing.Port = num(s+"port", defaultPort("smtp", acc.Outgoing.Security))
|
||||
}
|
||||
}
|
||||
if label := text(srv + "name"); label != "" && label != acc.Address {
|
||||
acc.Label = label
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
// nameFor is the name a profile account is answered under: its own name when the module declared it, else
|
||||
// the first label of its address's domain, made unique.
|
||||
func nameFor(pa profileAccount, address string, used map[string]bool) string {
|
||||
if mm := ownKey.FindStringSubmatch(pa.Key); mm != nil {
|
||||
return mm[1]
|
||||
}
|
||||
base := "account"
|
||||
if _, domain, ok := strings.Cut(address, "@"); ok {
|
||||
label, _, _ := strings.Cut(strings.ToLower(domain), ".")
|
||||
label = regexp.MustCompile(`[^a-z0-9]`).ReplaceAllString(label, "")
|
||||
if label != "" && label[0] >= 'a' && label[0] <= 'z' {
|
||||
base = label
|
||||
}
|
||||
}
|
||||
if len(base) > 28 {
|
||||
base = base[:28]
|
||||
}
|
||||
name := base
|
||||
for i := 2; used[name]; i++ {
|
||||
name = base + strconv.Itoa(i)
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The setting accounts (novox/hq ADR 0316 §10–§16, ADR 0317 §9 for betterbird_accounts): declared without
|
||||
// credentials, written under the module's own keys, the account list written into and never owned, refused
|
||||
// rather than duplicated, and said by count.
|
||||
|
||||
const personal = `{"accounts": {
|
||||
"personal": {"address": "me@mail.example.org", "from-name": "Me",
|
||||
"incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "password", "user": "me"},
|
||||
"outgoing": {"host": "smtp.example.org", "port": 587, "security": "starttls", "sign-in": "password", "user": "me"}}
|
||||
}}`
|
||||
|
||||
// profileWithAccounts is a profile holding an account of the person's at another address, Owl's work account,
|
||||
// local folders, and an account list in that order.
|
||||
func (f *fake) profileWithAccounts() {
|
||||
f.betterbird()
|
||||
f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+
|
||||
`user_pref("mail.account.account1.server", "server1");`+"\n"+
|
||||
`user_pref("mail.account.account1.identities", "id1");`+"\n"+
|
||||
`user_pref("mail.account.account2.server", "server2");`+"\n"+
|
||||
`user_pref("mail.account.account3.server", "server3");`+"\n"+
|
||||
`user_pref("mail.account.account3.identities", "id3");`+"\n"+
|
||||
`user_pref("mail.accountmanager.accounts", "account1,account3,account2");`+"\n"+
|
||||
`user_pref("mail.identity.id1.useremail", "other@mail.example.org");`+"\n"+
|
||||
`user_pref("mail.identity.id1.fullName", "Other");`+"\n"+
|
||||
`user_pref("mail.identity.id1.smtpServer", "smtp1");`+"\n"+
|
||||
`user_pref("mail.identity.id3.useremail", "work@corp.example.com");`+"\n"+
|
||||
`user_pref("mail.server.server1.type", "imap");`+"\n"+
|
||||
`user_pref("mail.server.server1.hostname", "imap.example.org");`+"\n"+
|
||||
`user_pref("mail.server.server1.socketType", 3);`+"\n"+
|
||||
`user_pref("mail.server.server1.authMethod", 10);`+"\n"+
|
||||
`user_pref("mail.server.server1.userName", "other");`+"\n"+
|
||||
`user_pref("mail.server.server1.directory", "/home/operator/.thunderbird/x/ImapMail/imap.example.org");`+"\n"+
|
||||
`user_pref("mail.server.server2.type", "none");`+"\n"+
|
||||
`user_pref("mail.server.server3.type", "owl-ews");`+"\n"+
|
||||
`user_pref("mail.smtpserver.smtp1.hostname", "smtp.example.org");`+"\n"+
|
||||
`user_pref("mail.smtpserver.smtp1.try_ssl", 3);`+"\n"+
|
||||
`user_pref("mail.smtpserver.smtp1.authMethod", 10);`+"\n"+
|
||||
`user_pref("mail.smtpserver.smtp1.username", "other");`+"\n"+
|
||||
`user_pref("mail.smtpservers", "smtp1");`+"\n"+
|
||||
`user_pref("mailnews.tags.version", 2);`+"\n")
|
||||
}
|
||||
|
||||
func (f *fake) declareAccounts(body string) {
|
||||
f.write(testHome+"/"+accountsFile, body)
|
||||
}
|
||||
|
||||
func (f *fake) read(path string) string {
|
||||
f.t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(f.Root, path))
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestAnAccountsValueCarryingAPasswordLikeFieldIsRefusedWithoutItsValue(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"accounts": {"personal": {"address": "me@mail.example.org", "password": "hunter2"}}}`,
|
||||
`{"accounts": {"personal": {"incoming": {"oauth_refresh": "1//hunter2"}}}}`,
|
||||
`{"token": "hunter2", "accounts": {}}`,
|
||||
} {
|
||||
_, err := parseAccounts([]byte(body))
|
||||
if err == nil || !strings.Contains(err.Error(), "shaped like a credential") || strings.Contains(err.Error(), "hunter2") {
|
||||
t.Errorf("%s: %v", body, err)
|
||||
}
|
||||
}
|
||||
// An unknown field is refused too, and a value that cannot be declared, said by the account's place.
|
||||
if _, err := parseAccounts([]byte(`{"accounts": {"personal": {"address": "me@mail.example.org", "keychain": 1}}}`)); err == nil {
|
||||
t.Error("an unknown field was taken")
|
||||
}
|
||||
_, err := parseAccounts([]byte(strings.Replace(personal, `"security": "tls"`, `"security": "maybe"`, 1)))
|
||||
if err == nil || !strings.Contains(err.Error(), "declared account 1 of 1") || strings.Contains(err.Error(), "personal") ||
|
||||
strings.Contains(err.Error(), "example.org") {
|
||||
t.Errorf("a bad value is said by the account's place, never its name or a value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSettingPrefsRefusesAnAccountsPreferences(t *testing.T) {
|
||||
for _, name := range []string{"mail.account.account1.server", "mail.identity.id1.useremail", "mail.server.server1.hostname",
|
||||
"mail.smtpserver.smtp1.hostname", "mail.accountmanager.accounts", "mail.smtpservers"} {
|
||||
err := validUserJS(mark + ")\nuser_pref(\"" + name + "\", \"x\");\n")
|
||||
if err == nil || !strings.Contains(err.Error(), "setting accounts") {
|
||||
t.Errorf("%s was taken in prefs: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := validUserJS(mark + ")\nuser_pref(\"calendar.alarms.show\", true);\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// noPackage answers pacman as a machine without the community package.
|
||||
func (f *fake) noPackage() {
|
||||
f.answer = func(name string, args []string) Output {
|
||||
if name == "pacman" {
|
||||
return Output{Code: 1}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
}
|
||||
|
||||
// userPrefLines are the user_pref lines of a user.js, by preference name.
|
||||
func userPrefLines(t *testing.T, text string) map[string]string {
|
||||
t.Helper()
|
||||
out := map[string]string{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if !strings.HasPrefix(line, "user_pref(") {
|
||||
continue
|
||||
}
|
||||
if !statement.MatchString(line) {
|
||||
t.Errorf("not a statement Betterbird reads as one: %s", line)
|
||||
}
|
||||
out[line[len(`user_pref("`):strings.Index(line, `",`)]] = line
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestADeclaredAccountTheProfileDoesNotHoldIsCreatedUnderTheModulesKeysAndListed(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.declareAccounts(personal)
|
||||
// Betterbird is running: the user.js is placed, the list waits for its next start.
|
||||
u, err := f.userJS(true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(u.Lists, "not written") || !strings.Contains(u.Lists, "running") {
|
||||
t.Fatalf("the list is never written while Betterbird runs: %+v", u)
|
||||
}
|
||||
if len(u.Each) != 1 || u.Each[0].How != created || u.Each[0].Keys != (keySet{"account_mesh_personal", "id_mesh_personal",
|
||||
"server_mesh_personal", "smtp_mesh_personal"}) {
|
||||
t.Fatalf("%+v", u.Each)
|
||||
}
|
||||
placed := f.read(profileDir + "/user.js")
|
||||
for name := range userPrefLines(t, placed) {
|
||||
if key := strings.Split(name, ".")[2]; !strings.HasSuffix(key, "mesh_personal") {
|
||||
t.Errorf("a created account's preference outside the module's keys: %s", name)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(name), "password") || strings.Contains(name, "directory") {
|
||||
t.Errorf("the module writes no credential and none of Betterbird's own preferences: %s", name)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`user_pref("mail.server.server_mesh_personal.port", 993);`,
|
||||
`user_pref("mail.server.server_mesh_personal.socketType", 3);`,
|
||||
`user_pref("mail.smtpserver.smtp_mesh_personal.try_ssl", 2);`,
|
||||
`user_pref("mail.identity.id_mesh_personal.useremail", "me@mail.example.org");`} {
|
||||
if !strings.Contains(placed, want) {
|
||||
t.Errorf("the user.js lacks %s:\n%s", want, placed)
|
||||
}
|
||||
}
|
||||
|
||||
// Quit: the list gains the module's keys at its end, every other line as it was.
|
||||
before := f.read(profileDir + "/prefs.js")
|
||||
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := f.read(profileDir + "/prefs.js")
|
||||
if !strings.Contains(after, `user_pref("mail.accountmanager.accounts", "account1,account3,account2,account_mesh_personal");`) ||
|
||||
!strings.Contains(after, `user_pref("mail.smtpservers", "smtp1,smtp_mesh_personal");`) {
|
||||
t.Fatalf("the lists:\n%s", after)
|
||||
}
|
||||
bl, al := strings.Split(before, "\n"), strings.Split(after, "\n")
|
||||
if len(bl) != len(al) {
|
||||
t.Fatalf("lines were added or lost: %d then %d", len(bl), len(al))
|
||||
}
|
||||
for i := range bl {
|
||||
if bl[i] != al[i] && !strings.Contains(bl[i], "mail.accountmanager.accounts") && !strings.Contains(bl[i], "mail.smtpservers") {
|
||||
t.Errorf("line %d changed: %q to %q", i+1, bl[i], al[i])
|
||||
}
|
||||
}
|
||||
|
||||
// The record lost: an account under the module's own keys is still its creation, never adopted.
|
||||
os.Remove(filepath.Join(f.Root, testHome, recordFile))
|
||||
u, err = f.userJS(true)
|
||||
if err != nil || u.Each[0].How != created {
|
||||
t.Fatalf("without the record, the module's keys still say created: %+v %v", u.Each, err)
|
||||
}
|
||||
|
||||
// The account leaves the setting: only the module's keys go, the mail stays in the profile.
|
||||
f.declareAccounts(`{"accounts": {}}`)
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := f.read(profileDir + "/prefs.js"); got != before {
|
||||
t.Fatalf("removing the module's keys left the file otherwise as it was:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// adoptable is a declaration of the profile's account1 (other@mail.example.org): equal but for its outgoing
|
||||
// port, said explicitly, and its incoming user.
|
||||
const adoptable = `{"accounts": {
|
||||
"mail": {"address": "Other@mail.example.org",
|
||||
"incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "oauth", "user": "other2"},
|
||||
"outgoing": {"host": "smtp.example.org", "port": 465, "security": "tls", "sign-in": "oauth", "user": "other"}}}}`
|
||||
|
||||
func TestADeclaredAccountTheProfileHoldsIsAdoptedUnderItsOwnKeysAndTheListIsLeftAlone(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.declareAccounts(adoptable)
|
||||
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
|
||||
before := f.read(profileDir + "/prefs.js")
|
||||
u, err := f.userJS(true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(u.Each) != 1 || u.Each[0].How != adopted || u.Each[0].Keys != (keySet{"account1", "id1", "server1", "smtp1"}) {
|
||||
t.Fatalf("adopted under the profile's keys: %+v", u.Each)
|
||||
}
|
||||
// The address's case and the user name differ; the default port declared explicitly is no change. Names
|
||||
// only, no value.
|
||||
if strings.Join(u.Each[0].Changed, ",") != "mail.identity.id1.useremail,mail.server.server1.userName" {
|
||||
t.Fatalf("the changed preferences, by name: %v", u.Each[0].Changed)
|
||||
}
|
||||
if f.read(profileDir+"/prefs.js") != before || u.Lists != "unchanged" {
|
||||
t.Fatalf("an adopted account's keys are in the lists already: nothing written to prefs.js (%s)", u.Lists)
|
||||
}
|
||||
lines := userPrefLines(t, f.read(profileDir+"/user.js"))
|
||||
for name := range lines {
|
||||
key := strings.Split(name, ".")[2]
|
||||
if key != "account1" && key != "id1" && key != "server1" && key != "smtp1" {
|
||||
t.Errorf("an adopted account's preference outside its keys: %s", name)
|
||||
}
|
||||
for _, never := range []string{"directory", ".type", "smtpServer", "identities", ".server"} {
|
||||
if strings.HasSuffix(name, never) {
|
||||
t.Errorf("the module wrote %s, which is Betterbird's or the account's own", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(lines["mail.server.server1.userName"], `"other2"`) {
|
||||
t.Fatalf("the declared value wins at every start: %v", lines)
|
||||
}
|
||||
// The status says it by place and keys, never by address, host or name.
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(s.Accounts)
|
||||
if s.Accounts.InList != 1 || s.Accounts.Others != 1 || strings.Contains(string(raw), "example") ||
|
||||
strings.Contains(string(raw), `"mail"`) || !strings.Contains(string(raw), `"account1"`) {
|
||||
t.Fatalf("%s", raw)
|
||||
}
|
||||
|
||||
// Leaving the setting releases it: its lines and its record go, the account and the lists stay.
|
||||
f.declareAccounts(`{"accounts": {}}`)
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.read(profileDir+"/prefs.js") != before || strings.Contains(f.read(profileDir+"/user.js"), "server1") ||
|
||||
strings.Contains(f.read(testHome+"/"+recordFile), "account1") {
|
||||
t.Fatal("a released account stays the person's, with no line or record of the module's")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAmbiguousOwlsOrOtherKindOfMatchIsRefusedForThatAccountAlone(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
|
||||
// A second account with account1's address.
|
||||
prefs := f.read(profileDir + "/prefs.js")
|
||||
prefs = strings.Replace(prefs, `"account1,account3,account2"`, `"account1,account3,account2,account4"`, 1) +
|
||||
`user_pref("mail.account.account4.server", "server4");` + "\n" +
|
||||
`user_pref("mail.account.account4.identities", "id4");` + "\n" +
|
||||
`user_pref("mail.identity.id4.useremail", "other@mail.example.org");` + "\n" +
|
||||
`user_pref("mail.identity.id4.smtpServer", "smtp4");` + "\n" +
|
||||
`user_pref("mail.smtpserver.smtp4.hostname", "smtp.example.org");` + "\n" +
|
||||
`user_pref("mail.server.server4.type", "imap");` + "\n"
|
||||
f.write(profileDir+"/prefs.js", prefs)
|
||||
both := strings.Replace(adoptable, `{"accounts": {`, `{"accounts": {
|
||||
"zz": {"address": "new@mail.example.org",
|
||||
"incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "password"},
|
||||
"outgoing": {"host": "smtp.example.org", "security": "tls", "sign-in": "password"}},`, 1)
|
||||
f.declareAccounts(both)
|
||||
err := f.Prepare()
|
||||
if err == nil || !strings.Contains(err.Error(), "declared account 1 of 2: 2 accounts in the profile have its address (account1, account4)") ||
|
||||
!strings.Contains(err.Error(), "key") || strings.Contains(err.Error(), "example.org") {
|
||||
t.Fatalf("refused by place, with the matches' keys and the remedy, never the address: %v", err)
|
||||
}
|
||||
u, _ := f.userJS(false)
|
||||
if u.Each[0].How != refused || u.Each[1].How != created {
|
||||
t.Fatalf("the other declared account goes ahead: %+v", u.Each)
|
||||
}
|
||||
if strings.Contains(f.read(profileDir+"/user.js"), "server1") || strings.Contains(f.read(profileDir+"/user.js"), "server4") {
|
||||
t.Fatal("a refused account gets no line")
|
||||
}
|
||||
|
||||
// The node's layer names the key: that one is adopted.
|
||||
f.declareAccounts(strings.Replace(both, `"address": "Other@mail.example.org",`, `"address": "Other@mail.example.org", "key": "account4",`, 1))
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if u, _ := f.userJS(false); u.Each[0].How != adopted || u.Each[0].Keys.Account != "account4" {
|
||||
t.Fatalf("%+v", u.Each)
|
||||
}
|
||||
os.Remove(filepath.Join(f.Root, testHome, recordFile))
|
||||
for key, want := range map[string]string{"account9": "not in the profile's account list", "account3": "Owl's",
|
||||
"account2": "another address"} {
|
||||
f.declareAccounts(strings.Replace(adoptable, `"address": "Other@mail.example.org",`, `"address": "Other@mail.example.org", "key": "`+key+`",`, 1))
|
||||
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("key %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Owl's account at the address, and an account of another kind than declared.
|
||||
f.declareAccounts(`{"accounts": {"work": {"address": "work@corp.example.com",
|
||||
"incoming": {"kind": "imap", "host": "outlook.example.com", "security": "tls", "sign-in": "oauth"},
|
||||
"outgoing": {"host": "smtp.example.com", "security": "tls", "sign-in": "oauth"}}}}`)
|
||||
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "Owl's") {
|
||||
t.Errorf("Owl's account is never adopted: %v", err)
|
||||
}
|
||||
f.write(profileDir+"/prefs.js", strings.Replace(prefs, `"mail.server.server4.type", "imap"`, `"mail.server.server4.type", "pop3"`, 1))
|
||||
f.declareAccounts(strings.Replace(adoptable, `"address": "Other@mail.example.org",`, `"address": "Other@mail.example.org", "key": "account4",`, 1))
|
||||
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "never converts") {
|
||||
t.Errorf("an account of another kind is refused, not converted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredAccountsWithoutASignInAreCountedFromTheServerNamesAlone(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
links(t, nil)
|
||||
f.declareAccounts(`{"accounts": {
|
||||
"personal": {"address": "me@mail.example.org", "incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "password"},
|
||||
"outgoing": {"host": "smtp.example.org", "security": "tls", "sign-in": "password"}},
|
||||
"google": {"address": "me@gmail.com", "incoming": {"kind": "imap", "host": "imap.gmail.com", "security": "tls", "sign-in": "oauth"},
|
||||
"outgoing": {"host": "smtp.gmail.com", "security": "tls", "sign-in": "oauth"}},
|
||||
"lan": {"address": "me@lan.example.org", "incoming": {"kind": "pop3", "host": "pop.example.org", "security": "tls", "sign-in": "kerberos"},
|
||||
"outgoing": {"host": "smtp.example.org", "security": "tls", "sign-in": "none"}}}}`)
|
||||
// The username and password fields are encrypted and never read; a fixture holding junk there proves it.
|
||||
f.write(profileDir+"/logins.json", `{"logins": [
|
||||
{"hostname": "imap://imap.example.org", "httpRealm": "imap://imap.example.org", "encryptedUsername": "MEIE...", "encryptedPassword": "MDoE..."},
|
||||
{"hostname": "smtp://smtp.example.org", "encryptedUsername": "x", "encryptedPassword": "y"}]}`)
|
||||
c := f.countAccounts(filepath.Join(f.Root, profileDir), "~/.thunderbird/60016jsp.default-default")
|
||||
if c.Declared != 3 || c.WithoutSignIn != 1 || c.Others != 2 || c.InList != 0 {
|
||||
t.Fatalf("three declared, the OAuth one not signed in, the person's and Owl's other (local folders not counted): %+v", c)
|
||||
}
|
||||
raw, _ := json.Marshal(c)
|
||||
if strings.Contains(string(raw), "example") || strings.Contains(string(raw), "gmail") {
|
||||
t.Fatalf("a count names nothing: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetterbirdAccountsAnswersCountsAndProposesTheValueWithoutOwlOrACredential(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
links(t, nil)
|
||||
a, err := f.ProfileAccounts(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Declarable["imap"] != 1 || a.KeptByOwl != 1 || a.NotDeclarable["local folders"] != 1 || a.Proposed != "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
raw, _ := json.Marshal(a)
|
||||
for _, never := range []string{"example", "other", "Other", "work@", "smtp1"} {
|
||||
if strings.Contains(string(raw), never) {
|
||||
t.Errorf("the answer to the caller names %q: %s", never, raw)
|
||||
}
|
||||
}
|
||||
|
||||
var asked map[string]any
|
||||
asker = func(key string, body any) (json.RawMessage, error) {
|
||||
if key != "seat:mesh-controller.settings" {
|
||||
t.Fatalf("asked %s", key)
|
||||
}
|
||||
asked = body.(map[string]any)
|
||||
return json.RawMessage(`{"ok": true, "output": "proposed p-17 for betterbird on the whole mesh: accounts (other@mail.example.org); fingerprint ab12"}`), nil
|
||||
}
|
||||
t.Cleanup(func() { asker = nil })
|
||||
a, err = f.ProfileAccounts(true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asked["module"] != "betterbird" || asked["propose"] != "true" || asked["node"] != nil {
|
||||
t.Fatalf("proposed for the whole mesh, never set: %v", asked)
|
||||
}
|
||||
value, err := parseAccounts([]byte(asked["values"].(string)))
|
||||
if err != nil {
|
||||
t.Fatalf("the proposed value is one the module takes: %v", err)
|
||||
}
|
||||
other, ok := value["mail"]
|
||||
if len(value) != 1 || !ok || other.Address != "other@mail.example.org" || other.Incoming.SignIn != "oauth" ||
|
||||
other.Incoming.Port != 993 || other.Outgoing.Security != "tls" || other.Outgoing.Port != 465 {
|
||||
t.Fatalf("the value holds the person's account and not Owl's: %+v", value)
|
||||
}
|
||||
if strings.Contains(a.Proposed, "other@") || !strings.Contains(a.Proposed, "p-17") {
|
||||
t.Fatalf("the controller's words come back with every address masked: %s", a.Proposed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAdoptedAccountNeverWritesIntoAnOutgoingServerAnotherIdentityUses(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
|
||||
// A second account of the person's, not declared, sends through smtp1 too.
|
||||
prefs := strings.Replace(f.read(profileDir+"/prefs.js"), `"account1,account3,account2"`, `"account1,account3,account2,account5"`, 1) +
|
||||
`user_pref("mail.account.account5.server", "server5");` + "\n" +
|
||||
`user_pref("mail.account.account5.identities", "id5");` + "\n" +
|
||||
`user_pref("mail.identity.id5.useremail", "fifth@mail.example.org");` + "\n" +
|
||||
`user_pref("mail.identity.id5.smtpServer", "smtp1");` + "\n" +
|
||||
`user_pref("mail.server.server5.type", "imap");` + "\n"
|
||||
f.write(profileDir+"/prefs.js", prefs)
|
||||
f.declareAccounts(adoptable)
|
||||
err := f.Prepare()
|
||||
if err == nil || !strings.Contains(err.Error(), "also used by identity id5") || strings.Contains(err.Error(), "example.org") {
|
||||
t.Fatalf("refused with the remedy, by keys: %v", err)
|
||||
}
|
||||
if user, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js")); strings.Contains(string(user), "smtpserver.smtp1") {
|
||||
t.Fatal("the shared outgoing server was written")
|
||||
}
|
||||
|
||||
// Recorded adoption, then the person moves the identity to a server of its own: the module follows it.
|
||||
f.write(profileDir+"/prefs.js", strings.Replace(prefs, `"mail.identity.id5.smtpServer", "smtp1"`, `"mail.identity.id5.smtpServer", "smtp5"`, 1))
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved := strings.Replace(f.read(profileDir+"/prefs.js"), `"mail.identity.id1.smtpServer", "smtp1"`, `"mail.identity.id1.smtpServer", "smtp9"`, 1) +
|
||||
`user_pref("mail.smtpserver.smtp9.hostname", "smtp.example.org");` + "\n"
|
||||
f.write(profileDir+"/prefs.js", moved)
|
||||
u, err := f.userJS(true)
|
||||
if err != nil || u.Each[0].How != adopted || u.Each[0].Keys.SMTP != "smtp9" {
|
||||
t.Fatalf("the identity's current outgoing server is followed: %+v %v", u.Each, err)
|
||||
}
|
||||
user := f.read(profileDir + "/user.js")
|
||||
if strings.Contains(user, "smtpserver.smtp1.") || !strings.Contains(user, "smtpserver.smtp9.hostname") {
|
||||
t.Fatalf("never the old one:\n%s", user)
|
||||
}
|
||||
// And the move back onto a shared server is refused again.
|
||||
f.write(profileDir+"/prefs.js", strings.Replace(moved, `"mail.identity.id5.smtpServer", "smtp5"`, `"mail.identity.id5.smtpServer", "smtp9"`, 1))
|
||||
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "also used by identity id5") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheUserJSAnswerWithholdsTheAccountsValues(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare(`user_pref("mail.compose.default_to", "x");`)
|
||||
f.declareAccounts(personal)
|
||||
u, err := f.userJS(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, never := range []string{"example.org", `"me"`, `"Me"`} {
|
||||
if strings.Contains(u.Content, never) {
|
||||
t.Errorf("the answer carries %s:\n%s", never, u.Content)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(u.Content, `user_pref("mail.server.server_mesh_personal.hostname", "<withheld>");`) {
|
||||
t.Fatalf("an account's preference is named and its value withheld:\n%s", u.Content)
|
||||
}
|
||||
// What is placed in the profile keeps the values: only the answer withholds them.
|
||||
if f.read(testHome+"/"+accountsFile) == "" {
|
||||
t.Fatal("no declaration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACredentialRefusalNamesNoAccount(t *testing.T) {
|
||||
_, err := parseAccounts([]byte(`{"accounts": {"personal": {"incoming": {"password": "hunter2"}}}}`))
|
||||
if err == nil || strings.Contains(err.Error(), "personal") || !strings.Contains(err.Error(), "accounts.<an account>.incoming.password") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAccountsOwnAliasesAndNonMailIdentitiesDoNotShareItsOutgoingServer(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.profileWithAccounts()
|
||||
f.noPackage()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
|
||||
// account1 has an alias identity on smtp1; smtp1 is the default server; Owl's account and local folders
|
||||
// have identities that name no outgoing server.
|
||||
prefs := strings.Replace(f.read(profileDir+"/prefs.js"), `"mail.account.account1.identities", "id1"`, `"mail.account.account1.identities", "id1,id1b"`, 1)
|
||||
prefs = strings.Replace(prefs, `"mail.account.account2.server", "server2");`, `"mail.account.account2.server", "server2");`+"\n"+
|
||||
`user_pref("mail.account.account2.identities", "id2");`, 1) +
|
||||
`user_pref("mail.identity.id1b.useremail", "alias@mail.example.org");` + "\n" +
|
||||
`user_pref("mail.identity.id1b.smtpServer", "smtp1");` + "\n" +
|
||||
`user_pref("mail.identity.id2.useremail", "local@mail.example.org");` + "\n" +
|
||||
`user_pref("mail.smtp.defaultserver", "smtp1");` + "\n"
|
||||
f.write(profileDir+"/prefs.js", prefs)
|
||||
f.declareAccounts(adoptable)
|
||||
if err := f.Prepare(); err != nil {
|
||||
t.Fatalf("an account's own alias, and identities that do not send by SMTP, are no sharing: %v", err)
|
||||
}
|
||||
// An identity of another mail account naming no server does use the default one.
|
||||
f.write(profileDir+"/prefs.js", strings.Replace(prefs, `"account1,account3,account2"`, `"account1,account3,account2,account6"`, 1)+
|
||||
`user_pref("mail.account.account6.server", "server6");`+"\n"+
|
||||
`user_pref("mail.account.account6.identities", "id6");`+"\n"+
|
||||
`user_pref("mail.identity.id6.useremail", "six@mail.example.org");`+"\n"+
|
||||
`user_pref("mail.server.server6.type", "pop3");`+"\n")
|
||||
os.Remove(filepath.Join(f.Root, testHome, recordFile))
|
||||
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "also used by identity id6") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package main
|
||||
|
||||
// Which account of the profile a declared account is (novox/hq ADR 0317): adopted, created or refused.
|
||||
//
|
||||
// At every apply, per declared account in its place in the setting (§1):
|
||||
//
|
||||
// 1. an account the module recorded on this node for this profile, adopted or created, is kept by its
|
||||
// recorded keys without matching again, while its account key is still in the profile's account list;
|
||||
// 2. otherwise an account under the module's own mesh_<name> keys is the module's creation;
|
||||
// 3. otherwise a node's layer may name the key to adopt (§8): adopted only when it is in the account list,
|
||||
// its default identity has the declared address and it is not Owl's;
|
||||
// 4. otherwise the profile's accounts whose default identity has the declared address: one is adopted, none
|
||||
// means the account is created under mesh_<name> keys (ADR 0316 §11).
|
||||
//
|
||||
// Refused, with the remedy, for that account alone (§7): two or more accounts with the address, the one that
|
||||
// has it being Owl's, a named key that does not qualify, an account of another kind than the declaration says
|
||||
// (the module never converts one: the review of ADR 0317), and an identity that names no outgoing server. The
|
||||
// other declared accounts go ahead.
|
||||
//
|
||||
// Adopting takes the account's keys as the module's (§2): its account key, its default identity, its incoming
|
||||
// server and the outgoing server that identity names. The account lists are left as they are, because the keys
|
||||
// are in them already; the declared fields are written under those keys (§4, §5). What was adopted or created
|
||||
// is recorded in the module's directory in the home, by the tools and never by a setting (§3); leaving the
|
||||
// setting releases an adopted account (its lines and its record go, the account stays the person's, §6).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// recordFile is where the module records, per profile, what it adopted or created (ADR 0317 §3).
|
||||
const recordFile = ".config/betterbird/accounts-record.json"
|
||||
|
||||
// How a declared account resolved.
|
||||
const (
|
||||
adopted = "adopted"
|
||||
created = "created"
|
||||
refused = "refused"
|
||||
)
|
||||
|
||||
// keySet are an account's four keys.
|
||||
type keySet struct {
|
||||
Account string `json:"account"`
|
||||
Identity string `json:"identity"`
|
||||
Server string `json:"server"`
|
||||
SMTP string `json:"smtp"`
|
||||
}
|
||||
|
||||
// resolvedAccount is one declared account as it resolved, said by its place and keys, never its name, address
|
||||
// or host (ADR 0316 §16, ADR 0317 §3).
|
||||
type resolvedAccount struct {
|
||||
Place int `json:"place"`
|
||||
How string `json:"how"`
|
||||
Keys keySet `json:"keys,omitempty"`
|
||||
// Why is a refusal's reason and its remedy.
|
||||
Why string `json:"why,omitempty"`
|
||||
// Changed are the preferences an apply changed for it, by name only (ADR 0317 §4).
|
||||
Changed []string `json:"changed,omitempty"`
|
||||
name string
|
||||
}
|
||||
|
||||
// record is what the module adopted or created, per profile (its directory as the tools say it), per
|
||||
// declared account's name.
|
||||
type record map[string]map[string]recorded
|
||||
|
||||
type recorded struct {
|
||||
How string `json:"how"`
|
||||
Keys keySet `json:"keys"`
|
||||
}
|
||||
|
||||
func (m *Machine) readRecord(profileDir string) map[string]recorded {
|
||||
raw, err := readBounded(m.home(recordFile))
|
||||
if err != nil {
|
||||
return map[string]recorded{}
|
||||
}
|
||||
var r record
|
||||
if json.Unmarshal(raw, &r) != nil || r[profileDir] == nil {
|
||||
return map[string]recorded{}
|
||||
}
|
||||
return r[profileDir]
|
||||
}
|
||||
|
||||
// writeRecord keeps what this apply resolved for the profile: a refused account is not recorded, and an
|
||||
// account no longer declared is dropped (§6).
|
||||
func (m *Machine) writeRecord(profileDir string, resolved []resolvedAccount) error {
|
||||
r := record{}
|
||||
if raw, err := readBounded(m.home(recordFile)); err == nil {
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
}
|
||||
mine := map[string]recorded{}
|
||||
for _, a := range resolved {
|
||||
if a.How != refused {
|
||||
mine[a.name] = recorded{How: a.How, Keys: a.Keys}
|
||||
}
|
||||
}
|
||||
if len(mine) == 0 {
|
||||
delete(r, profileDir)
|
||||
} else {
|
||||
r[profileDir] = mine
|
||||
}
|
||||
body, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := m.home(recordFile)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".mesh-new"
|
||||
if err := os.WriteFile(tmp, append(body, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAccounts resolves every declared account, in its place in the setting (the file's order, by name).
|
||||
func resolveAccounts(accounts map[string]Account, prefs map[string]string, rec map[string]recorded) []resolvedAccount {
|
||||
have := profileAccounts(prefs)
|
||||
inList := map[string]profileAccount{}
|
||||
for _, a := range have {
|
||||
inList[a.Key] = a
|
||||
}
|
||||
names := sortedNames(accounts)
|
||||
taken := map[string]bool{}
|
||||
var out []resolvedAccount
|
||||
for i, name := range names {
|
||||
r := resolveOne(i+1, len(names), name, accounts[name], prefs, have, inList, rec, taken)
|
||||
r.name = name
|
||||
if r.How != refused {
|
||||
taken[r.Keys.Account] = true
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return refuseSharedOutgoing(out, prefs, have)
|
||||
}
|
||||
|
||||
// refuseSharedOutgoing refuses an adopted account whose outgoing server another identity uses that is neither
|
||||
// adopted nor the module's (review 34 of mesh-catalog #232): Betterbird lets identities share an outgoing
|
||||
// server, and the declared values written into it would change that other account's sending at every start.
|
||||
// ADR 0317 §2 adopts the outgoing server the identity names and leaves the lists as they are, so the module
|
||||
// does not give the identity a server of its own; it refuses, with the remedy.
|
||||
func refuseSharedOutgoing(resolved []resolvedAccount, prefs map[string]string, have []profileAccount) []resolvedAccount {
|
||||
// Every identity of an adopted or created account is its own, its aliases too (review 36).
|
||||
accountOf := map[string]profileAccount{}
|
||||
kindOf := map[string]string{}
|
||||
for _, a := range have {
|
||||
accountOf[a.Key] = a
|
||||
for _, id := range a.Identities {
|
||||
kindOf[id] = a.Kind
|
||||
}
|
||||
}
|
||||
ours := map[string]bool{}
|
||||
for _, r := range resolved {
|
||||
if r.How == refused {
|
||||
continue
|
||||
}
|
||||
ours[r.Keys.Identity] = true
|
||||
for _, id := range accountOf[r.Keys.Account].Identities {
|
||||
ours[id] = true
|
||||
}
|
||||
}
|
||||
defaultServer := unquote(prefs["mail.smtp.defaultserver"])
|
||||
users := map[string][]string{}
|
||||
seen := map[string]bool{}
|
||||
use := func(id string) {
|
||||
if seen[id] || ours[id] {
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
server := unquote(prefs["mail.identity."+id+".smtpServer"])
|
||||
// Only a mail account's identity sends through the default server when it names none: Owl's, local
|
||||
// folders and feeds do not send by SMTP (review 36).
|
||||
if server == "" && (kindOf[id] == "imap" || kindOf[id] == "pop3") {
|
||||
server = defaultServer
|
||||
}
|
||||
if server != "" {
|
||||
users[server] = append(users[server], id)
|
||||
}
|
||||
}
|
||||
for _, a := range have {
|
||||
for _, id := range a.Identities {
|
||||
use(id)
|
||||
}
|
||||
}
|
||||
for k := range prefs {
|
||||
if strings.HasPrefix(k, "mail.identity.") && strings.HasSuffix(k, ".smtpServer") {
|
||||
use(strings.TrimSuffix(strings.TrimPrefix(k, "mail.identity."), ".smtpServer"))
|
||||
}
|
||||
}
|
||||
for i, r := range resolved {
|
||||
if r.How != adopted {
|
||||
continue
|
||||
}
|
||||
if others := users[r.Keys.SMTP]; len(others) > 0 {
|
||||
sort.Strings(others)
|
||||
resolved[i] = resolvedAccount{Place: r.Place, How: refused, name: r.name, Why: fmt.Sprintf(
|
||||
"declared account %d of %d: the outgoing server %s of the profile's account %s is also used by "+
|
||||
"identity %s, whose sending the declared values would change: give the account an outgoing "+
|
||||
"server of its own in Betterbird's account settings, then start Betterbird again",
|
||||
r.Place, len(resolved), r.Keys.SMTP, r.Keys.Account, strings.Join(others, ", "))}
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func resolveOne(place, of int, name string, a Account, prefs map[string]string, have []profileAccount,
|
||||
inList map[string]profileAccount, rec map[string]recorded, taken map[string]bool) resolvedAccount {
|
||||
r := resolvedAccount{Place: place}
|
||||
refuse := func(format string, args ...any) resolvedAccount {
|
||||
r.How, r.Keys = refused, keySet{}
|
||||
r.Why = fmt.Sprintf("declared account %d of %d: ", place, of) + fmt.Sprintf(format, args...)
|
||||
return r
|
||||
}
|
||||
// 1. Recorded, while its account is still in the list.
|
||||
if was, ok := rec[name]; ok {
|
||||
if _, there := inList[was.Keys.Account]; there || was.How == created {
|
||||
r.How, r.Keys = was.How, was.Keys
|
||||
if was.How == adopted {
|
||||
// The identity's outgoing server as it is now: a person may have moved it since.
|
||||
smtpKey := unquote(prefs["mail.identity."+was.Keys.Identity+".smtpServer"])
|
||||
if smtpKey == "" {
|
||||
return refuse("the profile's account %s has an identity that names no outgoing server: choose one "+
|
||||
"for it in Betterbird's account settings, then start Betterbird again", was.Keys.Account)
|
||||
}
|
||||
r.Keys.SMTP = smtpKey
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
// 2. Under the module's own keys: its creation.
|
||||
account, identity, server, smtp := keysOf(name)
|
||||
own := keySet{account, identity, server, smtp}
|
||||
if _, there := inList[account]; there {
|
||||
r.How, r.Keys = created, own
|
||||
return r
|
||||
}
|
||||
adopt := func(pa profileAccount) resolvedAccount {
|
||||
if pa.Kind != a.Incoming.Kind {
|
||||
return refuse("the profile's account %s is of kind %s and the declaration says %s: the module never "+
|
||||
"converts an account. Declare the kind the account has, or remove the account in Betterbird",
|
||||
pa.Key, pa.Kind, a.Incoming.Kind)
|
||||
}
|
||||
smtpKey := unquote(prefs["mail.identity."+pa.Identities[0]+".smtpServer"])
|
||||
if smtpKey == "" {
|
||||
return refuse("the profile's account %s has an identity that names no outgoing server: choose one for it "+
|
||||
"in Betterbird's account settings, then start Betterbird again", pa.Key)
|
||||
}
|
||||
r.How, r.Keys = adopted, keySet{pa.Key, pa.Identities[0], pa.Server, smtpKey}
|
||||
return r
|
||||
}
|
||||
matchesDefault := func(pa profileAccount) bool {
|
||||
return len(pa.Addresses) > 0 && strings.EqualFold(pa.Addresses[0], a.Address)
|
||||
}
|
||||
// 3. The key a node's layer names.
|
||||
if a.Key != "" {
|
||||
pa, there := inList[a.Key]
|
||||
switch {
|
||||
case !there:
|
||||
return refuse("the key %s this node's layer names is not in the profile's account list", a.Key)
|
||||
case pa.Owl:
|
||||
return refuse("the key %s this node's layer names is Owl's account, which is never declared "+
|
||||
"(novox/hq ADR 0316 §13)", a.Key)
|
||||
case !matchesDefault(pa):
|
||||
return refuse("the key %s this node's layer names is an account whose default identity has another "+
|
||||
"address", a.Key)
|
||||
case taken[pa.Key]:
|
||||
return refuse("the key %s is already another declared account's", a.Key)
|
||||
}
|
||||
return adopt(pa)
|
||||
}
|
||||
// 4. By the default identity's address.
|
||||
var matches []profileAccount
|
||||
for _, pa := range have {
|
||||
if !pa.Ours && !taken[pa.Key] && matchesDefault(pa) {
|
||||
matches = append(matches, pa)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(matches) == 0:
|
||||
r.How, r.Keys = created, own
|
||||
return r
|
||||
case len(matches) > 1:
|
||||
var keys []string
|
||||
for _, pa := range matches {
|
||||
keys = append(keys, pa.Key)
|
||||
}
|
||||
return refuse("%d accounts in the profile have its address (%s): remove the duplicate in Betterbird, or "+
|
||||
"give the one to adopt as key in this node's layer of the setting accounts (novox/hq ADR 0317 §7, §8)",
|
||||
len(matches), strings.Join(keys, ", "))
|
||||
case matches[0].Owl:
|
||||
return refuse("the profile's account with its address, %s, is Owl's, which is never declared (novox/hq "+
|
||||
"ADR 0316 §13): take it out of the setting accounts", matches[0].Key)
|
||||
}
|
||||
return adopt(matches[0])
|
||||
}
|
||||
|
||||
// changedPrefs are the preferences of a resolved account whose declared value differs from the profile's
|
||||
// saved one (prefs.js), by name only (ADR 0317 §4). A preference prefs.js does not hold is changed unless the
|
||||
// declared value is what Betterbird reads in its place.
|
||||
func changedPrefs(r resolvedAccount, a Account, saved map[string]string) []string {
|
||||
defaults := map[string]string{}
|
||||
if r.How == adopted {
|
||||
// What Betterbird reads when prefs.js holds no value: the same defaults betterbird_accounts reads with.
|
||||
pa := profileAccount{Key: r.Keys.Account, Server: r.Keys.Server, Kind: a.Incoming.Kind,
|
||||
Identities: []string{r.Keys.Identity}}
|
||||
view := readAccount(saved, pa)
|
||||
for _, p := range declaredPrefs(r.Keys, view, true) {
|
||||
defaults[p[0]] = p[1]
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
for _, p := range declaredPrefs(r.Keys, a, r.How == adopted) {
|
||||
have, ok := saved[p[0]]
|
||||
if !ok {
|
||||
have, ok = defaults[p[0]]
|
||||
}
|
||||
if !ok || have != p[1] {
|
||||
out = append(out, p[0])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -10,19 +10,23 @@ package main
|
||||
// (passwordSecret, accountPrefix);
|
||||
// - the profile's extensions.json, for the add-ons' ids and whether each is on;
|
||||
// - the module's declared user.js (~/.config/betterbird/user.js), which the node-engine writes from
|
||||
// the module's setting `prefs`.
|
||||
// the module's setting `prefs`, and its declared accounts (~/.config/betterbird/accounts.json, the
|
||||
// setting `accounts`, accounts.go);
|
||||
// - of logins.json, only the server name each kept sign-in is for, which Betterbird keeps in the clear,
|
||||
// to count the declared accounts that have not signed in yet (novox/hq ADR 0316 §16).
|
||||
//
|
||||
// Never read: the mail store, the address books, logins.json, key4.db, cert9.db, cookies, the
|
||||
// calendar's caches. Nothing of the operator's mail, calendar or accounts is answered.
|
||||
// Never read: the mail store, the address books, a user name or password in logins.json, key4.db,
|
||||
// cert9.db, cookies, the calendar's caches. Nothing of the operator's mail or calendar is answered, and of
|
||||
// their accounts only counts and kinds.
|
||||
//
|
||||
// The one file a tool writes in the profile is user.js, and only the module's own: the declared file,
|
||||
// copied whole (betterbird_user_js with apply, betterbird_restart). A user.js that is not the module's
|
||||
// is never overwritten.
|
||||
// The files a tool writes in the profile are user.js, and only the module's own (the declared file and
|
||||
// the declared accounts' preferences, betterbird_user_js with apply, betterbird_restart, and the start
|
||||
// from the launcher), and the module's own keys in the account lists of prefs.js, only while Betterbird is
|
||||
// not running (accounts.go). A user.js that is not the module's is never overwritten.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -35,8 +39,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
mainComm = "betterbird-bin"
|
||||
startBin = "/usr/bin/betterbird"
|
||||
mainComm = "betterbird-bin"
|
||||
// startBin is Betterbird itself, where the mesh's release and the community package both put it. The
|
||||
// module's start (/usr/local/bin/betterbird) runs the preparation first; a tool that starts Betterbird
|
||||
// prepares itself and starts this.
|
||||
startBin = installDir + "/betterbird"
|
||||
// packageFor is the community package the mesh's release replaces (novox/hq ADR 0316 §4).
|
||||
packageFor = "betterbird-bin"
|
||||
appIni = "/opt/betterbird/application.ini"
|
||||
restartAs = "mesh-betterbird"
|
||||
@@ -262,6 +270,11 @@ var (
|
||||
allowedExtensions = map[string]bool{"extensions.activeThemeID": true}
|
||||
)
|
||||
|
||||
// accountOwned are the preferences of an account and of the account lists: the setting accounts' alone, so
|
||||
// the setting prefs refuses them and an account has one owner (novox/hq ADR 0316 §11).
|
||||
var accountOwned = []string{"mail.account.", "mail.identity.", "mail.server.", "mail.smtpserver.",
|
||||
"mail.accountmanager.", "mail.smtpservers", "mail.smtp.defaultserver"}
|
||||
|
||||
// withheld says why a preference's value is never answered, or "" when it may be.
|
||||
func withheld(name string) string {
|
||||
if strings.HasPrefix(name, "extensions.") && !allowedExtensions[name] {
|
||||
@@ -392,6 +405,12 @@ func validUserJS(text string) error {
|
||||
if passwordSecret.MatchString(name) {
|
||||
return fmt.Errorf("line %d sets %s, a preference that can hold a credential: not through the mesh", i+1, name)
|
||||
}
|
||||
for _, p := range accountOwned {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return fmt.Errorf("line %d sets %s, an account's preference: an account is declared in the setting "+
|
||||
"accounts, never in prefs (novox/hq ADR 0316 §11)", i+1, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -433,13 +452,35 @@ type UserJS struct {
|
||||
Effective string `json:"effective,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Applied bool `json:"applied,omitempty"`
|
||||
// Accounts are how many accounts the setting accounts declares, and what is wrong with them.
|
||||
Accounts int `json:"declared_accounts"`
|
||||
AccountsProblem string `json:"accounts_problem,omitempty"`
|
||||
// Each is every declared account by its place: adopted, created or refused (with why), its keys, and the
|
||||
// preferences an apply changed for it, by name (novox/hq ADR 0317 §3, §4).
|
||||
Each []resolvedAccount `json:"accounts,omitempty"`
|
||||
// Lists says whether the keys of the accounts the module created were written into the account lists.
|
||||
Lists string `json:"account_lists,omitempty"`
|
||||
}
|
||||
|
||||
// userJS compares the declared user.js with the profile's; apply places the declared one when the
|
||||
// profile has none or the module's.
|
||||
// refusedAccounts are the reasons of the declared accounts that were refused.
|
||||
func (u UserJS) refusedAccounts() []string {
|
||||
var out []string
|
||||
for _, r := range u.Each {
|
||||
if r.How == refused {
|
||||
out = append(out, r.Why)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// userJS compares the user.js the module places — the declared file, then the declared accounts' fields under
|
||||
// the keys each was adopted or created under (adoption.go) — with the profile's; apply places it when the
|
||||
// profile has none or the module's, records what was adopted or created, and writes the keys of the accounts
|
||||
// it created into the profile's account lists when Betterbird is not running. A refused account gets nothing;
|
||||
// the others go ahead (novox/hq ADR 0317 §7).
|
||||
func (m *Machine) userJS(apply bool) (UserJS, error) {
|
||||
a := UserJS{Declared: "~/" + declared}
|
||||
want, err := readBounded(m.home(declared))
|
||||
declaredText, err := readBounded(m.home(declared))
|
||||
a.Present = err == nil
|
||||
p, perr := m.profile()
|
||||
if perr != nil {
|
||||
@@ -447,11 +488,21 @@ func (m *Machine) userJS(apply bool) (UserJS, error) {
|
||||
}
|
||||
target := filepath.Join(p.path, "user.js")
|
||||
a.Profile = m.tilde(strings.TrimPrefix(target, m.Root))
|
||||
accounts, _, aerr := m.readAccounts()
|
||||
if aerr != nil {
|
||||
a.AccountsProblem = aerr.Error()
|
||||
accounts = nil
|
||||
} else {
|
||||
a.Accounts = len(accounts)
|
||||
a.Each = resolveAccounts(accounts, mergedPrefs(p.path), m.readRecord(p.Dir))
|
||||
}
|
||||
var want []byte
|
||||
if a.Present {
|
||||
if verr := validUserJS(string(want)); verr != nil {
|
||||
if verr := validUserJS(string(declaredText)); verr != nil {
|
||||
a.Valid = verr.Error()
|
||||
}
|
||||
a.Content = string(want)
|
||||
want = []byte(composeUserJS(string(declaredText), accounts, a.Each))
|
||||
a.Content = m.answeredUserJS(string(want))
|
||||
for _, line := range strings.Split(string(want), "\n") {
|
||||
for _, st := range splitStatements(strings.TrimSpace(line)) {
|
||||
if statement.MatchString(st) {
|
||||
@@ -477,6 +528,17 @@ func (m *Machine) userJS(apply bool) (UserJS, error) {
|
||||
return a, fmt.Errorf("the module's user.js is not on this machine (~/%s): the module is not pushed here yet", declared)
|
||||
case a.Valid != "":
|
||||
return a, fmt.Errorf("the module's user.js is refused: %s; change the setting prefs", a.Valid)
|
||||
case a.AccountsProblem != "":
|
||||
return a, fmt.Errorf("nothing was placed: %s", a.AccountsProblem)
|
||||
}
|
||||
saved, _ := readBounded(filepath.Join(p.path, "prefs.js"))
|
||||
savedPrefs := readPrefs(saved)
|
||||
for i, r := range a.Each {
|
||||
if r.How != refused {
|
||||
a.Each[i].Changed = changedPrefs(r, accounts[r.name], savedPrefs)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case herr == nil && !bytes.HasPrefix(have, []byte(mark)):
|
||||
return a, fmt.Errorf("%s is not the module's: move it aside (its preferences belong in the setting prefs), then apply again", a.Profile)
|
||||
}
|
||||
@@ -491,6 +553,14 @@ func (m *Machine) userJS(apply bool) (UserJS, error) {
|
||||
}
|
||||
}
|
||||
a.Applied, a.Placed = true, "the module's, as declared"
|
||||
if err := m.writeRecord(p.Dir, a.Each); err != nil {
|
||||
return a, fmt.Errorf("the user.js is placed, and what was adopted or created could not be recorded: %v", err)
|
||||
}
|
||||
if lists, err := m.writeAccountLists(p.path, a.Each); err != nil {
|
||||
a.Lists = "not written: " + err.Error()
|
||||
} else {
|
||||
a.Lists = lists
|
||||
}
|
||||
}
|
||||
if ps := m.mainProcs(); len(ps) > 0 {
|
||||
if st, err := os.Stat(target); err == nil {
|
||||
@@ -504,27 +574,74 @@ func (m *Machine) userJS(apply bool) (UserJS, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// answeredUserJS is the user.js as a tool answers it (novox/hq ADR 0316 §16, ADR 0317 §9): an account's
|
||||
// preferences with their values withheld, every other value masked as betterbird_prefs masks it.
|
||||
func (m *Machine) answeredUserJS(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, line := range lines {
|
||||
for _, st := range splitStatements(strings.TrimSpace(line)) {
|
||||
mm := prefLine.FindStringSubmatch(st)
|
||||
if mm == nil {
|
||||
continue
|
||||
}
|
||||
value := m.maskValue(mm[2])
|
||||
if why := withheld(mm[1]); why != "" {
|
||||
value = `"<withheld>"`
|
||||
}
|
||||
lines[i] = fmt.Sprintf("user_pref(%s, %s);", quoted(mm[1]), value)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// Prepare is what the module's start runs before Betterbird (`betterbird-tools prepare`): with no Betterbird
|
||||
// of this account running, the user.js placed and the declared accounts written into the account lists. With
|
||||
// one running it does nothing, since the start then only hands over to it. Its error is said by the start,
|
||||
// which starts Betterbird either way.
|
||||
func (m *Machine) Prepare() error {
|
||||
if len(m.mainProcs()) > 0 {
|
||||
return nil
|
||||
}
|
||||
u, err := m.userJS(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var problems []string
|
||||
if strings.HasPrefix(u.Lists, "not written") {
|
||||
problems = append(problems, "the account lists were "+u.Lists)
|
||||
}
|
||||
problems = append(problems, u.refusedAccounts()...)
|
||||
if len(problems) > 0 {
|
||||
return fmt.Errorf("%s", strings.Join(problems, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status is what betterbird_status answers.
|
||||
type Status struct {
|
||||
Running []Proc `json:"running"`
|
||||
Children int `json:"children"`
|
||||
Installed string `json:"installed,omitempty"`
|
||||
Running []Proc `json:"running"`
|
||||
Children int `json:"children"`
|
||||
// Installation is what is installed and from where, the policy and Owl (novox/hq ADR 0316 §16).
|
||||
Installation Installation `json:"installation"`
|
||||
// Installed is the community package's version, while it is still there.
|
||||
Installed string `json:"community_package,omitempty"`
|
||||
Foreign bool `json:"from_the_aur,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Build string `json:"build,omitempty"`
|
||||
Profile *Profile `json:"profile,omitempty"`
|
||||
Problem string `json:"profile_problem,omitempty"`
|
||||
Output []Stream `json:"output,omitempty"`
|
||||
UserJS *UserJS `json:"user_js,omitempty"`
|
||||
AddOns []AddOn `json:"add_ons,omitempty"`
|
||||
// Accounts are counts: the declared, those in the profile's list, the others, those without a sign-in.
|
||||
Accounts *AccountCount `json:"accounts,omitempty"`
|
||||
AddOns []AddOn `json:"add_ons,omitempty"`
|
||||
// Display are the preferences that decide how its windows and notifications look.
|
||||
Display []Pref `json:"display_prefs,omitempty"`
|
||||
}
|
||||
|
||||
// AddOn is one add-on the operator installed: its id, and whether it is on.
|
||||
type AddOn struct {
|
||||
ID string `json:"id"`
|
||||
Active bool `json:"active"`
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// displayPrefs are the preferences betterbird_status answers: the reminder dialog, the theme, the
|
||||
@@ -550,39 +667,24 @@ func (m *Machine) Status() (Status, error) {
|
||||
if v, err := m.installed(packageFor); err == nil && v != "" {
|
||||
s.Installed, s.Foreign = v, m.foreign(packageFor)
|
||||
}
|
||||
if raw, err := readBounded(m.path(appIni)); err == nil {
|
||||
_, sec := iniSections(raw)
|
||||
s.Version, s.Build = sec["App"]["Version"], sec["App"]["BuildID"]
|
||||
}
|
||||
if len(s.Running) > 0 {
|
||||
s.Output = m.streams(s.Running[0].PID)
|
||||
}
|
||||
p, err := m.profile()
|
||||
if err != nil {
|
||||
s.Installation = m.installation("")
|
||||
s.Problem = err.Error()
|
||||
return s, nil
|
||||
}
|
||||
s.Profile = &p
|
||||
s.Installation = m.installation(p.path)
|
||||
if u, err := m.userJS(false); err == nil {
|
||||
u.Content = ""
|
||||
s.UserJS = &u
|
||||
}
|
||||
if raw, err := readBounded(filepath.Join(p.path, "extensions.json")); err == nil {
|
||||
var ext struct {
|
||||
Addons []struct {
|
||||
ID string `json:"id"`
|
||||
Active bool `json:"active"`
|
||||
Location string `json:"location"`
|
||||
} `json:"addons"`
|
||||
}
|
||||
if json.Unmarshal(raw, &ext) == nil {
|
||||
for _, a := range ext.Addons {
|
||||
if a.Location == "app-profile" {
|
||||
s.AddOns = append(s.AddOns, AddOn{a.ID, a.Active})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c := m.countAccounts(p.path, p.Dir)
|
||||
s.Accounts = &c
|
||||
s.AddOns = addOns(p.path)
|
||||
if got, err := m.Prefs(displayPrefs); err == nil {
|
||||
s.Display = got["prefs"].([]Pref)
|
||||
}
|
||||
@@ -711,7 +813,8 @@ func (m *Machine) Restart(discard bool) (RestartAnswer, error) {
|
||||
if u, err := m.userJS(true); err != nil {
|
||||
a.UserJS = "not placed: " + err.Error()
|
||||
} else {
|
||||
a.UserJS = fmt.Sprintf("placed: %d preference(s) from ~/%s", u.Statements, declared)
|
||||
a.UserJS = fmt.Sprintf("placed: %d preference(s) from ~/%s, %d declared account(s); account lists %s",
|
||||
u.Statements, declared, u.Accounts, u.Lists)
|
||||
}
|
||||
// As detach does, with shorter bounds: the worst case of the whole call (3 s for the window tree,
|
||||
// 8 s to quit, 3 + 5 s to start, 4 s to see it) stays under the runtime's 30 s.
|
||||
@@ -750,12 +853,56 @@ type CheckAnswer struct {
|
||||
func (m *Machine) Check() (CheckAnswer, error) {
|
||||
a := CheckAnswer{Findings: []Finding{}}
|
||||
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
||||
v, err := m.installed(packageFor)
|
||||
if err != nil {
|
||||
if _, err := m.installed(packageFor); err != nil {
|
||||
return a, err
|
||||
}
|
||||
if v == "" {
|
||||
add("Betterbird ("+packageFor+") is not installed", "install it from the AUR: the node-engine installs packages from the official repositories only, so the module does not")
|
||||
p, perr := m.profile()
|
||||
profilePath := ""
|
||||
if perr == nil {
|
||||
profilePath = p.path
|
||||
}
|
||||
in := m.installation(profilePath)
|
||||
switch {
|
||||
case in.From == "not installed":
|
||||
add("Betterbird is not installed in "+installDir, "the module's push places the mesh's release; it waits for the operator's push (upgrade policy record)")
|
||||
case in.From != "the mesh":
|
||||
add("Betterbird is "+in.From, "quit Betterbird, then push the module to this node: the push removes the community package and places the mesh's release")
|
||||
}
|
||||
if in.Behind {
|
||||
add("Betterbird "+in.Version+" (build "+in.Build+") is behind the newest published build "+in.Newest,
|
||||
"move the module's pin in a reviewed change (novox/hq ADR 0316 §5)")
|
||||
}
|
||||
if in.From == "the mesh" && !strings.HasPrefix(in.Policy, "placed:") {
|
||||
add("the enterprise policy is "+in.Policy, "push the module to this node")
|
||||
}
|
||||
switch {
|
||||
case in.From != "the mesh":
|
||||
case in.Owl == nil || in.Owl.Pinned == "":
|
||||
add("Owl's xpi is not placed ("+owlFile+")", "push the module to this node")
|
||||
case in.Owl.InProfile == "":
|
||||
add("Owl is not installed in the profile yet", "Betterbird installs it from the policy at its next start")
|
||||
case !in.Owl.AtThePin && !newerVersion(in.Owl.InProfile, in.Owl.Pinned):
|
||||
add("Owl "+in.Owl.InProfile+" runs in the profile, older than the mesh's pin "+in.Owl.Pinned,
|
||||
"Owl is installed by policy and cannot be removed in Betterbird: move the module's Owl pin in a reviewed change (novox/hq ADR 0316 §6)")
|
||||
case !in.Owl.Active:
|
||||
add("Owl is installed and not active", "turn it on in Betterbird's add-ons")
|
||||
}
|
||||
if perr == nil {
|
||||
c := m.countAccounts(p.path, p.Dir)
|
||||
for _, r := range c.Each {
|
||||
if r.How == refused {
|
||||
add("refused: "+r.Why, "as it says; the other declared accounts go ahead")
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case c.InList < c.Declared:
|
||||
add(fmt.Sprintf("%d of %d declared account(s) are not in the profile's account list yet", c.Declared-c.InList, c.Declared),
|
||||
"quit Betterbird and start it from the launcher, or betterbird_restart")
|
||||
}
|
||||
if c.WithoutSignIn > 0 {
|
||||
add(fmt.Sprintf("%d declared account(s) have not signed in yet", c.WithoutSignIn),
|
||||
"a person signs in once, in Betterbird's own window, when it asks (novox/hq ADR 0316 §14)")
|
||||
}
|
||||
}
|
||||
ps := m.mainProcs()
|
||||
switch {
|
||||
@@ -772,6 +919,8 @@ func (m *Machine) Check() (CheckAnswer, error) {
|
||||
add("the module's user.js is not on this machine (~/"+declared+")", "the module is not pushed here: the controller's push")
|
||||
case u.Valid != "":
|
||||
add("the module's user.js is refused: "+u.Valid, "change the module's setting prefs")
|
||||
case u.AccountsProblem != "":
|
||||
add("the module's user.js is not placed: "+u.AccountsProblem, "change the module's setting accounts at the controller's terminal, or propose it (betterbird_accounts)")
|
||||
case !strings.HasPrefix(u.Placed, "the module's, as declared"):
|
||||
add("the profile's user.js is "+u.Placed, "betterbird_user_js with apply, then betterbird_restart")
|
||||
case strings.HasPrefix(u.Effective, "not yet"):
|
||||
|
||||
@@ -161,7 +161,7 @@ func TestStatusSaysWhatRunsTheProfileTheAddOnsAndTheDisplayPrefs(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Running) != 1 || s.Running[0].PID != 4000 || s.Children != 1 || s.Installed != "153.1.0esr-1" || !s.Foreign ||
|
||||
s.Version != "153.1.0" || s.Profile == nil || len(s.AddOns) != 1 || s.AddOns[0].ID != "owl@beonex.com" {
|
||||
s.Installation.Version != "153.1.0" || s.Profile == nil || len(s.AddOns) != 1 || s.AddOns[0].ID != "owl@beonex.com" {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if !s.Output[0].Dead {
|
||||
@@ -238,6 +238,13 @@ func TestTheReminderTestTouchesNothingOfTheOperatorsProfile(t *testing.T) {
|
||||
!strings.Contains(string(user), "file:///run/user/1000/"+testUnit+"/test.ics") || strings.Contains(string(user), "someone@") {
|
||||
t.Fatalf("%s", user)
|
||||
}
|
||||
// Betterbird's update check stays off, so no About window opens on the operator's screen, and the
|
||||
// test's own preferences come last, so nothing carried or declared overrides them.
|
||||
if !strings.Contains(string(user), `"betterbird.update.checkIntervalDays", 0`) ||
|
||||
strings.Index(string(user), "extensions.activeThemeID") > strings.Index(string(user), "calendar.registry.meshtest.uri") ||
|
||||
strings.Index(string(user), `"calendar.alarms.show"`) > strings.Index(string(user), "calendar.registry.meshtest.uri") {
|
||||
t.Fatalf("%s", user)
|
||||
}
|
||||
// The event starts 10 minutes after the reminder, which is due 40 s from now.
|
||||
if !strings.Contains(string(cal), "DTSTART:20270115T081040Z") || !strings.Contains(string(cal), "TRIGGER:-PT10M") ||
|
||||
a.Due != "2027-01-15T08:00:40Z" {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package main
|
||||
|
||||
// What the mesh installs (novox/hq ADR 0316 §1–§9, to-be 56 §1–§3): Betterbird from its pinned upstream release
|
||||
// in /opt/betterbird, where the community package betterbird-bin put it, so Betterbird keeps choosing the same
|
||||
// profile; the enterprise policy in its distribution directory, which turns Betterbird's own updater off and
|
||||
// installs Owl into every profile from the xpi the mesh placed beside it; and a defaults file naming the
|
||||
// system's spelling dictionaries. The tools read these files and say what they find: the version against the
|
||||
// pin and the newest published build, whether the policy is placed, Owl's version against its pin.
|
||||
//
|
||||
// **The pin on the machine is what the mesh placed.** The manifest pins the release and the xpi by sha256; the
|
||||
// node-engine places exactly those bytes, so the version in /opt/betterbird/application.ini (when the mesh
|
||||
// placed it) and the version in the placed xpi are the pins. The community package's own copy is said as
|
||||
// such, and the switch from it waits for the operator (ADR 0316 §4).
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
installDir = "/opt/betterbird"
|
||||
// policyFile is the enterprise policy the module places (ADR 0316 §7).
|
||||
policyFile = installDir + "/distribution/policies.json"
|
||||
// owlFile is Owl's xpi as the mesh placed it, and the URL the policy installs it from.
|
||||
owlFile = installDir + "/owl/owl.xpi"
|
||||
owlID = "owl@beonex.com"
|
||||
// preparation is the tools' program as the module places it for its start (betterbird-tools prepare).
|
||||
preparation = "/usr/local/lib/mesh-betterbird/betterbird-tools"
|
||||
// versionsURL is where Betterbird publishes its newest build per major version: what its own updater
|
||||
// reads (MailGlue's BetterbirdUpdateCheck), and what the status compares the installed build with.
|
||||
versionsURL = "https://www.betterbird.eu/start/versions.txt"
|
||||
)
|
||||
|
||||
// publishedBuilds fetches Betterbird's list of newest builds: a variable so a test answers.
|
||||
var publishedBuilds = func() (string, error) {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get(versionsURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("%s answered %s", versionsURL, resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
return string(body), err
|
||||
}
|
||||
|
||||
// Installation is what is installed, and from where.
|
||||
type Installation struct {
|
||||
// From is the mesh, the community package (betterbird-bin, before the switch), or nothing.
|
||||
From string `json:"from"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Build string `json:"build,omitempty"`
|
||||
// Newest is the newest build Betterbird published for this major version, and whether this one is behind.
|
||||
Newest string `json:"newest_published_build,omitempty"`
|
||||
Behind bool `json:"behind,omitempty"`
|
||||
// Policy says whether the module's enterprise policy is placed, and what it does.
|
||||
Policy string `json:"policy"`
|
||||
// Owl is the add-on's version as the mesh placed it (the pin), and as the profile runs it.
|
||||
Owl *OwlState `json:"owl,omitempty"`
|
||||
}
|
||||
|
||||
// OwlState is Owl against its pin.
|
||||
type OwlState struct {
|
||||
Pinned string `json:"pinned,omitempty"`
|
||||
InProfile string `json:"in_profile,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
AtThePin bool `json:"at_the_pin"`
|
||||
// Note says, as a fact, a profile running an Owl newer than the pin: the policy's install keeps a newer
|
||||
// version, and Owl installed by policy cannot be removed in Betterbird.
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// newerVersion says whether version a is newer than b, comparing dotted numbers.
|
||||
func newerVersion(a, b string) bool {
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
pa, pb := strings.Split(a, "."), strings.Split(b, ".")
|
||||
for i := 0; i < len(pa) || i < len(pb); i++ {
|
||||
var x, y int
|
||||
if i < len(pa) {
|
||||
fmt.Sscanf(pa[i], "%d", &x)
|
||||
}
|
||||
if i < len(pb) {
|
||||
fmt.Sscanf(pb[i], "%d", &y)
|
||||
}
|
||||
if x != y {
|
||||
return x > y
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// installation reads what is installed. profile may be empty when none is found yet.
|
||||
func (m *Machine) installation(profile string) Installation {
|
||||
var in Installation
|
||||
community, _ := m.installed(packageFor)
|
||||
raw, err := readBounded(m.path(appIni))
|
||||
switch {
|
||||
case community != "":
|
||||
in.From = "the community package " + packageFor + " " + community + ": the switch to the mesh's release waits for the operator's push, with Betterbird quit (novox/hq ADR 0316 §4)"
|
||||
case err == nil:
|
||||
in.From = "the mesh"
|
||||
default:
|
||||
in.From = "not installed"
|
||||
}
|
||||
if err == nil {
|
||||
_, sec := iniSections(raw)
|
||||
in.Version, in.Build = sec["App"]["Version"], sec["App"]["BuildID"]
|
||||
in.Newest, in.Behind = newestBuild(in.Version, in.Build)
|
||||
}
|
||||
in.Policy = m.policyState()
|
||||
pinned := xpiVersion(m.path(owlFile))
|
||||
owl := &OwlState{Pinned: pinned}
|
||||
if profile != "" {
|
||||
for _, a := range addOns(profile) {
|
||||
if a.ID == owlID {
|
||||
owl.InProfile, owl.Active = a.Version, a.Active
|
||||
}
|
||||
}
|
||||
}
|
||||
owl.AtThePin = owl.Pinned != "" && owl.Pinned == owl.InProfile
|
||||
if newerVersion(owl.InProfile, owl.Pinned) {
|
||||
owl.Note = "the profile runs Owl " + owl.InProfile + ", newer than the mesh's pin " + owl.Pinned + ": the policy " +
|
||||
"keeps a newer version and installs the pin only where Owl is missing. To run the same Owl everywhere, " +
|
||||
"move the module's pin in a reviewed change"
|
||||
}
|
||||
if owl.Pinned != "" || owl.InProfile != "" {
|
||||
in.Owl = owl
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// newestBuild is the newest build Betterbird published for a version's major, and whether build is older.
|
||||
func newestBuild(version, build string) (string, bool) {
|
||||
major, _, _ := strings.Cut(version, ".")
|
||||
if major == "" || build == "" {
|
||||
return "", false
|
||||
}
|
||||
list, err := publishedBuilds()
|
||||
if err != nil {
|
||||
return "unknown: " + err.Error(), false
|
||||
}
|
||||
for _, l := range strings.Split(list, "\n") {
|
||||
f := strings.Split(strings.TrimSpace(l), ",")
|
||||
if len(f) == 3 && f[0] == "Linux" && f[1] == major {
|
||||
return f[2], f[2] > build
|
||||
}
|
||||
}
|
||||
return "unknown: " + versionsURL + " names no build for " + major, false
|
||||
}
|
||||
|
||||
// policyState says whether the module's policy is placed and does what ADR 0316 §5 and §7 say.
|
||||
func (m *Machine) policyState() string {
|
||||
raw, err := readBounded(m.path(policyFile))
|
||||
if err != nil {
|
||||
return "not placed: " + policyFile + " is missing"
|
||||
}
|
||||
if why := policyProblem(raw); why != "" {
|
||||
return "placed, and " + why
|
||||
}
|
||||
return "placed: Betterbird's updater off, Owl installed into every profile from the mesh's xpi, its updates off"
|
||||
}
|
||||
|
||||
// policyProblem is what the policy fails to say of what ADR 0316 asks, or "".
|
||||
func policyProblem(raw []byte) string {
|
||||
var p struct {
|
||||
Policies struct {
|
||||
DisableAppUpdate bool `json:"DisableAppUpdate"`
|
||||
ExtensionSettings map[string]struct {
|
||||
Mode string `json:"installation_mode"`
|
||||
URL string `json:"install_url"`
|
||||
Disabled bool `json:"updates_disabled"`
|
||||
} `json:"ExtensionSettings"`
|
||||
} `json:"policies"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return "it is not JSON: " + err.Error()
|
||||
}
|
||||
owl, ok := p.Policies.ExtensionSettings[owlID]
|
||||
switch {
|
||||
case !p.Policies.DisableAppUpdate:
|
||||
return "it does not turn Betterbird's updater off (DisableAppUpdate)"
|
||||
case !ok || owl.Mode != "force_installed":
|
||||
return "it does not install Owl into every profile (force_installed)"
|
||||
case owl.URL != "file://"+owlFile:
|
||||
return "it installs Owl from " + owl.URL + ", not from the mesh's xpi"
|
||||
case !owl.Disabled:
|
||||
return "it leaves Owl's updates on"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// xpiVersion is the version an add-on's manifest.json says, or "" when it cannot be read.
|
||||
func xpiVersion(path string) string {
|
||||
r, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer r.Close()
|
||||
for _, f := range r.File {
|
||||
if f.Name != "manifest.json" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer rc.Close()
|
||||
var mf struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if json.NewDecoder(io.LimitReader(rc, 1<<20)).Decode(&mf) != nil {
|
||||
return ""
|
||||
}
|
||||
return mf.Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// addOns reads a profile's extensions.json: each add-on's id, version, whether it is on, and where from.
|
||||
func addOns(profile string) []AddOn {
|
||||
raw, err := readBounded(filepath.Join(profile, "extensions.json"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var ext struct {
|
||||
Addons []struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Active bool `json:"active"`
|
||||
Location string `json:"location"`
|
||||
} `json:"addons"`
|
||||
}
|
||||
if json.Unmarshal(raw, &ext) != nil {
|
||||
return nil
|
||||
}
|
||||
var out []AddOn
|
||||
for _, a := range ext.Addons {
|
||||
if a.Location == "app-profile" {
|
||||
out = append(out, AddOn{ID: a.ID, Version: a.Version, Active: a.Active})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the mesh installs, as the tools say it (novox/hq ADR 0316 §16): the version against the pin and the
|
||||
// newest published build, the policy, Owl against its pin; and the reminder test, visibly a test.
|
||||
|
||||
func (f *fake) xpi(path, version string) {
|
||||
f.t.Helper()
|
||||
full := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
out, err := os.Create(full)
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
w := zip.NewWriter(out)
|
||||
m, _ := w.Create("manifest.json")
|
||||
m.Write([]byte(`{"version": "` + version + `", "browser_specific_settings": {"gecko": {"id": "owl@beonex.com"}}}`))
|
||||
w.Close()
|
||||
out.Close()
|
||||
}
|
||||
|
||||
// placedByTheMesh is a node after the switch: the release, the policy and Owl's xpi in /opt/betterbird, and
|
||||
// the profile running Owl at the pin.
|
||||
func (f *fake) placedByTheMesh() {
|
||||
f.noPackage()
|
||||
f.write(appIni, "[App]\nVendor=Betterbird Project\nVersion=153.4.0\nBuildID=20260929222610\n")
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "install", "distribution", "policies.json"))
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
f.write(policyFile, string(raw))
|
||||
f.xpi(owlFile, "1.5.6.1")
|
||||
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","version":"1.5.6.1","active":true,"location":"app-profile"}]}`)
|
||||
}
|
||||
|
||||
func TestTheStatusSaysTheInstallationAgainstThePinTheNewestBuildThePolicyAndOwl(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
f.placedByTheMesh()
|
||||
links(t, nil)
|
||||
publishedBuilds = func() (string, error) {
|
||||
return "blocklist,{}\nLinux,153,20261101000000\nLinux,140,20260829111240\n", nil
|
||||
}
|
||||
t.Cleanup(func() { publishedBuilds = func() (string, error) { return "", os.ErrDeadlineExceeded } })
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := s.Installation
|
||||
if in.From != "the mesh" || in.Version != "153.4.0" || in.Newest != "20261101000000" || !in.Behind ||
|
||||
!strings.HasPrefix(in.Policy, "placed:") || in.Owl == nil || in.Owl.Pinned != "1.5.6.1" || !in.Owl.AtThePin || !in.Owl.Active {
|
||||
t.Fatalf("%+v %+v", in, in.Owl)
|
||||
}
|
||||
c, err := f.Check()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, x := range c.Findings {
|
||||
found = found || strings.Contains(x.What, "behind the newest published build 20261101000000")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("a release left behind is said: %+v", c.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCommunityPackageAPolicyThatIsNotTheModulesAndAnOwlOffItsPinAreSaid(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
f.placedByTheMesh()
|
||||
links(t, nil)
|
||||
f.write(policyFile, `{"policies": {"ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed",
|
||||
"install_url": "https://vendor.example/owl.xpi", "updates_disabled": true}}}}`)
|
||||
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","version":"1.5.7","active":true,"location":"app-profile"}]}`)
|
||||
in := f.installation(filepath.Join(f.Root, profileDir))
|
||||
if !strings.Contains(in.Policy, "DisableAppUpdate") || in.Owl.AtThePin || in.Owl.InProfile != "1.5.7" {
|
||||
t.Fatalf("%+v %+v", in, in.Owl)
|
||||
}
|
||||
// A newer Owl than the pin is a fact with the real remedy, never a finding to remove it.
|
||||
if !strings.Contains(in.Owl.Note, "move the module's pin") {
|
||||
t.Fatalf("%+v", in.Owl)
|
||||
}
|
||||
f.write(policyFile, `{"policies": {"DisableAppUpdate": true, "ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed",
|
||||
"install_url": "file:///opt/betterbird/owl/owl.xpi", "updates_disabled": true}}}}`)
|
||||
c, _ := f.Check()
|
||||
for _, x := range c.Findings {
|
||||
if strings.Contains(x.What, "Owl") {
|
||||
t.Errorf("a newer Owl is not a finding: %+v", x)
|
||||
}
|
||||
}
|
||||
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","version":"1.5.5","active":true,"location":"app-profile"}]}`)
|
||||
c, _ = f.Check()
|
||||
found := false
|
||||
for _, x := range c.Findings {
|
||||
found = found || (strings.Contains(x.What, "older than the mesh's pin") && strings.Contains(x.Do, "move the module's Owl pin"))
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("an older Owl is a finding whose remedy is the pin: %+v", c.Findings)
|
||||
}
|
||||
f.write(policyFile, `{"policies": {"DisableAppUpdate": true, "ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed",
|
||||
"install_url": "https://vendor.example/owl.xpi", "updates_disabled": true}}}}`)
|
||||
if in := f.installation(""); !strings.Contains(in.Policy, "not from the mesh's xpi") {
|
||||
t.Fatalf("%s", in.Policy)
|
||||
}
|
||||
f.answer = func(name string, args []string) Output {
|
||||
if name == "pacman" && args[0] == "-Q" {
|
||||
return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
in = f.installation("")
|
||||
if !strings.HasPrefix(in.From, "the community package betterbird-bin 153.1.0esr-1") || !strings.Contains(in.From, "operator") {
|
||||
t.Fatalf("%s", in.From)
|
||||
}
|
||||
raw, _ := json.Marshal(in)
|
||||
if strings.Contains(string(raw), "example.org") {
|
||||
t.Fatalf("%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheReminderTestIsVisiblyATestAndSaysItOpensAWindow(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.declareAccounts(personal)
|
||||
a, err := f.ReminderTest(40*time.Second, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join("/run/user/1000", testUnit)
|
||||
css := f.read(dir + "/profile/chrome/userChrome.css")
|
||||
user := f.read(dir + "/profile/user.js")
|
||||
cal := f.read(dir + "/test.ics")
|
||||
if !strings.Contains(css, "MESH TEST") || !strings.Contains(css, `:root:not([windowtype="Calendar:AlarmWindow"])`) {
|
||||
t.Fatalf("the test's windows but the reminder carry a MESH TEST band: %s", css)
|
||||
}
|
||||
if !strings.Contains(user, `user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);`) ||
|
||||
strings.Contains(user, "mesh_personal") || strings.Contains(user, "example.org") {
|
||||
t.Fatalf("the test profile reads its userChrome.css and holds no declared account: %s", user)
|
||||
}
|
||||
if !strings.Contains(cal, "SUMMARY:MESH TEST reminder") || !strings.Contains(a.OnScreen, "operator's screen") {
|
||||
t.Fatalf("the event and the answer say it is a test on the operator's screen: %s %+v", cal, a)
|
||||
}
|
||||
if !f.called("systemd-run --user --collect --quiet --unit="+testUnit) || !strings.Contains(strings.Join(f.calls, "\n"), startBin+" --no-remote") {
|
||||
t.Fatalf("the test starts Betterbird itself, never the module's start: %v", f.calls)
|
||||
}
|
||||
for _, tool := range tools() {
|
||||
if tool.Name == "betterbird_reminder_test" && !strings.HasPrefix(tool.Description, "OPENS WINDOWS ON THE OPERATOR'S SCREEN") {
|
||||
t.Fatalf("the description says first that a window opens on the operator's screen: %s", tool.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheUserJSDescriptionSaysAdoption(t *testing.T) {
|
||||
for _, tool := range tools() {
|
||||
if tool.Name == "betterbird_user_js" && (!strings.Contains(tool.Description, "adopted under its own keys") ||
|
||||
strings.Contains(tool.Description, "refuses the apply")) {
|
||||
t.Fatalf("%s", tool.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,15 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// The module's start runs this before Betterbird (novox/hq ADR 0316 §11, §12): the user.js placed and the
|
||||
// declared accounts written into the account lists, while no Betterbird of this account runs.
|
||||
if len(os.Args) == 2 && os.Args[1] == "prepare" {
|
||||
if err := machine.Prepare(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "betterbird: "+err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
@@ -29,10 +38,13 @@ func tools() []stdio.Tool {
|
||||
{
|
||||
Name: "betterbird_status",
|
||||
Description: "Betterbird: whether it runs (the main process's pid, since, and the scope it runs in; how " +
|
||||
"many child processes), the installed package and version and whether it is from the AUR, where its " +
|
||||
"output goes, the profile it uses and how that was found, the module's user.js (declared, placed, in " +
|
||||
"effect), the add-ons the operator installed, and the preferences that decide how its windows and " +
|
||||
"notifications look (reminder dialog, theme, renderer, system notifications). (r)",
|
||||
"many child processes); what is installed in /opt/betterbird and from where (the mesh's pinned release, " +
|
||||
"or the community package before the switch), its version and build beside the newest published build; " +
|
||||
"whether the enterprise policy is placed; Owl's version against the mesh's pin and whether it is active; " +
|
||||
"where its output goes; the profile it uses and how that was found; the module's user.js (declared, " +
|
||||
"placed, in effect); the declared accounts by count (in the profile's list, other accounts, not signed " +
|
||||
"in yet), never an address, host or name; the add-ons in the profile; and the preferences that decide " +
|
||||
"how its windows and notifications look. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||
},
|
||||
{
|
||||
@@ -55,11 +67,15 @@ func tools() []stdio.Tool {
|
||||
},
|
||||
{
|
||||
Name: "betterbird_user_js",
|
||||
Description: "The module's user.js — the preferences the module declares, from its setting prefs, written by " +
|
||||
"the mesh to ~/.config/betterbird/user.js — against the profile's user.js: whether it is placed, and " +
|
||||
"whether the running Betterbird started after it was. With apply, place it in the profile (only where " +
|
||||
"the profile has no user.js or the module's own; an operator's file is never overwritten). Betterbird " +
|
||||
"reads it at its next start: betterbird_restart. (r; a with apply)",
|
||||
Description: "The module's user.js — the preferences the module declares (setting prefs), then the declared " +
|
||||
"accounts' preferences under the module's own keys (setting accounts) — against the profile's user.js: " +
|
||||
"whether it is placed, and whether the running Betterbird started after it was. With apply, place it in " +
|
||||
"the profile (only where the profile has no user.js or the module's own; an operator's file is never " +
|
||||
"overwritten). A declared account the profile already holds at its address is adopted under its own keys; " +
|
||||
"one it does not hold is created under the module's keys, which are added to the account lists when " +
|
||||
"Betterbird is not running; an ambiguous one is refused alone, with the remedy (novox/hq ADR 0317). " +
|
||||
"Accounts' values are withheld in the answer. Betterbird reads it at its next start: betterbird_restart. " +
|
||||
"(r; a with apply)",
|
||||
Input: map[string]any{
|
||||
"apply": map[string]any{"type": "boolean", "description": "place the declared user.js in the profile (default false)"},
|
||||
},
|
||||
@@ -118,19 +134,43 @@ func tools() []stdio.Tool {
|
||||
},
|
||||
{
|
||||
Name: "betterbird_check",
|
||||
Description: "Check what the module promises: Betterbird is installed; one runs; its profile is found; the " +
|
||||
"module's user.js is on the machine, valid, placed in the profile and in effect; its output is read " +
|
||||
"(a pipe nobody reads loses what it prints). Answers ok and each finding with what to do. (r)",
|
||||
Description: "Check what the module promises: Betterbird installed from the mesh's pinned release and not " +
|
||||
"behind the newest published build; the enterprise policy placed; Owl at its pin and active; one " +
|
||||
"Betterbird runs; its profile is found; the module's user.js on the machine, valid, placed and in " +
|
||||
"effect; the declared accounts in the profile's list, none refused, each signed in once; its output " +
|
||||
"read. Answers ok and each finding with what to do, never an address, host or name. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||
},
|
||||
{
|
||||
Name: "betterbird_accounts",
|
||||
Description: "The accounts of the profile Betterbird uses, by count and kind: the IMAP and POP3 accounts a " +
|
||||
"declaration can hold, the accounts Owl keeps (never declared), and the rest. Never an address, a host " +
|
||||
"or a name, and never anything of the login store. With propose, it builds the module's setting " +
|
||||
"accounts from them (every account but Owl's, without any password or token) and proposes it through " +
|
||||
"the controller's settings verb for the operator's Approve on a channel that proves who answers " +
|
||||
"(novox/hq ADR 0317, ADR 0277): nothing changes until then, and the operator reads the value whole at " +
|
||||
"their desk. (r; a with propose)",
|
||||
Input: map[string]any{
|
||||
"propose": map[string]any{"type": "boolean", "description": "build the setting accounts from the profile and propose it to the operator (default false)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
propose, err := flag(args, "propose", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return machine.ProfileAccounts(propose)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "betterbird_reminder_test",
|
||||
Description: "Show a calendar reminder without touching the operator's calendar: a second Betterbird in a " +
|
||||
"throw-away profile under the runtime directory (--no-remote, unit mesh-betterbird-reminder-test, at most " +
|
||||
"10 minutes), with the module's user.js and one local calendar file holding one event whose reminder is " +
|
||||
"due after `after` seconds. carry copies named preferences of the operator's profile (a theme, a " +
|
||||
"colour switch) into it, never a credential or an account. Then xorg_windows (class Calendar) and " +
|
||||
"xorg_screenshot show the reminder window. stop ends it and removes the profile. (a)",
|
||||
Description: "OPENS WINDOWS ON THE OPERATOR'S SCREEN: tell the operator before calling it. Shows a calendar " +
|
||||
"reminder without touching the operator's calendar: a second Betterbird in a throw-away profile under " +
|
||||
"the runtime directory (--no-remote, unit mesh-betterbird-reminder-test, at most 10 minutes), its main " +
|
||||
"window marked MESH TEST in red, with the module's user.js (no accounts) and one local calendar file " +
|
||||
"holding one event, \"MESH TEST reminder\", whose reminder is due after `after` seconds. carry copies " +
|
||||
"named preferences of the operator's profile (a theme, a colour switch) into it, never a credential or " +
|
||||
"an account. Then xorg_windows (class Calendar) and xorg_screenshot show the reminder window. stop ends " +
|
||||
"it and removes the profile. (a)",
|
||||
Input: map[string]any{
|
||||
"after": map[string]any{"type": "integer", "description": "seconds until the reminder is due (default 40, 15 to 300)"},
|
||||
"carry": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
|
||||
@@ -2,25 +2,32 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// betterbird's shape (novox/hq issue 345, ADR 0208): no package (Betterbird comes from the AUR, and the
|
||||
// node-engine installs from the official repositories only), no seat, no start of its own (the operator
|
||||
// starts it from the launcher); the X display on its own machine; one directory and one file of its own,
|
||||
// the user.js the setting prefs fills; and the Go bundle serving exactly the listed betterbird_ tools.
|
||||
// betterbird's shape (novox/hq issue 345, ADR 0208, ADR 0316): the X display on its own machine; its directory
|
||||
// in the home with the user.js the setting prefs fills and the accounts file the setting accounts fills, both
|
||||
// trusted; the community package declared absent before anything is placed in /opt/betterbird; Betterbird from
|
||||
// its pinned upstream release, Owl pinned by its vendor's digest, the policy and the dictionaries' default
|
||||
// unpacked into the installation; the tools' program for the start's preparation, the start and the launcher;
|
||||
// every new build waiting for the operator's push; and the Go bundle serving exactly the listed tools.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Requires []string `json:"requires"`
|
||||
Settings map[string]any `json:"settings"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Upgrade map[string]string `json:"upgrade"`
|
||||
Requires []string `json:"requires"`
|
||||
Invokes []string `json:"invokes"`
|
||||
Settings map[string]any `json:"settings"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
@@ -41,43 +48,216 @@ func readManifest(t *testing.T) (manifest, string) {
|
||||
return m, string(raw)
|
||||
}
|
||||
|
||||
func TestItOwnsTheUserJSAndNothingOfTheProfile(t *testing.T) {
|
||||
func resource(t *testing.T, m manifest, id string) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("module.json has no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func artifact(t *testing.T, m manifest, name string) map[string]any {
|
||||
t.Helper()
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["name"] == name {
|
||||
return a
|
||||
}
|
||||
}
|
||||
t.Fatalf("module.json builds no artifact %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
if m.Module != "betterbird" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
// Every new build waits for a person's push: the switch removes a package and replaces what a running
|
||||
// Betterbird has open (ADR 0316 §4).
|
||||
if m.Upgrade["policy"] != "record" || strings.TrimSpace(m.Upgrade["why"]) == "" {
|
||||
t.Fatalf("the module is marked for a person's approval, with why: %v", m.Upgrade)
|
||||
}
|
||||
var ids []string
|
||||
for _, r := range m.Resources {
|
||||
ids = append(ids, r["id"].(string))
|
||||
if r["type"] == "package" {
|
||||
t.Errorf("betterbird-bin is the AUR's: a package resource asks the node-engine for what it cannot install")
|
||||
}
|
||||
if p, _ := r["path"].(string); !strings.HasPrefix(p, "${machine:account-home}/.config/betterbird") {
|
||||
t.Errorf("a path outside the module's directory: %s", p)
|
||||
}
|
||||
}
|
||||
if strings.Join(ids, ",") != "configuration-dir,user-js" {
|
||||
t.Fatalf("%v", ids)
|
||||
want := "configuration-dir,user-js,accounts,community-package,installation,owl,policies,spelling,preparation,start,launcher"
|
||||
if strings.Join(ids, ",") != want {
|
||||
t.Fatalf("the resources, in the order the node-engine applies them: %v, want %s", ids, want)
|
||||
}
|
||||
pkg := resource(t, m, "community-package")
|
||||
if pkg["type"] != "package" || pkg["package"] != packageFor || pkg["absent"] != true {
|
||||
t.Fatalf("the community package is declared absent (ADR 0180): %v", pkg)
|
||||
}
|
||||
places := map[string]string{"installation": installDir, "owl": filepath.Dir(owlFile),
|
||||
"policies": filepath.Dir(policyFile), "spelling": installDir + "/defaults/pref", "preparation": filepath.Dir(preparation)}
|
||||
arts := map[string]string{"installation": "archive", "owl": "owl", "policies": "distribution", "spelling": "defaults", "preparation": "tools"}
|
||||
for id, path := range places {
|
||||
r := resource(t, m, id)
|
||||
if r["type"] != "archive" || r["path"] != path || r["artifact"] != arts[id] || r["owner"] != nil {
|
||||
t.Errorf("%s is the %s archive unpacked at %s, root's: %v", id, arts[id], path, r)
|
||||
}
|
||||
}
|
||||
for _, never := range []string{".thunderbird", "prefs.js", "logins.json", "key4.db"} {
|
||||
if strings.Contains(raw, never) {
|
||||
t.Errorf("module.json names %q: the profile is Betterbird's", never)
|
||||
}
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http://", "mediahuis"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
file := m.Resources[1]
|
||||
}
|
||||
|
||||
func TestTheUserJSAndTheAccountsAreTrustedFilesOfTheModulesOwn(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
file := resource(t, m, "user-js")
|
||||
content := file["content"].(string)
|
||||
if !strings.HasPrefix(content, mark) || !strings.Contains(content, "${setting:prefs}") || file["trusted"] != nil {
|
||||
t.Fatalf("the user.js begins with the mark, holds the setting, and stays trusted (only the terminal sets it): %v", file)
|
||||
if !strings.HasPrefix(content, mark) || !strings.Contains(content, "${setting:prefs}") || file["trusted"] != true {
|
||||
t.Fatalf("the user.js begins with the mark, holds the setting, and is trusted (only the terminal or the operator's warrant sets it): %v", file)
|
||||
}
|
||||
// The default is a comment, so a machine without the setting places a valid, empty user.js.
|
||||
def := m.Settings["prefs"].(map[string]any)["default"].(string)
|
||||
if err := validUserJS(strings.ReplaceAll(content, "${setting:prefs}", def)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acc := resource(t, m, "accounts")
|
||||
if acc["path"] != "${machine:account-home}/"+accountsFile || acc["merge"] != "json" || acc["trusted"] != true ||
|
||||
acc["mode"] != "0600" || acc["owner"] != "${machine:account}" {
|
||||
t.Fatalf("the setting accounts reaches the machine as a trusted mergeable file of the account's (ADR 0316 §10, §15): %v", acc)
|
||||
}
|
||||
got, err := parseAccounts([]byte(acc["content"].(string)))
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("its default declares no account: %v %v", got, err)
|
||||
}
|
||||
if _, declared := m.Settings["accounts"]; declared {
|
||||
t.Fatal("accounts is the operator's value and has no default in the definition (ADR 0262)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBothPinsAreAURLAVersionAndASha256AndOwlIsNeverInTheRepository(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
sha := regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
for _, name := range []string{"archive", "owl"} {
|
||||
a := artifact(t, m, name)
|
||||
u, _ := a["url"].(string)
|
||||
if a["kind"] != "archive" || !strings.HasPrefix(u, "https://") || !sha.MatchString(fmt.Sprint(a["sha256"])) ||
|
||||
strings.TrimSpace(fmt.Sprint(a["version"])) == "" || a["from"] != nil {
|
||||
t.Errorf("%s is pinned by URL, version and sha256, and copied from nowhere in the repository: %v", name, a)
|
||||
}
|
||||
}
|
||||
if a := artifact(t, m, "archive"); a["within"] != "betterbird" {
|
||||
t.Errorf("the release unpacks into betterbird/, whose content is the installation: %v", a)
|
||||
}
|
||||
if a := artifact(t, m, "owl"); a["file"] != filepath.Base(owlFile) {
|
||||
t.Errorf("Owl's xpi is kept whole as %s: %v", filepath.Base(owlFile), a)
|
||||
}
|
||||
// Owl's licence forbids copying it to third parties: no xpi anywhere in the module's tree.
|
||||
filepath.Walk(filepath.Join("..", ".."), func(p string, info os.FileInfo, err error) error {
|
||||
if err == nil && strings.HasSuffix(strings.ToLower(p), ".xpi") {
|
||||
t.Errorf("%s is in the repository", p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// owlSupports is the range of Betterbird versions each pinned Owl declares in its manifest.json
|
||||
// (browser_specific_settings.gecko strict_min_version, strict_max_version), read from the vendor's xpi
|
||||
// when its pin was taken. Moving Owl's pin means reading the new xpi's range into here.
|
||||
var owlSupports = map[string][2]string{
|
||||
"1.5.6.1": {"115.0", "156.*"},
|
||||
}
|
||||
|
||||
// owlAccepts says why a Betterbird version is outside the range an Owl version declares, or nil.
|
||||
func owlAccepts(owl, betterbird string) error {
|
||||
r, known := owlSupports[owl]
|
||||
if !known {
|
||||
return fmt.Errorf("Owl %s's supported range is not recorded in owlSupports: read it from the xpi's manifest.json", owl)
|
||||
}
|
||||
major := func(v string) int {
|
||||
head, _, _ := strings.Cut(v, ".")
|
||||
n, err := strconv.Atoi(head)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return n
|
||||
}
|
||||
b, lo, hi := major(betterbird), major(r[0]), major(r[1])
|
||||
if b < 0 || b < lo || b > hi {
|
||||
return fmt.Errorf("Betterbird %s is outside the range Owl %s supports (%s to %s): a Betterbird Owl does not support "+
|
||||
"waits for Owl's pin to move (novox/hq ADR 0316 §8)", betterbird, owl, r[0], r[1])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestABetterbirdOwlDoesNotSupportIsRefused(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
owl, bb := fmt.Sprint(artifact(t, m, "owl")["version"]), fmt.Sprint(artifact(t, m, "archive")["version"])
|
||||
if err := owlAccepts(owl, bb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, outside := range []string{"157.0esr-bb1", "102.15.1esr-bb4"} {
|
||||
if err := owlAccepts(owl, outside); err == nil {
|
||||
t.Errorf("Betterbird %s was accepted with Owl %s", outside, owl)
|
||||
}
|
||||
}
|
||||
if err := owlAccepts("9.9", bb); err == nil {
|
||||
t.Error("an Owl whose range nobody read was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePolicyTurnsTheUpdaterOffAndInstallsTheMeshsOwlEverywhere(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
if a := artifact(t, m, "distribution"); a["kind"] != "archive" || a["from"] != "install/distribution" {
|
||||
t.Fatalf("%v", a)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "install", "distribution", "policies.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why := policyProblem(raw); why != "" {
|
||||
t.Fatalf("the module's policies.json: %s", why)
|
||||
}
|
||||
entries, _ := os.ReadDir(filepath.Join("..", "..", "install", "distribution"))
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("the distribution directory holds the policy alone: %v", entries)
|
||||
}
|
||||
if a := artifact(t, m, "defaults"); a["kind"] != "archive" || a["from"] != "install/defaults-pref" {
|
||||
t.Fatalf("%v", a)
|
||||
}
|
||||
prefs, err := os.ReadFile(filepath.Join("..", "..", "install", "defaults-pref", "vendor-prefs.js"))
|
||||
if err != nil || !strings.Contains(string(prefs), `pref("spellchecker.dictionary_path", "/usr/share/hunspell");`) {
|
||||
t.Fatalf("the dictionaries are named as a default, in place of a link: %s %v", prefs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
start := resource(t, m, "start")
|
||||
content := start["content"].(string)
|
||||
if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" ||
|
||||
!strings.Contains(content, "\n"+preparation+" prepare ||") || !strings.HasSuffix(content, "\nexec "+startBin+" \"$@\"\n") {
|
||||
t.Fatalf("the start runs the preparation, says when it did not complete, and starts Betterbird either way: %v", start)
|
||||
}
|
||||
launcher := resource(t, m, "launcher")
|
||||
lc := launcher["content"].(string)
|
||||
if launcher["path"] != "/usr/local/share/applications/eu.betterbird.Betterbird.desktop" ||
|
||||
!strings.Contains(lc, "\nExec=/usr/local/bin/betterbird %u\n") || !strings.Contains(lc, "\nIcon="+installDir+"/") ||
|
||||
!strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") {
|
||||
t.Fatalf("the launcher starts the module's start and names the icon in the installation: %v", launcher)
|
||||
}
|
||||
if b := artifact(t, m, "tools"); filepath.Base(preparation) != b["binary"] {
|
||||
t.Fatalf("the preparation runs the tools' binary: %v", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
m, _ := readManifest(t)
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
served[tool.Name] = true
|
||||
@@ -94,17 +274,12 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
for name := range served {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("%v", m.Build.Artifacts)
|
||||
}
|
||||
b := m.Build.Artifacts[0]
|
||||
b := artifact(t, m, "tools")
|
||||
if b["kind"] != "bundle" || b["language"] != "go" || b["from"] != "cmd/betterbird-tools" || b["binary"] != "betterbird-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", b)
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
// betterbird_accounts proposes the setting accounts through the controller's settings verb (ADR 0317 §9).
|
||||
if !reflect.DeepEqual(m.Invokes, []string{"seat:mesh-controller.settings"}) {
|
||||
t.Errorf("the module invokes the controller's settings verb and nothing else: %v", m.Invokes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ package main
|
||||
// module declares. A theme or another preference of the operator's profile can be carried over by
|
||||
// name (carry), within the same limits as betterbird_prefs: never a credential, never an account.
|
||||
//
|
||||
// **It is visibly a test** (the operator, 2026-10-12): 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 profile
|
||||
// carries a userChrome.css that marks its main window with a red band reading MESH TEST, and the event is
|
||||
// called "MESH TEST reminder". The reminder window itself is left as Betterbird draws it, because how it is
|
||||
// drawn is what the test looks at. The tool's description says a window opens on the operator's screen, so
|
||||
// a caller tells the operator first.
|
||||
//
|
||||
// It lives for at most ten minutes (the unit's RuntimeMaxSec), and stop ends it early and removes the
|
||||
// profile. The reminder window is then found with xorg_windows (class Calendar, role AlarmWindow) and
|
||||
// looked at with xorg_screenshot.
|
||||
@@ -41,6 +48,7 @@ type ReminderAnswer struct {
|
||||
Carried []string `json:"carried,omitempty"`
|
||||
Withheld []string `json:"withheld,omitempty"`
|
||||
Look string `json:"look"`
|
||||
OnScreen string `json:"on_screen,omitempty"`
|
||||
Stopped bool `json:"stopped,omitempty"`
|
||||
}
|
||||
|
||||
@@ -50,11 +58,38 @@ func ics(now time.Time, after time.Duration, stamp string) string {
|
||||
start := now.Add(after + 10*time.Minute)
|
||||
return strings.Join([]string{"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//novox mesh//betterbird reminder test//EN",
|
||||
"BEGIN:VEVENT", "UID:mesh-reminder-test-" + stamp + "@mesh", "DTSTAMP:" + f(now), "DTSTART:" + f(start),
|
||||
"DTEND:" + f(start.Add(30*time.Minute)), "SUMMARY:Mesh reminder test", "BEGIN:VALARM", "ACTION:DISPLAY",
|
||||
"DESCRIPTION:Mesh reminder test", "TRIGGER:-PT10M", "END:VALARM", "END:VEVENT", "END:VCALENDAR", ""}, "\r\n")
|
||||
"DTEND:" + f(start.Add(30*time.Minute)), "SUMMARY:" + testTitle, "BEGIN:VALARM", "ACTION:DISPLAY",
|
||||
"DESCRIPTION:" + testTitle, "TRIGGER:-PT10M", "END:VALARM", "END:VEVENT", "END:VCALENDAR", ""}, "\r\n")
|
||||
}
|
||||
|
||||
// testTitle is the test event's title: what the reminder window shows.
|
||||
const testTitle = "MESH TEST reminder (a throw-away Betterbird of the mesh, not your calendar)"
|
||||
|
||||
// testChrome is the test profile's userChrome.css: a red band reading MESH TEST across the top of every
|
||||
// Betterbird window but the reminder (windowtype Calendar:AlarmWindow), and a red frame around it, so the test
|
||||
// Betterbird is never taken for the operator's.
|
||||
const testChrome = `/* The mesh's reminder test (module betterbird, novox/hq issue 345): a throw-away Betterbird, not yours. */
|
||||
:root:not([windowtype="Calendar:AlarmWindow"]) {
|
||||
outline: 6px solid #d00000 !important;
|
||||
outline-offset: -6px !important;
|
||||
}
|
||||
:root:not([windowtype="Calendar:AlarmWindow"]) > body::before {
|
||||
content: "MESH TEST \2014 a throw-away Betterbird started by the mesh for a reminder test; your mail and calendar are not here. It ends by itself within 10 minutes.";
|
||||
display: block !important;
|
||||
background: #d00000 !important;
|
||||
color: #ffffff !important;
|
||||
font: bold 15px sans-serif !important;
|
||||
padding: 6px 12px !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
`
|
||||
|
||||
// testPrefs are the test profile's own preferences: its one calendar, and no first-run questions.
|
||||
//
|
||||
// betterbird.update.checkIntervalDays 0 is Betterbird's own switch for its update check (MailGlue): a
|
||||
// fresh profile has never checked, so Betterbird checks at its start and, when betterbird.eu publishes a
|
||||
// newer build than the installed one, opens the About window on the operator's screen and gives it the
|
||||
// focus (seen on the workstation 2026-10-12, novox/hq issue 345). The test is about the reminder only.
|
||||
func testPrefs(calendar string) string {
|
||||
return strings.Join([]string{
|
||||
`user_pref("calendar.registry.meshtest.type", "ics");`,
|
||||
@@ -68,7 +103,9 @@ func testPrefs(calendar string) string {
|
||||
`user_pref("datareporting.policy.dataSubmissionEnabled", false);`,
|
||||
`user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);`,
|
||||
`user_pref("app.update.auto", false);`,
|
||||
`user_pref("betterbird.update.checkIntervalDays", 0);`,
|
||||
`user_pref("mail.rights.version", 1);`,
|
||||
`user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);`,
|
||||
"",
|
||||
}, "\n")
|
||||
}
|
||||
@@ -125,14 +162,22 @@ func (m *Machine) ReminderTest(after time.Duration, carry []string, stop bool) (
|
||||
if err := os.WriteFile(calendar, []byte(ics(now, after, strconv.FormatInt(now.Unix(), 10))), 0o600); err != nil {
|
||||
return a, err
|
||||
}
|
||||
user := testPrefs(strings.TrimPrefix(calendar, m.Root))
|
||||
// The module's user.js first, then what is carried, then the test's own preferences: the last
|
||||
// statement wins, so neither can take the test's calendar or its quiet start away.
|
||||
user := ""
|
||||
if mine, err := readBounded(m.home(declared)); err == nil && validUserJS(string(mine)) == nil {
|
||||
user = string(mine) + "\n" + user
|
||||
user = string(mine) + "\n"
|
||||
}
|
||||
user += strings.Join(carried, "\n") + "\n"
|
||||
user += strings.Join(carried, "\n") + "\n" + testPrefs(strings.TrimPrefix(calendar, m.Root))
|
||||
if err := os.WriteFile(filepath.Join(dir, "profile", "user.js"), []byte(user), 0o600); err != nil {
|
||||
return a, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "profile", "chrome"), 0o700); err != nil {
|
||||
return a, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "profile", "chrome", "userChrome.css"), []byte(testChrome), 0o600); err != nil {
|
||||
return a, err
|
||||
}
|
||||
// The unit removes the profile when it ends, however it ends: at its 10 minutes, on stop, or when the
|
||||
// test Betterbird is closed.
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + testUnit, "-p", "RuntimeMaxSec=600",
|
||||
@@ -146,7 +191,9 @@ func (m *Machine) ReminderTest(after time.Duration, carry []string, stop bool) (
|
||||
if err := failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...); err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Event = "Mesh reminder test, in a calendar file of the test profile only"
|
||||
a.Event = testTitle + ", in a calendar file of the test profile only"
|
||||
a.OnScreen = "a second Betterbird's window is now on the operator's screen, marked MESH TEST in red; its reminder " +
|
||||
"window follows when the reminder is due. Both go when the test ends (stop, or 10 minutes)"
|
||||
a.Due = now.Add(after).UTC().Format(time.RFC3339)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// No test reaches the network or the mesh: Betterbird's list of published builds and the controller's
|
||||
// settings verb answer from here, and a test that needs them says what they answer.
|
||||
func TestMain(m *testing.M) {
|
||||
publishedBuilds = func() (string, error) { return "", errors.New("no network in tests") }
|
||||
asker = func(string, any) (json.RawMessage, error) { return nil, errors.New("no mesh in tests") }
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Written by the mesh (module betterbird, novox/hq ADR 0316 §3): the system's spelling dictionaries, named
|
||||
// as a default preference in place of the link the community package made.
|
||||
pref("spellchecker.dictionary_path", "/usr/share/hunspell");
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"policies": {
|
||||
"DisableAppUpdate": true,
|
||||
"ExtensionSettings": {
|
||||
"owl@beonex.com": {
|
||||
"installation_mode": "force_installed",
|
||||
"install_url": "file:///opt/betterbird/owl/owl.xpi",
|
||||
"updates_disabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
{
|
||||
"module": "betterbird",
|
||||
"version": "1",
|
||||
"upgrade": {
|
||||
"policy": "record",
|
||||
"why": "a new build may remove the community package betterbird-bin and replaces the installation in /opt/betterbird, which a running Betterbird has open: the switch on each node and every upgrade wait for the operator, who quits Betterbird first and then pushes (novox/hq ADR 0316 §4, §5)"
|
||||
},
|
||||
"requires": [
|
||||
"x11-display"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-controller.settings"
|
||||
],
|
||||
"settings": {
|
||||
"prefs": {
|
||||
"kind": "preference",
|
||||
"default": "// none",
|
||||
"why": "Betterbird runs with its own defaults until a machine's assignment names a preference: one line of user_pref(\"name\", value); statements, which the module's user.js holds and Betterbird reads at its start (novox/hq issue 345)"
|
||||
"why": "Betterbird runs with its own defaults until a machine's assignment names a preference: one line of user_pref(\"name\", value); statements, which the module's user.js holds and Betterbird reads at its start (novox/hq issue 345). An account's preferences (mail.account., mail.identity., mail.server., mail.smtpserver. and the account lists) are refused here: an account is declared in the setting accounts, so it has one owner (novox/hq ADR 0316 §11)"
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
@@ -18,6 +25,7 @@
|
||||
"betterbird_log",
|
||||
"betterbird_restart",
|
||||
"betterbird_check",
|
||||
"betterbird_accounts",
|
||||
"betterbird_reminder_test"
|
||||
],
|
||||
"resources": [
|
||||
@@ -34,7 +42,68 @@
|
||||
"path": "${machine:account-home}/.config/betterbird/user.js",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"content": "// Betterbird's user.js (module betterbird, novox/hq issue 345). Owned by the mesh: the node-engine\n// writes it here at every push, and the module's tools copy it whole into Betterbird's profile\n// (betterbird_user_js with apply, betterbird_restart), where Betterbird reads it at its start and lets it\n// win over the preferences Betterbird saves itself. Change the module's setting prefs, never this file or the profile's copy.\n//\n// Only comments and user_pref(\"name\", value); statements; the tools refuse anything else, and any\n// preference whose name can hold a credential.\n${setting:prefs}\n"
|
||||
"content": "// Betterbird's user.js (module betterbird, novox/hq issue 345). Owned by the mesh: the node-engine\n// writes it here at every push, and the module's tools copy it whole into Betterbird's profile\n// (betterbird_user_js with apply, betterbird_restart), where Betterbird reads it at its start and lets it\n// win over the preferences Betterbird saves itself. Change the module's setting prefs, never this file or the profile's copy.\n//\n// Only comments and user_pref(\"name\", value); statements; the tools refuse anything else, and any\n// preference whose name can hold a credential.\n${setting:prefs}\n",
|
||||
"trusted": true
|
||||
},
|
||||
{
|
||||
"id": "accounts",
|
||||
"type": "file",
|
||||
"path": "${machine:account-home}/.config/betterbird/accounts.json",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0600",
|
||||
"merge": "json",
|
||||
"content": "{\n \"accounts\": {}\n}\n",
|
||||
"trusted": true
|
||||
},
|
||||
{
|
||||
"id": "community-package",
|
||||
"type": "package",
|
||||
"package": "betterbird-bin",
|
||||
"absent": true
|
||||
},
|
||||
{
|
||||
"id": "installation",
|
||||
"type": "archive",
|
||||
"path": "/opt/betterbird",
|
||||
"artifact": "archive"
|
||||
},
|
||||
{
|
||||
"id": "owl",
|
||||
"type": "archive",
|
||||
"path": "/opt/betterbird/owl",
|
||||
"artifact": "owl"
|
||||
},
|
||||
{
|
||||
"id": "policies",
|
||||
"type": "archive",
|
||||
"path": "/opt/betterbird/distribution",
|
||||
"artifact": "distribution"
|
||||
},
|
||||
{
|
||||
"id": "spelling",
|
||||
"type": "archive",
|
||||
"path": "/opt/betterbird/defaults/pref",
|
||||
"artifact": "defaults"
|
||||
},
|
||||
{
|
||||
"id": "preparation",
|
||||
"type": "archive",
|
||||
"path": "/usr/local/lib/mesh-betterbird",
|
||||
"artifact": "tools"
|
||||
},
|
||||
{
|
||||
"id": "start",
|
||||
"type": "file",
|
||||
"path": "/usr/local/bin/betterbird",
|
||||
"mode": "0755",
|
||||
"content": "#!/bin/sh\n# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.\n#\n# Before Betterbird starts, the module's tools place its user.js in the profile (the setting prefs, and the\n# accounts the setting accounts declares) and add the declared accounts to the profile's account list. They do\n# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a\n# second start while one runs only hands over to it. What the preparation could not do it says here, and\n# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.\n/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo \"betterbird: the mesh's preparation did not complete; betterbird_check says why\" >&2\nexec /opt/betterbird/betterbird \"$@\"\n"
|
||||
},
|
||||
{
|
||||
"id": "launcher",
|
||||
"type": "file",
|
||||
"path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop",
|
||||
"mode": "0644",
|
||||
"content": "[Desktop Entry]\nVersion=1.0\nType=Application\nName=Betterbird\nGenericName=Mail Client\nComment=Send and receive mail with Betterbird\nExec=/usr/local/bin/betterbird %u\nIcon=/opt/betterbird/chrome/icons/default/default128.png\nTerminal=false\nCategories=Network;Email;\nMimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid;\nStartupNotify=true\nStartupWMClass=eu.betterbird.Betterbird\nKeywords=Email;E-mail;Calendar;Thunderbird;\nActions=ComposeMessage;OpenAddressBook;\n# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:\n# it starts Betterbird through the module's start, and names the icon in the installation, with no link.\n\n[Desktop Action ComposeMessage]\nName=Write new message\nExec=/usr/local/bin/betterbird -compose\n\n[Desktop Action OpenAddressBook]\nName=Open address book\nExec=/usr/local/bin/betterbird -addressbook\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -49,6 +118,38 @@
|
||||
"loads": [
|
||||
"betterbird-tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "archive",
|
||||
"kind": "archive",
|
||||
"url": "https://www.betterbird.eu/downloads/LinuxArchive/betterbird-153.4.0esr-bb10.en-US.linux-x86_64.tar.xz",
|
||||
"sha256": "38dd24890a66b4cf9a45bc24e15192e00c26b929e1bb8df7974c16a4539129e2",
|
||||
"version": "153.4.0esr-bb10",
|
||||
"within": "betterbird",
|
||||
"names-on-purpose": {
|
||||
"www.betterbird.eu": "Betterbird's project publishes its Linux release there; the build seat fetches it once, by its pinned sha256 (novox/hq ADR 0316 §2)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "owl",
|
||||
"kind": "archive",
|
||||
"url": "https://www.beonex.com/owl/owl.xpi",
|
||||
"sha256": "a54cc3d52113b34f97942ee19190947dee23561e2217f18db3d80ac023991221",
|
||||
"version": "1.5.6.1",
|
||||
"file": "owl.xpi",
|
||||
"names-on-purpose": {
|
||||
"www.beonex.com": "Owl's vendor publishes its xpi there; the build seat fetches it once, by its pinned sha256 (novox/hq ADR 0316 §6)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "distribution",
|
||||
"kind": "archive",
|
||||
"from": "install/distribution"
|
||||
},
|
||||
{
|
||||
"name": "defaults",
|
||||
"kind": "archive",
|
||||
"from": "install/defaults-pref"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user