mesh/merge-gate head c9a711c259ed merged into trunk 827b89dbf109 — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head c9a711c259ed merged into trunk 827b89dbf109 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
writeAccountLists checked only for a running Betterbird and wrote without a sync; it now waits for the profile's lock too and writes atomically. readLayer read any answer that was not JSON as an empty layer, and a proposal replaces the layer whole, so a changed wording would have dropped accounts; it now refuses. The README says the rollout order that leaves no node on Betterbird's defaults.
843 lines
31 KiB
Go
843 lines
31 KiB
Go
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) {
|
|
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
|
|
}
|
|
// Only while Betterbird is closed: no main process and no lock on the profile (novox/hq ADR 0316 §12, ADR 0329 §9).
|
|
if why := m.closedFor(profile); why != "" {
|
|
return "", errors.New(why + ": the account list is written at its next start from the launcher, or by betterbird_restart")
|
|
}
|
|
mode := os.FileMode(0o600)
|
|
if st, err := os.Stat(path); err == nil {
|
|
mode = st.Mode().Perm()
|
|
}
|
|
if err := writeAtomic(path, []byte(out), mode); err != nil {
|
|
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: the profile's accounts with the operator's own
|
|
// values (novox/hq ADR 0329 §1, §12), Owl's marked and by name and address only (§13), and counts and kinds.
|
|
// Never a password, a token or anything of the login store (§2).
|
|
type AccountsAnswer struct {
|
|
Profile string `json:"profile"`
|
|
// Accounts are the profile's accounts, in its list's order.
|
|
Accounts []listedAccount `json:"accounts"`
|
|
// Derived is the value of the setting accounts the propose built, as it was proposed (ADR 0329 §10).
|
|
Derived map[string]Account `json:"derived,omitempty"`
|
|
// 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"`
|
|
}
|
|
|
|
// listedAccount is one account of the profile as betterbird_accounts lists it.
|
|
type listedAccount struct {
|
|
Key string `json:"key"`
|
|
Kind string `json:"kind"`
|
|
// DeclaredAs is the name the setting accounts declares it under, or empty: the person's own.
|
|
DeclaredAs string `json:"declared_as,omitempty"`
|
|
Owl bool `json:"owl,omitempty"`
|
|
// Name is what Betterbird shows it as, and Address its default identity's.
|
|
Name string `json:"name,omitempty"`
|
|
Address string `json:"address,omitempty"`
|
|
Value *Account `json:"value,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
// 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, Accounts: []listedAccount{}, Declarable: map[string]int{}, NotDeclarable: map[string]int{}}
|
|
value := map[string]Account{}
|
|
used := map[string]bool{}
|
|
declaredAs := map[string]string{}
|
|
if accounts, _, err := m.readAccounts(); err == nil {
|
|
for _, r := range resolveAccounts(accounts, prefs, m.readRecord(p.Dir)) {
|
|
if r.How != refused {
|
|
declaredAs[r.Keys.Account] = r.name
|
|
}
|
|
}
|
|
}
|
|
for _, pa := range profileAccounts(prefs) {
|
|
listed := listedAccount{Key: pa.Key, Kind: pa.Kind, DeclaredAs: declaredAs[pa.Key], Owl: pa.Owl,
|
|
Name: unquote(prefs["mail.server."+pa.Server+".name"])}
|
|
if len(pa.Addresses) > 0 {
|
|
listed.Address = pa.Addresses[0]
|
|
}
|
|
switch {
|
|
case pa.Owl:
|
|
a.KeptByOwl++
|
|
listed.Note = "Owl's: never declared (novox/hq ADR 0316 §13)"
|
|
a.Accounts = append(a.Accounts, listed)
|
|
continue
|
|
case pa.Kind != "imap" && pa.Kind != "pop3":
|
|
kind := pa.Kind
|
|
if kind == "none" {
|
|
kind = "local folders"
|
|
}
|
|
a.NotDeclarable[kind]++
|
|
listed.Note = "not an account a declaration holds"
|
|
a.Accounts = append(a.Accounts, listed)
|
|
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)
|
|
if declaredAs[pa.Key] != "" {
|
|
name = declaredAs[pa.Key]
|
|
}
|
|
used[name] = true
|
|
acc := readAccount(prefs, pa)
|
|
value[name] = acc
|
|
listed.Value = &acc
|
|
a.Accounts = append(a.Accounts, listed)
|
|
}
|
|
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 = "the profile's accounts with their values, never a password or token (novox/hq ADR 0329 §1, §2). With " +
|
|
"propose, the setting accounts is built from them (never Owl's) and proposed to the operator in the mesh-wide " +
|
|
"layer as it stands, who approves it on a channel that proves who answers (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, "; "))
|
|
}
|
|
// A layer is replaced whole: the proposal carries the mesh-wide layer as it stands, with this value of accounts.
|
|
layer, err := readLayer("")
|
|
if err != nil {
|
|
return a, fmt.Errorf("nothing was proposed: %v", err)
|
|
}
|
|
layer["accounts"] = value
|
|
a.Derived = value
|
|
said, err := proposeLayer("", layer, false)
|
|
a.Proposed = m.maskValue(said)
|
|
return a, err
|
|
}
|
|
|
|
// 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
|
|
}
|