473 lines
18 KiB
Go
473 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; trusted too since issue 751,
|
||
// so a change to it is the operator's to approve.
|
||
//
|
||
// 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
|
||
}
|