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 }