mesh/merge-gate head 1835f0efad0f merged into trunk 39e7a06e6293 — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head 1835f0efad0f merged into trunk 39e7a06e6293 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
The module owned only the accounts' server fields, so identities, signatures, calendars, address books, filters and tags could differ between desktop nodes unseen, and the operator's own preferences were shipped in the catalogue. prefs becomes one entry per preference (a node's layer overrides one key) and the defaults file keeps no value of the operator's; identities, calendars, address-books, filters and tags become settings, adopted like accounts, filters owned whole per account, and what Betterbird rewrites written only while it is closed. New tools list each kind, derive the layers from every desktop node and propose one at a time, and show drift; every answer passes a credential check.
750 lines
26 KiB
Go
750 lines
26 KiB
Go
package main
|
|
|
|
// How the five settings of ADR 0329 reach the profile (§7–§9).
|
|
//
|
|
// Identities, calendars, address books and tags are matched as accounts are (ADR 0317): an entry the module
|
|
// recorded on this node is kept by its recorded key; else an entry under the module's own key is its creation;
|
|
// else a key a node's layer names is adopted when the profile holds it and it matches; else the profile's
|
|
// entries that match (an identity by its address within its account, a calendar and an address book by their
|
|
// address, a tag by its key): one is adopted, none means the entry is created under the module's own key, more
|
|
// than one is refused with the remedy. Their declared fields are written into the user.js the module places,
|
|
// where they win at every start. An entry leaving the setting releases what was adopted and removes the
|
|
// preferences of what was created; never a calendar's events, an address book's contacts or a cache.
|
|
//
|
|
// What Betterbird rewrites itself is written only while it does not run (§9): the lists in prefs.js (an adopted
|
|
// account's identities, the calendars' order) and each account's msgFilterRules.dat. Until then the module's
|
|
// check counts, per kind, the changes that wait.
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// configRecordFile is where the module records, per profile, what it adopted or created of the five kinds and
|
|
// which filter files it wrote (ADR 0329 §7, §8, ADR 0317 §3).
|
|
const configRecordFile = ".config/betterbird/configuration-record.json"
|
|
|
|
// resolvedEntry is one declared entry as it resolved on this node.
|
|
type resolvedEntry struct {
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
How string `json:"how"`
|
|
Key string `json:"key,omitempty"`
|
|
Why string `json:"why,omitempty"`
|
|
}
|
|
|
|
type recordedEntry struct {
|
|
How string `json:"how"`
|
|
Key string `json:"key"`
|
|
}
|
|
|
|
// filterRecord is what the module did with one account's filter file on a profile.
|
|
type filterRecord struct {
|
|
// File is the account's filter file as the module last wrote it, and Kept the file it replaced at its first write.
|
|
File string `json:"file"`
|
|
Kept string `json:"kept,omitempty"`
|
|
// Released says the account left the setting filters: the module no longer writes its file (§8).
|
|
Released bool `json:"released,omitempty"`
|
|
}
|
|
|
|
type configRecord struct {
|
|
Entries map[string]map[string]recordedEntry `json:"entries,omitempty"`
|
|
Filters map[string]filterRecord `json:"filters,omitempty"`
|
|
}
|
|
|
|
func (m *Machine) readConfigRecord(profileDir string) configRecord {
|
|
all := map[string]configRecord{}
|
|
if raw, err := readBounded(m.home(configRecordFile)); err == nil {
|
|
_ = json.Unmarshal(raw, &all)
|
|
}
|
|
r := all[profileDir]
|
|
if r.Entries == nil {
|
|
r.Entries = map[string]map[string]recordedEntry{}
|
|
}
|
|
if r.Filters == nil {
|
|
r.Filters = map[string]filterRecord{}
|
|
}
|
|
return r
|
|
}
|
|
|
|
func (m *Machine) writeConfigRecord(profileDir string, r configRecord) error {
|
|
all := map[string]configRecord{}
|
|
if raw, err := readBounded(m.home(configRecordFile)); err == nil {
|
|
_ = json.Unmarshal(raw, &all)
|
|
}
|
|
all[profileDir] = r
|
|
body, err := json.MarshalIndent(all, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeAtomic(m.home(configRecordFile), append(body, '\n'), 0o600)
|
|
}
|
|
|
|
// writeAtomic writes a file beside itself and renames it into place.
|
|
func writeAtomic(path string, data []byte, mode os.FileMode) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".mesh-new"
|
|
if err := writeSynced(tmp, data, mode); err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// The module's own keys for what it creates: each names the entry.
|
|
func ownIdentityKey(name string) string { return "ident_mesh_" + name }
|
|
func ownCalendarKey(name string) string { return "mesh_" + name }
|
|
func ownBookKey(name string) string { return "mesh_" + name }
|
|
|
|
var (
|
|
ownIdentity = regexp.MustCompile(`^ident_mesh_[a-z][a-z0-9]{0,30}$`)
|
|
ownMeshKey = regexp.MustCompile(`^mesh_[a-z][a-z0-9]{0,30}$`)
|
|
)
|
|
|
|
// configPlan is what an apply does for the five kinds on this profile.
|
|
type configPlan struct {
|
|
Entries []resolvedEntry
|
|
// lines are the user.js statements, a comment before each entry's.
|
|
lines []string
|
|
// identityLists are, per adopted account key, the identity keys of the module's own it wants in its list.
|
|
identityLists map[string][]string
|
|
// calendarOrder are the calendar keys the module created, for calendar.list.sortOrder.
|
|
calendarOrder []string
|
|
// removals are the preference prefixes of what the module created and the setting no longer holds.
|
|
removals []string
|
|
filters []filterPlan
|
|
// released are the accounts that left the setting filters since the module last wrote their file.
|
|
released []string
|
|
record configRecord
|
|
problem string
|
|
}
|
|
|
|
// filterPlan is one declared account's filter file on this node.
|
|
type filterPlan struct {
|
|
Account string `json:"account"`
|
|
File string `json:"file,omitempty"`
|
|
// Waiting says why the file cannot be written yet, when it cannot.
|
|
Waiting string `json:"waiting,omitempty"`
|
|
Refused string `json:"refused,omitempty"`
|
|
want string
|
|
have string
|
|
exists bool
|
|
path string
|
|
}
|
|
|
|
func (p filterPlan) changes() bool { return p.Refused == "" && p.path != "" && p.want != p.have }
|
|
|
|
// planConfiguration resolves every declared entry against the profile and says what an apply writes.
|
|
func (m *Machine) planConfiguration(profile Profile, c Configuration, accounts map[string]Account,
|
|
ra []resolvedAccount, prefs map[string]string) configPlan {
|
|
rec := m.readConfigRecord(profile.Dir)
|
|
plan := configPlan{identityLists: map[string][]string{},
|
|
record: configRecord{Entries: map[string]map[string]recordedEntry{}, Filters: map[string]filterRecord{}}}
|
|
byName := map[string]resolvedAccount{}
|
|
for _, r := range ra {
|
|
byName[r.name] = r
|
|
}
|
|
have := profileAccounts(prefs)
|
|
listOfAccount := map[string][]string{}
|
|
for _, a := range have {
|
|
listOfAccount[a.Key] = a.Identities
|
|
}
|
|
identityKeyOf := map[string]string{}
|
|
|
|
// Identities.
|
|
taken := map[string]bool{}
|
|
for _, name := range sortedKeys(c.Identities) {
|
|
id := c.Identities[name]
|
|
e := resolvedEntry{Kind: kindIdentities, Name: name}
|
|
acct, declared := byName[id.Account]
|
|
switch {
|
|
case !declared:
|
|
e.How, e.Why = refused, fmt.Sprintf("its account %s is not in the setting accounts", id.Account)
|
|
case acct.How == refused:
|
|
e.How, e.Why = refused, fmt.Sprintf("its account %s is refused: betterbird_check says why", id.Account)
|
|
case id.Address == "":
|
|
e.How, e.Key = acct.How, acct.Keys.Identity
|
|
if taken[e.Key] {
|
|
e.How, e.Key, e.Why = refused, "", fmt.Sprintf("account %s has its default identity declared twice", id.Account)
|
|
}
|
|
default:
|
|
list := listOfAccount[acct.Keys.Account]
|
|
var others []string
|
|
if len(list) > 1 {
|
|
others = list[1:]
|
|
}
|
|
present := func(k string) bool {
|
|
for _, x := range others {
|
|
if x == k {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
matches := func(k string) bool {
|
|
return strings.EqualFold(unquote(prefs["mail.identity."+k+".useremail"]), id.Address)
|
|
}
|
|
e = resolveEntry(kindIdentities, name, id.Key, ownIdentityKey(name), rec.Entries[kindIdentities][name],
|
|
others, present, matches, taken, "an identity of its account with its address",
|
|
"give the one to adopt as key in this node's layer of the setting identities")
|
|
if e.How == created {
|
|
plan.identityLists[acct.Keys.Account] = append(plan.identityLists[acct.Keys.Account], e.Key)
|
|
}
|
|
}
|
|
if e.How != refused {
|
|
taken[e.Key] = true
|
|
identityKeyOf[name] = e.Key
|
|
plan.lines = append(plan.lines, fmt.Sprintf("// identity %s: %s under %s", name, e.How, e.Key))
|
|
plan.lines = append(plan.lines, identityLines(e, id, acct)...)
|
|
}
|
|
plan.Entries = append(plan.Entries, e)
|
|
}
|
|
// A created account's identity list is the module's whole: its default and the identities it created.
|
|
for _, name := range sortedKeys(byName) {
|
|
r := byName[name]
|
|
if r.How == created && len(plan.identityLists[r.Keys.Account]) > 0 {
|
|
list := append([]string{r.Keys.Identity}, plan.identityLists[r.Keys.Account]...)
|
|
plan.lines = append(plan.lines, fmt.Sprintf("user_pref(%s, %s);", quoted("mail.account."+r.Keys.Account+".identities"),
|
|
quoted(strings.Join(list, ","))))
|
|
delete(plan.identityLists, r.Keys.Account)
|
|
}
|
|
}
|
|
// Adopted accounts keep their list in prefs.js: the module's own keys are written into it while Betterbird is
|
|
// closed. An adopted account whose list holds none of them and wants none is left out.
|
|
for _, a := range have {
|
|
if _, wanted := plan.identityLists[a.Key]; wanted {
|
|
continue
|
|
}
|
|
for _, k := range a.Identities {
|
|
if ownIdentity.MatchString(k) {
|
|
plan.identityLists[a.Key] = []string{}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calendars.
|
|
cals := profileCalendars(prefs)
|
|
calKeys := map[string]profileCalendar{}
|
|
var caldav []string
|
|
for _, pc := range cals {
|
|
calKeys[pc.Key] = pc
|
|
if pc.Kind == "caldav" {
|
|
caldav = append(caldav, pc.Key)
|
|
}
|
|
}
|
|
taken = map[string]bool{}
|
|
for _, name := range sortedKeys(c.Calendars) {
|
|
cal := c.Calendars[name]
|
|
present := func(k string) bool { pc, ok := calKeys[k]; return ok && pc.Kind == "caldav" }
|
|
matches := func(k string) bool { return sameURL(calKeys[k].Value.URL, cal.URL) }
|
|
e := resolveEntry(kindCalendars, name, cal.Key, ownCalendarKey(name), rec.Entries[kindCalendars][name],
|
|
caldav, present, matches, taken, "a CalDAV calendar at its address",
|
|
"remove the duplicate in Betterbird, or give the one to adopt as key in this node's layer of the setting calendars")
|
|
if e.How != refused {
|
|
taken[e.Key] = true
|
|
if e.How == created {
|
|
plan.calendarOrder = append(plan.calendarOrder, e.Key)
|
|
}
|
|
plan.lines = append(plan.lines, fmt.Sprintf("// calendar %s: %s under %s", name, e.How, e.Key))
|
|
plan.lines = append(plan.lines, calendarLines(e, cal, identityKeyOf[cal.Invitations])...)
|
|
}
|
|
plan.Entries = append(plan.Entries, e)
|
|
}
|
|
|
|
// Address books.
|
|
books := profileAddressBooks(prefs)
|
|
bookKeys := map[string]profileAddressBook{}
|
|
var carddav []string
|
|
for _, b := range books {
|
|
bookKeys[b.Key] = b
|
|
if b.CardDAV {
|
|
carddav = append(carddav, b.Key)
|
|
}
|
|
}
|
|
taken = map[string]bool{}
|
|
for _, name := range sortedKeys(c.AddressBooks) {
|
|
ab := c.AddressBooks[name]
|
|
present := func(k string) bool { b, ok := bookKeys[k]; return ok && b.CardDAV }
|
|
matches := func(k string) bool { return sameURL(bookKeys[k].Value.URL, ab.URL) }
|
|
e := resolveEntry(kindAddressBooks, name, ab.Key, ownBookKey(name), rec.Entries[kindAddressBooks][name],
|
|
carddav, present, matches, taken, "a CardDAV address book at its address",
|
|
"remove the duplicate in Betterbird, or give the one to adopt as key in this node's layer of the setting address-books")
|
|
if e.How != refused {
|
|
taken[e.Key] = true
|
|
plan.lines = append(plan.lines, fmt.Sprintf("// address book %s: %s under %s", name, e.How, e.Key))
|
|
plan.lines = append(plan.lines, addressBookLines(e, ab, name)...)
|
|
}
|
|
plan.Entries = append(plan.Entries, e)
|
|
}
|
|
|
|
// Tags: a tag's key is its own, unique in a profile, so a match is never ambiguous.
|
|
tagKeys := map[string]bool{}
|
|
for _, t := range profileTags(prefs) {
|
|
tagKeys[t.Key] = true
|
|
}
|
|
for _, key := range sortedKeys(c.Tags) {
|
|
t := c.Tags[key]
|
|
e := resolvedEntry{Kind: kindTags, Name: key, Key: key, How: adopted}
|
|
if was, ok := rec.Entries[kindTags][key]; ok && was.How == created {
|
|
e.How = created
|
|
} else if !tagKeys[key] {
|
|
e.How = created
|
|
}
|
|
plan.lines = append(plan.lines, fmt.Sprintf("// tag %s: %s", key, e.How))
|
|
plan.lines = append(plan.lines, tagLines(key, t)...)
|
|
plan.Entries = append(plan.Entries, e)
|
|
}
|
|
|
|
// What the setting no longer holds: an adopted entry is released (its lines go), a created one's preferences
|
|
// are removed from prefs.js while Betterbird is closed.
|
|
declaredNow := map[string]map[string]bool{}
|
|
for _, e := range plan.Entries {
|
|
if declaredNow[e.Kind] == nil {
|
|
declaredNow[e.Kind] = map[string]bool{}
|
|
}
|
|
declaredNow[e.Kind][e.Name] = true
|
|
if e.How != refused {
|
|
if plan.record.Entries[e.Kind] == nil {
|
|
plan.record.Entries[e.Kind] = map[string]recordedEntry{}
|
|
}
|
|
plan.record.Entries[e.Kind][e.Name] = recordedEntry{How: e.How, Key: e.Key}
|
|
}
|
|
}
|
|
for _, kind := range []string{kindIdentities, kindCalendars, kindAddressBooks, kindTags} {
|
|
for _, name := range sortedKeys(rec.Entries[kind]) {
|
|
was := rec.Entries[kind][name]
|
|
if declaredNow[kind][name] || was.How != created {
|
|
continue
|
|
}
|
|
switch kind {
|
|
case kindIdentities:
|
|
plan.removals = append(plan.removals, "mail.identity."+was.Key+".")
|
|
case kindCalendars:
|
|
plan.removals = append(plan.removals, "calendar.registry."+was.Key+".")
|
|
case kindAddressBooks:
|
|
plan.removals = append(plan.removals, "ldap_2.servers."+was.Key+".")
|
|
case kindTags:
|
|
plan.removals = append(plan.removals, "mailnews.tags."+was.Key+".")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filters, owned whole per declared account (§8).
|
|
for _, account := range sortedAccountsOf(c.Filters) {
|
|
fp := filterPlan{Account: account}
|
|
r, ok := byName[account]
|
|
switch {
|
|
case !ok:
|
|
fp.Refused = fmt.Sprintf("its account %s is not in the setting accounts", account)
|
|
case r.How == refused:
|
|
fp.Refused = fmt.Sprintf("its account %s is refused: betterbird_check says why", account)
|
|
default:
|
|
dir := m.accountDirectory(profile.path, prefs, r.Keys.Server)
|
|
if dir == "" {
|
|
fp.Waiting = "Betterbird has not made the account's directory yet: it does at its first start with the account"
|
|
break
|
|
}
|
|
fp.path = filepath.Join(dir, filterFileName)
|
|
fp.File = m.tilde(strings.TrimPrefix(fp.path, m.Root))
|
|
fp.want = renderFilters(c.Filters[account], folderBase(prefs, r.Keys.Server))
|
|
raw, err := readBounded(fp.path)
|
|
fp.exists = err == nil
|
|
fp.have = string(raw)
|
|
}
|
|
was := rec.Filters[account]
|
|
was.Released = false
|
|
if fp.path != "" {
|
|
was.File = fp.File
|
|
}
|
|
if was.File != "" || was.Kept != "" {
|
|
plan.record.Filters[account] = was
|
|
}
|
|
plan.filters = append(plan.filters, fp)
|
|
}
|
|
for _, account := range sortedKeys(rec.Filters) {
|
|
if _, still := c.Filters[account]; still {
|
|
continue
|
|
}
|
|
was := rec.Filters[account]
|
|
if !was.Released {
|
|
plan.released = append(plan.released, account)
|
|
}
|
|
was.Released = true
|
|
plan.record.Filters[account] = was
|
|
}
|
|
return plan
|
|
}
|
|
|
|
// resolveEntry resolves an identity, a calendar or an address book as ADR 0317 resolves an account (§7).
|
|
func resolveEntry(kind, name, layerKey, own string, was recordedEntry, candidates []string, present, matches func(string) bool,
|
|
taken map[string]bool, what, remedy string) resolvedEntry {
|
|
e := resolvedEntry{Kind: kind, Name: name}
|
|
refuse := func(format string, args ...any) resolvedEntry {
|
|
e.How, e.Key, e.Why = refused, "", fmt.Sprintf(format, args...)
|
|
return e
|
|
}
|
|
// 1. Recorded, while the profile still holds it.
|
|
if was.Key != "" && !taken[was.Key] && (present(was.Key) || was.How == created) && (layerKey == "" || layerKey == was.Key) {
|
|
e.How, e.Key = was.How, was.Key
|
|
return e
|
|
}
|
|
// 2. Under the module's own key: its creation.
|
|
if present(own) {
|
|
e.How, e.Key = created, own
|
|
return e
|
|
}
|
|
// 3. The key a node's layer names.
|
|
if layerKey != "" {
|
|
switch {
|
|
case !present(layerKey):
|
|
return refuse("the key %s this node's layer names is not in the profile", layerKey)
|
|
case taken[layerKey]:
|
|
return refuse("the key %s is already another declared entry's", layerKey)
|
|
case !matches(layerKey):
|
|
return refuse("the key %s this node's layer names is not %s", layerKey, what)
|
|
}
|
|
e.How, e.Key = adopted, layerKey
|
|
return e
|
|
}
|
|
// 4. By the match.
|
|
var found []string
|
|
for _, k := range candidates {
|
|
if !taken[k] && !ownMeshKey.MatchString(k) && !ownIdentity.MatchString(k) && matches(k) {
|
|
found = append(found, k)
|
|
}
|
|
}
|
|
switch len(found) {
|
|
case 0:
|
|
e.How, e.Key = created, own
|
|
case 1:
|
|
e.How, e.Key = adopted, found[0]
|
|
default:
|
|
return refuse("%d entries in the profile are %s (%s): %s (novox/hq ADR 0329 §7)", len(found), what,
|
|
strings.Join(found, ", "), remedy)
|
|
}
|
|
return e
|
|
}
|
|
|
|
// sameURL compares two addresses without a trailing slash's difference.
|
|
func sameURL(a, b string) bool {
|
|
return a != "" && strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/"))
|
|
}
|
|
|
|
func pref(name, value string) string { return fmt.Sprintf("user_pref(%s, %s);", quoted(name), value) }
|
|
|
|
func boolWord(b bool) string { return strconv.FormatBool(b) }
|
|
|
|
// identityLines are an identity's declared fields under its key. The default identity's name and address are
|
|
// the setting accounts' (§4), so only another identity carries them here.
|
|
func identityLines(e resolvedEntry, id *Identity, acct resolvedAccount) []string {
|
|
p := "mail.identity." + e.Key + "."
|
|
var out []string
|
|
if id.Address != "" {
|
|
out = append(out, pref(p+"fullName", jsQuoted(id.Name)), pref(p+"useremail", jsQuoted(id.Address)))
|
|
if e.How == created {
|
|
out = append(out, pref(p+"valid", "true"), pref(p+"smtpServer", quoted(acct.Keys.SMTP)))
|
|
}
|
|
}
|
|
above := 0
|
|
if id.SignatureAboveQuote {
|
|
above = 1
|
|
}
|
|
out = append(out,
|
|
pref(p+"reply_to", jsQuoted(id.ReplyTo)),
|
|
pref(p+"organization", jsQuoted(id.Organization)),
|
|
pref(p+"htmlSigText", jsQuoted(strings.Join(id.Signature, "\n"))),
|
|
pref(p+"htmlSigFormat", boolWord(id.SignatureHTML)),
|
|
pref(p+"attach_signature", "false"),
|
|
pref(p+"reply_on_top", strconv.Itoa(above)),
|
|
pref(p+"sig_bottom", boolWord(!id.SignatureAboveQuote)))
|
|
return out
|
|
}
|
|
|
|
func calendarLines(e resolvedEntry, cal *Calendar, identityKey string) []string {
|
|
p := "calendar.registry." + e.Key + "."
|
|
var out []string
|
|
if e.How == created {
|
|
out = append(out, pref(p+"type", quoted("caldav")), pref(p+"cache.enabled", "true"),
|
|
pref(p+"calendar-main-in-composite", "true"))
|
|
}
|
|
reminders := cal.Reminders == nil || *cal.Reminders
|
|
out = append(out, pref(p+"uri", jsQuoted(cal.URL)), pref(p+"name", jsQuoted(cal.Name)))
|
|
if cal.Color != "" {
|
|
out = append(out, pref(p+"color", jsQuoted(strings.ToLower(cal.Color))))
|
|
}
|
|
out = append(out, pref(p+"readOnly", boolWord(cal.ReadOnly)), pref(p+"suppressAlarms", boolWord(!reminders)),
|
|
pref(p+"username", jsQuoted(cal.User)), pref(p+"imip.identity.key", jsQuoted(identityKey)))
|
|
return out
|
|
}
|
|
|
|
func addressBookLines(e resolvedEntry, ab *AddressBook, name string) []string {
|
|
p := "ldap_2.servers." + e.Key + "."
|
|
var out []string
|
|
if e.How == created {
|
|
out = append(out, pref(p+"dirType", "102"), pref(p+"filename", quoted("abook-mesh_"+name+".sqlite")))
|
|
}
|
|
return append(out, pref(p+"description", jsQuoted(ab.Name)), pref(p+"carddav.url", jsQuoted(ab.URL)),
|
|
pref(p+"carddav.username", jsQuoted(ab.User)))
|
|
}
|
|
|
|
func tagLines(key string, t *Tag) []string {
|
|
p := "mailnews.tags." + key + "."
|
|
out := []string{pref(p+"tag", jsQuoted(t.Name))}
|
|
if t.Color != "" {
|
|
out = append(out, pref(p+"color", jsQuoted(strings.ToUpper(t.Color))))
|
|
}
|
|
if t.Order > 0 {
|
|
out = append(out, pref(p+"ordinal", quoted(tagOrdinal(t.Order))))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// configBlock is the user.js block of the five kinds.
|
|
func (p configPlan) configBlock() string {
|
|
if len(p.lines) == 0 {
|
|
return ""
|
|
}
|
|
return "\n// The identities, calendars, address books and tags the settings declare (novox/hq ADR 0329), under the\n" +
|
|
"// keys each was adopted or created under. No password, token or cookie.\n" + strings.Join(p.lines, "\n") + "\n"
|
|
}
|
|
|
|
// closedFor says why the profile's own files may not be written now, or "": Betterbird runs, or the profile's
|
|
// lock says one holds it (§9).
|
|
func (m *Machine) closedFor(profile string) string {
|
|
if ps := m.mainProcs(); len(ps) > 0 {
|
|
return fmt.Sprintf("Betterbird is running (pid %d), and it rewrites these files when it quits", ps[0].PID)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(profile, "lock")); err == nil {
|
|
return "the profile is locked (its lock file is there): a Betterbird holds it, or one ended without quitting"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// prefsChanges is prefs.js as the plan wants it, and how many changes each kind has in it.
|
|
func (p configPlan) prefsChanges(text string) (string, map[string]int) {
|
|
counts := map[string]int{}
|
|
var lines []string
|
|
done := map[string]bool{}
|
|
s := bufio.NewScanner(strings.NewReader(text))
|
|
s.Buffer(make([]byte, 64<<10), 4<<20)
|
|
for s.Scan() {
|
|
line := s.Text()
|
|
mm := prefLine.FindStringSubmatch(line)
|
|
if mm == nil {
|
|
lines = append(lines, line)
|
|
continue
|
|
}
|
|
name := mm[1]
|
|
removed := false
|
|
for _, prefix := range p.removals {
|
|
if strings.HasPrefix(name, prefix) {
|
|
removed = true
|
|
counts[kindOfPrefix(prefix)]++
|
|
break
|
|
}
|
|
}
|
|
if removed {
|
|
continue
|
|
}
|
|
if acct, ok := strings.CutPrefix(name, "mail.account."); ok && strings.HasSuffix(acct, ".identities") {
|
|
key := strings.TrimSuffix(acct, ".identities")
|
|
if want, ok := p.identityLists[key]; ok {
|
|
done[name] = true
|
|
next := listedWithOwn(listOf(mm[2]), want, ownIdentity.MatchString, ",")
|
|
if next != strings.Join(listOf(mm[2]), ",") {
|
|
line = pref(name, quoted(next))
|
|
counts[kindIdentities]++
|
|
}
|
|
}
|
|
}
|
|
if name == "calendar.list.sortOrder" {
|
|
done[name] = true
|
|
have := strings.Fields(unquote(mm[2]))
|
|
next := listedWithOwn(have, p.calendarOrder, ownMeshKey.MatchString, " ")
|
|
if next != strings.Join(have, " ") {
|
|
line = pref(name, quoted(next))
|
|
counts[kindCalendars]++
|
|
}
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
if !done["calendar.list.sortOrder"] && len(p.calendarOrder) > 0 {
|
|
lines = append(lines, pref("calendar.list.sortOrder", quoted(strings.Join(p.calendarOrder, " "))))
|
|
counts[kindCalendars]++
|
|
}
|
|
out := strings.Join(lines, "\n")
|
|
if text == "" || strings.HasSuffix(text, "\n") {
|
|
out += "\n"
|
|
}
|
|
return out, counts
|
|
}
|
|
|
|
func kindOfPrefix(prefix string) string {
|
|
switch {
|
|
case strings.HasPrefix(prefix, "mail.identity."):
|
|
return kindIdentities
|
|
case strings.HasPrefix(prefix, "calendar."):
|
|
return kindCalendars
|
|
case strings.HasPrefix(prefix, "ldap_2."):
|
|
return kindAddressBooks
|
|
}
|
|
return kindTags
|
|
}
|
|
|
|
// listedWithOwn is a list with the module's own keys made the wanted ones, every other key in its place.
|
|
func listedWithOwn(have, wanted []string, own func(string) bool, sep 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 own(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, sep)
|
|
}
|
|
|
|
// Waiting counts, per kind, the changes of what Betterbird rewrites that wait for it to close (§9).
|
|
func (m *Machine) waiting(profile string, p configPlan, ra []resolvedAccount) map[string]int {
|
|
raw, _ := readBounded(filepath.Join(profile, "prefs.js"))
|
|
_, counts := p.prefsChanges(string(raw))
|
|
var wantAccounts, wantSMTP []string
|
|
for _, r := range ra {
|
|
if r.How == created {
|
|
wantAccounts, wantSMTP = append(wantAccounts, r.Keys.Account), append(wantSMTP, r.Keys.SMTP)
|
|
}
|
|
}
|
|
if _, changed := withListed(string(raw), map[string][]string{
|
|
"mail.accountmanager.accounts": wantAccounts, "mail.smtpservers": wantSMTP}); changed {
|
|
counts["accounts"]++
|
|
}
|
|
for _, f := range p.filters {
|
|
if f.changes() {
|
|
counts[kindFilters]++
|
|
}
|
|
}
|
|
for k, v := range counts {
|
|
if v == 0 {
|
|
delete(counts, k)
|
|
}
|
|
}
|
|
return counts
|
|
}
|
|
|
|
// writeClosed writes what Betterbird rewrites itself, only while it is closed: prefs.js's lists and the
|
|
// removals, and each declared account's filter file, keeping the file it replaces at its first write. It
|
|
// answers what it wrote, or why it waits.
|
|
func (m *Machine) writeClosed(profile Profile, p *configPlan) (string, error) {
|
|
var said []string
|
|
path := filepath.Join(profile.path, "prefs.js")
|
|
raw, err := readBounded(path)
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return "", err
|
|
}
|
|
next, counts := p.prefsChanges(string(raw))
|
|
pending := next != string(raw)
|
|
for _, f := range p.filters {
|
|
pending = pending || f.changes()
|
|
}
|
|
if !pending {
|
|
return "unchanged", nil
|
|
}
|
|
if why := m.closedFor(profile.path); why != "" {
|
|
return "", errors.New(why + ": the identities' and calendars' lists, the removals and the filter files are " +
|
|
"written at its next start from the launcher, or by betterbird_restart")
|
|
}
|
|
if next != string(raw) {
|
|
mode := os.FileMode(0o600)
|
|
if st, err := os.Stat(path); err == nil {
|
|
mode = st.Mode().Perm()
|
|
}
|
|
if err := writeAtomic(path, []byte(next), mode); err != nil {
|
|
return "", err
|
|
}
|
|
said = append(said, fmt.Sprintf("prefs.js: %d change(s) to the lists and removals", sum(counts)))
|
|
}
|
|
for i, f := range p.filters {
|
|
if !f.changes() {
|
|
continue
|
|
}
|
|
was := p.record.Filters[f.Account]
|
|
if was.Kept == "" && f.exists {
|
|
kept := m.keptFilterFile(profile.Dir, f.Account)
|
|
if err := writeAtomic(kept, []byte(f.have), 0o600); err != nil {
|
|
return strings.Join(said, "; "), fmt.Errorf("the filter file of %s was not written: the file it replaces could not be kept: %v", f.Account, err)
|
|
}
|
|
was.Kept = m.tilde(strings.TrimPrefix(kept, m.Root))
|
|
}
|
|
mode := os.FileMode(0o644)
|
|
if st, err := os.Stat(f.path); err == nil {
|
|
mode = st.Mode().Perm()
|
|
}
|
|
if err := writeAtomic(f.path, []byte(f.want), mode); err != nil {
|
|
return strings.Join(said, "; "), err
|
|
}
|
|
was.File = f.File
|
|
p.record.Filters[f.Account] = was
|
|
p.filters[i].have, p.filters[i].exists = f.want, true
|
|
said = append(said, "the filters of "+f.Account)
|
|
}
|
|
if len(said) == 0 {
|
|
return "unchanged", nil
|
|
}
|
|
return "written: " + strings.Join(said, "; "), nil
|
|
}
|
|
|
|
func sum(m map[string]int) int {
|
|
n := 0
|
|
for _, v := range m {
|
|
n += v
|
|
}
|
|
return n
|
|
}
|
|
|
|
// accountDirectory is an account's directory in the profile, as its server's preferences name it, or "" when
|
|
// they name none within the profile.
|
|
func (m *Machine) accountDirectory(profile string, prefs map[string]string, server string) string {
|
|
if dir := accountDirectory(profile, prefs, server); dir != "" {
|
|
return dir
|
|
}
|
|
abs := unquote(prefs["mail.server."+server+".directory"])
|
|
if abs == "" {
|
|
return ""
|
|
}
|
|
dir := m.path(abs)
|
|
if strings.HasPrefix(filepath.Clean(dir), filepath.Clean(profile)+string(filepath.Separator)) {
|
|
return dir
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func mustJSON(v any) string {
|
|
b, _ := json.Marshal(v)
|
|
return string(bytes.TrimSpace(b))
|
|
}
|