mesh/merge-gate head 1835f0efad0f merged into trunk 39e7a06e6293 — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head 1835f0efad0f merged into trunk 39e7a06e6293 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
The module owned only the accounts' server fields, so identities, signatures, calendars, address books, filters and tags could differ between desktop nodes unseen, and the operator's own preferences were shipped in the catalogue. prefs becomes one entry per preference (a node's layer overrides one key) and the defaults file keeps no value of the operator's; identities, calendars, address-books, filters and tags become settings, adopted like accounts, filters owned whole per account, and what Betterbird rewrites written only while it is closed. New tools list each kind, derive the layers from every desktop node and propose one at a time, and show drift; every answer passes a credential check.
472 lines
18 KiB
Go
472 lines
18 KiB
Go
package main
|
||
|
||
// Betterbird's whole configuration, beside the accounts (novox/hq ADR 0329 §4–§6, issue 586).
|
||
//
|
||
// Five settings, each mesh-wide with a node's layer over it, reach the machine as two files the node-engine
|
||
// merges from the module's layers key by key:
|
||
//
|
||
// - ~/.config/betterbird/configuration.json: identities, calendars, address-books and filters, trusted as
|
||
// accounts is (§5), because each names where a password, a reply or forwarded mail goes;
|
||
// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail, and is not trusted.
|
||
//
|
||
// An entry is keyed by a name of the operator's (lower-case letters and digits, as an account's), a tag by its
|
||
// own key. A node's layer may set an entry to null to take it out on that node, and may give an identity, a
|
||
// calendar or an address book its `key`: the profile's key to adopt there (§4, §7). No value holds a password,
|
||
// a token or a cookie: a key shaped like one is refused whole, and so is an address whose user part carries a
|
||
// password (§2). A setting holds no line break (novox/hq issue 339), so a signature is a list of its lines.
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/url"
|
||
"os"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
const (
|
||
// configurationFile holds identities, calendars, address-books and filters; tagsFile holds tags.
|
||
configurationFile = ".config/betterbird/configuration.json"
|
||
tagsFile = ".config/betterbird/tags.json"
|
||
)
|
||
|
||
// The five kinds, in the order the tools answer them.
|
||
const (
|
||
kindIdentities = "identities"
|
||
kindCalendars = "calendars"
|
||
kindAddressBooks = "address-books"
|
||
kindFilters = "filters"
|
||
kindTags = "tags"
|
||
)
|
||
|
||
var allKinds = []string{kindIdentities, kindCalendars, kindAddressBooks, kindFilters, kindTags}
|
||
|
||
// Identity is one identity of a declared account. The account's default identity is the entry that gives no
|
||
// address: its name and address are the ones the setting accounts gives (§4). Any other entry is another
|
||
// identity of that account, matched by its address.
|
||
type Identity struct {
|
||
Account string `json:"account"`
|
||
Address string `json:"address,omitempty"`
|
||
Name string `json:"name,omitempty"`
|
||
ReplyTo string `json:"reply-to,omitempty"`
|
||
Organization string `json:"organization,omitempty"`
|
||
// Signature is the signature's text, a line to an element.
|
||
Signature []string `json:"signature,omitempty"`
|
||
SignatureHTML bool `json:"signature-html,omitempty"`
|
||
// SignatureAboveQuote puts the reply and its signature above the quoted message (Betterbird's "start my
|
||
// reply above the quote", signature below the reply); false is below the quote.
|
||
SignatureAboveQuote bool `json:"signature-above-quote,omitempty"`
|
||
Key string `json:"key,omitempty"`
|
||
}
|
||
|
||
// Calendar is one CalDAV calendar.
|
||
type Calendar struct {
|
||
Name string `json:"name"`
|
||
URL string `json:"url"`
|
||
Color string `json:"color,omitempty"`
|
||
ReadOnly bool `json:"read-only,omitempty"`
|
||
// Reminders absent is on.
|
||
Reminders *bool `json:"reminders,omitempty"`
|
||
User string `json:"user,omitempty"`
|
||
// Invitations is the identities entry whose address answers invitations; absent, none.
|
||
Invitations string `json:"invitations,omitempty"`
|
||
Key string `json:"key,omitempty"`
|
||
}
|
||
|
||
// AddressBook is one CardDAV address book.
|
||
type AddressBook struct {
|
||
Name string `json:"name"`
|
||
URL string `json:"url"`
|
||
User string `json:"user,omitempty"`
|
||
Key string `json:"key,omitempty"`
|
||
}
|
||
|
||
// Tag is one tag, keyed in the setting by its own key.
|
||
type Tag struct {
|
||
Name string `json:"name"`
|
||
Color string `json:"color,omitempty"`
|
||
// Order is its place among the tags, from 1.
|
||
Order int `json:"order,omitempty"`
|
||
}
|
||
|
||
// Filter is one message filter of an account, in its place in the account's list.
|
||
type Filter struct {
|
||
Name string `json:"name"`
|
||
Enabled bool `json:"enabled"`
|
||
// When it runs: new-mail, manual, after-junk, sending, archiving, periodic.
|
||
When []string `json:"when"`
|
||
// Match is all (every condition holds), any (one does) or every-message (no condition).
|
||
Match string `json:"match"`
|
||
Conditions []Condition `json:"conditions,omitempty"`
|
||
Actions []Action `json:"actions"`
|
||
}
|
||
|
||
// Condition is one of a filter's conditions, in Betterbird's own words: a field, an operator, a value.
|
||
type Condition struct {
|
||
Field string `json:"field"`
|
||
Op string `json:"op"`
|
||
Value string `json:"value,omitempty"`
|
||
}
|
||
|
||
// Action is one of a filter's actions, in Betterbird's own words. A folder is named by its path within the
|
||
// account (INBOX/Receipts), and becomes that folder's address on each node (§8).
|
||
type Action struct {
|
||
Action string `json:"action"`
|
||
Folder string `json:"folder,omitempty"`
|
||
Value string `json:"value,omitempty"`
|
||
}
|
||
|
||
// Configuration is the five settings as the machine has them.
|
||
type Configuration struct {
|
||
Identities map[string]*Identity `json:"identities,omitempty"`
|
||
Calendars map[string]*Calendar `json:"calendars,omitempty"`
|
||
AddressBooks map[string]*AddressBook `json:"address-books,omitempty"`
|
||
Filters map[string][]Filter `json:"filters,omitempty"`
|
||
Tags map[string]*Tag `json:"tags,omitempty"`
|
||
}
|
||
|
||
var (
|
||
entryName = regexp.MustCompile(`^[a-z][a-z0-9]{0,30}$`)
|
||
tagKey = regexp.MustCompile(`^[$A-Za-z0-9_.\-]{1,64}$`)
|
||
colorWord = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
|
||
// secretName is a field's name that says it holds a credential: such a field is withheld from every answer (§2).
|
||
secretName = regexp.MustCompile(`(?i)(password|passwd|token|secret|cookie)`)
|
||
// userPassword is an address whose user part carries a password: scheme://user:password@host.
|
||
userPassword = regexp.MustCompile(`(?i)\b([a-z][a-z0-9+.\-]*://[^/:@\s]*):[^@/\s]+@`)
|
||
)
|
||
|
||
// The words a filter is written in.
|
||
var (
|
||
filterWhen = map[string]int{"new-mail": 0x1, "manual": 0x10, "after-junk": 0x20, "sending": 0x40,
|
||
"archiving": 0x80, "periodic": 0x100}
|
||
filterFields = map[string]bool{"subject": true, "from": true, "to": true, "cc": true, "to or cc": true,
|
||
"all addresses": true, "body": true, "date": true, "priority": true, "status": true, "age in days": true,
|
||
"size": true, "tag": true, "junk status": true, "junk percent": true, "junk score origin": true,
|
||
"has attachment status": true, "date received": true}
|
||
filterOps = map[string]bool{"contains": true, "doesn't contain": true, "is": true, "isn't": true,
|
||
"begins with": true, "ends with": true, "is empty": true, "isn't empty": true, "is before": true,
|
||
"is after": true, "is greater than": true, "is less than": true, "is higher than": true,
|
||
"is lower than": true, "is in ab": true, "isn't in ab": true, "matches": true, "doesn't match": true}
|
||
// filterActions are the actions a declared filter may take, and whether each names a folder or a value.
|
||
filterActions = map[string]string{"Move to folder": "folder", "Copy to folder": "folder",
|
||
"Change priority": "value", "Delete": "", "Mark read": "", "Mark unread": "", "Mark flagged": "",
|
||
"Ignore thread": "", "Ignore subthread": "", "Watch thread": "", "AddTag": "value", "JunkScore": "value",
|
||
"Forward": "value", "Stop execution": "", "Delete from Pop3 server": "", "Leave on Pop3 server": "",
|
||
"Fetch body from Pop3Server": ""}
|
||
)
|
||
|
||
// readConfiguration reads the five settings. A missing file holds none of its kinds (the module is older
|
||
// here); a file that carries a key shaped like a credential, an address with a password, an unknown field or a
|
||
// value Betterbird cannot be given is refused whole, naming the entry and never a value.
|
||
func (m *Machine) readConfiguration() (Configuration, error) {
|
||
var c Configuration
|
||
var problems []string
|
||
for _, file := range []string{configurationFile, tagsFile} {
|
||
raw, err := readBounded(m.home(file))
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
continue
|
||
}
|
||
if err != nil {
|
||
return Configuration{}, err
|
||
}
|
||
if err := decodeInto(raw, file, &c); err != nil {
|
||
problems = append(problems, err.Error())
|
||
}
|
||
}
|
||
if len(problems) > 0 {
|
||
return Configuration{}, errors.New(strings.Join(problems, "; "))
|
||
}
|
||
if p := configurationProblems(c); len(p) > 0 {
|
||
return Configuration{}, fmt.Errorf("the configuration is refused: %s", strings.Join(p, "; "))
|
||
}
|
||
c.dropNulls()
|
||
for _, cal := range c.Calendars {
|
||
cal.Color = strings.ToLower(cal.Color)
|
||
}
|
||
for _, t := range c.Tags {
|
||
t.Color = strings.ToUpper(t.Color)
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
// decodeInto reads one settings file into c, refusing a credential-shaped key or an unknown field.
|
||
func decodeInto(raw []byte, file string, c *Configuration) error {
|
||
var tree any
|
||
if err := json.Unmarshal(raw, &tree); err != nil {
|
||
return fmt.Errorf("~/%s is not JSON: %v", file, err)
|
||
}
|
||
if where := secretKeyIn(tree, ""); where != "" {
|
||
return fmt.Errorf("~/%s carries %s, a key shaped like a credential: the configuration is declared without "+
|
||
"any password, token or cookie (novox/hq ADR 0329 §2); take it out at the controller's terminal", file, where)
|
||
}
|
||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||
dec.DisallowUnknownFields()
|
||
var into Configuration
|
||
if err := dec.Decode(&into); err != nil {
|
||
return fmt.Errorf("~/%s does not hold the shape of the settings it takes: %v", file, err)
|
||
}
|
||
if file == tagsFile {
|
||
if into.Identities != nil || into.Calendars != nil || into.AddressBooks != nil || into.Filters != nil {
|
||
return fmt.Errorf("~/%s holds only tags", file)
|
||
}
|
||
c.Tags = into.Tags
|
||
return nil
|
||
}
|
||
if into.Tags != nil {
|
||
return fmt.Errorf("~/%s holds no tags: tags are their own setting", file)
|
||
}
|
||
c.Identities, c.Calendars, c.AddressBooks, c.Filters = into.Identities, into.Calendars, into.AddressBooks, into.Filters
|
||
return nil
|
||
}
|
||
|
||
// dropNulls takes out the entries a node's layer set to null: not on this node.
|
||
func (c *Configuration) dropNulls() {
|
||
for k, v := range c.Identities {
|
||
if v == nil {
|
||
delete(c.Identities, k)
|
||
}
|
||
}
|
||
for k, v := range c.Calendars {
|
||
if v == nil {
|
||
delete(c.Calendars, k)
|
||
}
|
||
}
|
||
for k, v := range c.AddressBooks {
|
||
if v == nil {
|
||
delete(c.AddressBooks, k)
|
||
}
|
||
}
|
||
for k, v := range c.Filters {
|
||
if v == nil {
|
||
delete(c.Filters, k)
|
||
}
|
||
}
|
||
for k, v := range c.Tags {
|
||
if v == nil {
|
||
delete(c.Tags, k)
|
||
}
|
||
}
|
||
}
|
||
|
||
// addressPassword says whether an address carries a password in its user part.
|
||
func addressPassword(s string) bool {
|
||
if userPassword.MatchString(s) {
|
||
return true
|
||
}
|
||
if u, err := url.Parse(s); err == nil && u.User != nil {
|
||
_, has := u.User.Password()
|
||
return has
|
||
}
|
||
return false
|
||
}
|
||
|
||
// withoutPassword is an address with the password of its user part as ‹withheld› (§2).
|
||
func withoutPassword(s string) string {
|
||
return userPassword.ReplaceAllString(s, "${1}:‹withheld›@")
|
||
}
|
||
|
||
// davURL says what is wrong with a calendar's or an address book's address, or "".
|
||
func davURL(s string) string {
|
||
if addressPassword(s) {
|
||
return "its address carries a password in its user part, which no setting holds (novox/hq ADR 0329 §2): " +
|
||
"Betterbird asks for the password at the first sign-in"
|
||
}
|
||
u, err := url.Parse(s)
|
||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||
return "its address is an https:// (or http://) address of the server"
|
||
}
|
||
if strings.ContainsAny(s, lineEnds+`"\`) {
|
||
return "its address holds a quote, a backslash or a line break"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// configurationProblems is everything wrong with the five settings, each said by its kind and entry name.
|
||
func configurationProblems(c Configuration) []string {
|
||
var out []string
|
||
say := func(kind, name, format string, args ...any) {
|
||
out = append(out, fmt.Sprintf("%s %s: ", kind, name)+fmt.Sprintf(format, args...))
|
||
}
|
||
text := func(kind, name, field, v string) {
|
||
if !plainText.MatchString(v) {
|
||
say(kind, name, "%s holds a quote, a backslash or a line break", field)
|
||
}
|
||
}
|
||
for _, name := range sortedKeys(c.Identities) {
|
||
id := c.Identities[name]
|
||
if id == nil {
|
||
continue
|
||
}
|
||
if !entryName.MatchString(name) {
|
||
say(kindIdentities, name, "a name is lower-case letters and digits, starting with a letter")
|
||
}
|
||
if !accountName.MatchString(id.Account) {
|
||
say(kindIdentities, name, "account names an entry of the setting accounts")
|
||
}
|
||
if id.Address == "" && id.Name != "" {
|
||
say(kindIdentities, name, "the account's default identity (no address) takes its name from the setting accounts")
|
||
}
|
||
if id.Address != "" && !addressShape.MatchString(id.Address) {
|
||
say(kindIdentities, name, "the address is not a mail address")
|
||
}
|
||
if id.ReplyTo != "" && !addressShape.MatchString(id.ReplyTo) {
|
||
say(kindIdentities, name, "reply-to is not a mail address")
|
||
}
|
||
for field, v := range map[string]string{"name": id.Name, "organization": id.Organization} {
|
||
text(kindIdentities, name, field, v)
|
||
}
|
||
for _, line := range id.Signature {
|
||
if strings.ContainsAny(line, lineEnds) {
|
||
say(kindIdentities, name, "a signature's line holds a line break: each line is its own element")
|
||
break
|
||
}
|
||
}
|
||
if id.Key != "" && !profileKey.MatchString(id.Key) {
|
||
say(kindIdentities, name, "key is an identity key of the profile, such as id3")
|
||
}
|
||
}
|
||
for _, name := range sortedKeys(c.Calendars) {
|
||
cal := c.Calendars[name]
|
||
if cal == nil {
|
||
continue
|
||
}
|
||
if !entryName.MatchString(name) {
|
||
say(kindCalendars, name, "a name is lower-case letters and digits, starting with a letter")
|
||
}
|
||
if why := davURL(cal.URL); why != "" {
|
||
say(kindCalendars, name, "%s", why)
|
||
}
|
||
if cal.Color != "" && !colorWord.MatchString(cal.Color) {
|
||
say(kindCalendars, name, "a colour is #rrggbb")
|
||
}
|
||
if cal.Invitations != "" && c.Identities[cal.Invitations] == nil {
|
||
say(kindCalendars, name, "invitations names %s, which the setting identities does not hold", cal.Invitations)
|
||
}
|
||
text(kindCalendars, name, "name", cal.Name)
|
||
text(kindCalendars, name, "user", cal.User)
|
||
if cal.Key != "" && !tagKey.MatchString(cal.Key) {
|
||
say(kindCalendars, name, "key is a calendar key of the profile")
|
||
}
|
||
}
|
||
for _, name := range sortedKeys(c.AddressBooks) {
|
||
ab := c.AddressBooks[name]
|
||
if ab == nil {
|
||
continue
|
||
}
|
||
if !entryName.MatchString(name) {
|
||
say(kindAddressBooks, name, "a name is lower-case letters and digits, starting with a letter")
|
||
}
|
||
if why := davURL(ab.URL); why != "" {
|
||
say(kindAddressBooks, name, "%s", why)
|
||
}
|
||
text(kindAddressBooks, name, "name", ab.Name)
|
||
text(kindAddressBooks, name, "user", ab.User)
|
||
if ab.Key != "" && !profileKey.MatchString(ab.Key) {
|
||
say(kindAddressBooks, name, "key is an address book key of the profile")
|
||
}
|
||
}
|
||
for _, key := range sortedKeys(c.Tags) {
|
||
t := c.Tags[key]
|
||
if t == nil {
|
||
continue
|
||
}
|
||
if !tagKey.MatchString(key) {
|
||
say(kindTags, key, "a tag's key is letters, digits, $, _, . or -")
|
||
}
|
||
if t.Color != "" && !colorWord.MatchString(t.Color) {
|
||
say(kindTags, key, "a colour is #rrggbb")
|
||
}
|
||
text(kindTags, key, "name", t.Name)
|
||
}
|
||
for _, account := range sortedKeys(c.Filters) {
|
||
if !accountName.MatchString(account) {
|
||
say(kindFilters, account, "filters are keyed by an account's name in the setting accounts")
|
||
}
|
||
for i, f := range c.Filters[account] {
|
||
for _, p := range filterProblems(f) {
|
||
say(kindFilters, account, "filter %d: %s", i+1, p)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// filterValue is what a filter's name, value or folder may hold: what its file can carry without quoting.
|
||
var filterValue = regexp.MustCompile(`^[^"\\()\n\r]*$`)
|
||
|
||
func filterProblems(f Filter) []string {
|
||
var out []string
|
||
if f.Name == "" || !filterValue.MatchString(f.Name) {
|
||
out = append(out, "a name, without a quote, a backslash or a parenthesis")
|
||
}
|
||
if len(f.When) == 0 {
|
||
out = append(out, "when says when it runs: new-mail, manual, after-junk, sending, archiving or periodic")
|
||
}
|
||
for _, w := range f.When {
|
||
if _, ok := filterWhen[w]; !ok {
|
||
out = append(out, fmt.Sprintf("when %q is new-mail, manual, after-junk, sending, archiving or periodic", w))
|
||
}
|
||
}
|
||
switch f.Match {
|
||
case "all", "any":
|
||
if len(f.Conditions) == 0 {
|
||
out = append(out, "match all or any needs a condition")
|
||
}
|
||
case "every-message":
|
||
if len(f.Conditions) > 0 {
|
||
out = append(out, "match every-message takes no condition")
|
||
}
|
||
default:
|
||
out = append(out, "match is all, any or every-message")
|
||
}
|
||
for i, c := range f.Conditions {
|
||
if !filterFields[c.Field] && !(strings.HasPrefix(c.Field, `"`) && strings.HasSuffix(c.Field, `"`) && len(c.Field) > 2) {
|
||
out = append(out, fmt.Sprintf("condition %d: the field %q is not one Betterbird filters on", i+1, c.Field))
|
||
}
|
||
if !filterOps[c.Op] {
|
||
out = append(out, fmt.Sprintf("condition %d: the operator %q is not one of Betterbird's", i+1, c.Op))
|
||
}
|
||
if !filterValue.MatchString(c.Value) {
|
||
out = append(out, fmt.Sprintf("condition %d: the value holds a quote, a backslash, a parenthesis or a line break", i+1))
|
||
}
|
||
}
|
||
if len(f.Actions) == 0 {
|
||
out = append(out, "a filter takes an action")
|
||
}
|
||
for i, a := range f.Actions {
|
||
takes, ok := filterActions[a.Action]
|
||
switch {
|
||
case !ok:
|
||
out = append(out, fmt.Sprintf("action %d: %q is not an action a declared filter takes", i+1, a.Action))
|
||
case takes == "folder" && (a.Folder == "" || a.Value != ""):
|
||
out = append(out, fmt.Sprintf("action %d: %s names a folder, by its path within the account", i+1, a.Action))
|
||
case takes == "value" && (a.Value == "" || a.Folder != ""):
|
||
out = append(out, fmt.Sprintf("action %d: %s takes a value", i+1, a.Action))
|
||
case takes == "" && (a.Value != "" || a.Folder != ""):
|
||
out = append(out, fmt.Sprintf("action %d: %s takes no value", i+1, a.Action))
|
||
}
|
||
if !filterValue.MatchString(a.Value) || !filterValue.MatchString(a.Folder) || strings.HasPrefix(a.Folder, "/") ||
|
||
strings.Contains(a.Folder, "://") {
|
||
out = append(out, fmt.Sprintf("action %d: a folder is a path within the account, and a value holds no quote, "+
|
||
"backslash, parenthesis or line break", i+1))
|
||
}
|
||
if a.Action == "Forward" && !addressShape.MatchString(a.Value) {
|
||
out = append(out, fmt.Sprintf("action %d: Forward names a mail address", i+1))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// sortedFilterAccounts are the accounts filters are declared for.
|
||
func (c Configuration) sortedFilterAccounts() []string {
|
||
out := make([]string, 0, len(c.Filters))
|
||
for a := range c.Filters {
|
||
out = append(out, a)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|