Files
mesh-catalog/modules/betterbird/cmd/betterbird-tools/accounts.go
T
jochen c9a711c259
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
betterbird: write the account lists only while the profile is closed, and never propose over an unreadable layer (hq issue 586, review 121)
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.
2026-10-13 02:12:06 +02:00

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
}