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_ (account_mesh_, id_mesh_, server_mesh_, smtp_mesh_); // - 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_. 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.." + 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_)") } 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 `) 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 }