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_ 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_ 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) { // An identity the module created for a declared account sends as that account does (ADR 0329 §7). if seen[id] || ours[id] || ownIdentity.MatchString(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 }