Decision — email accounts live in settingsd, passwords in the app
Status: accepted 2026-10-16. Applies to the email app
and the settings contract (email.*, contract
0.8.0).
The decision
- Non-secret account settings live in settingsd.
- The session profile's
emailgroup holds every account, in tree order:id,name,url,email,ca_path,enabledandsignature. It also holdsdefault_accountand the mail options (page_size,mark_read_on_open,confirm_delete). email.accountsis replaced whole by one fencedsettings.apply. A change that removes the default account resetsemail.default_accountin the same batch, so settingsd never holds half a change.- The email app follows the group through the session-profile summary and reconciles its connections live.
- Ids are minted once (
acct-and 12 hex digits) and never reused for another account.
- The session profile's
- Passwords never enter settingsd.
- settingsd publishes retained snapshots on the Bus, which every
client can read. The
emailgroup therefore has no password field, and every account record refuses unknown keys, so an apply carryingpasswordis refused. - Each password lives in the email app's own file,
<config>/secrets.conf.mix({schema_version: 1, passwords: {id: …}}). The directory is 0700 and the file 0600, it is replaced atomically, and the poison-pill law applies: a malformed file is never overwritten. - Only the email app writes that file, and a password leaves it only toward the account's own server.
- settingsd publishes retained snapshots on the Bus, which every
client can read. The
- A password is reachable only through a write-only verb.
email.account.password.set {id, password}stores a password and reconnects the account.email.account.setmay carrypasswordtoo. It is held only once settingsd has accepted the account.- No verb, topic, log line or diagnostic returns a password.
email.accountsandemail.inforeporthas_password: bool.
- Prefs is the configuration surface.
- Accounts are added, edited, removed, reordered and made the default
in Prefs' Email section, which writes
email.*through settingsd. It sets passwords throughemail.account.password.set, starting Email first if it is not running. - The email app has no account form of its own. With no account, or with an account that failed or has no password, it offers Open Email Settings.
- Accounts are added, edited, removed, reordered and made the default
in Prefs' Email section, which writes
- The app keeps a cache of the last accounts it read in its own
config (schema 2, which holds no secret). When settingsd is absent, the
cached accounts open read-only and the status line says so. Account
writes are refused with
UNAVAILABLEuntil settingsd is back. - The single account of an older install migrates once, losing
nothing.
- The account comes from the schema-1
config.conf.mixand is given an id derived from its URL and address. - Its password is written to the secrets file first. An inline CA
bundle goes to a 0600 file beside it, which the account then names by
ca_path. - The account is then applied to settingsd, and made the default when there is none. An account settingsd already holds with the same URL and address is adopted instead.
- The schema-1 file is kept, and is written back unchanged, until settingsd has the account and its password is durable under that account's id. Only then is it rewritten as schema 2.
- The account comes from the schema-1
Why
- The Bus must not carry secrets. The session profile is retained and broadcast so that any app can follow it. A password there would be readable by every Bus client and would appear in every snapshot and backup of the profile.
- One place to configure. Terminal preferences already live in settingsd and are edited in Prefs. Mail accounts are the same kind of thing, and a second account form inside the app would drift from it.
- Automation needs the accounts as data. An agent can list, add or
disable an account through
settings.applyor the email verbs. It never handles a secret, unless it is the one supplying the password.
Consequences
- A settings read never deletes a password. Only an explicit action
through Email does:
email.account.remove, oremail.account.password.setwith an empty password. An account removed elsewhere (in Prefs, by a reset) drops the app's client and view, and leaves its entry in the 0600 secrets file, unused. That is accepted: losing a password to a misread would be worse. - An empty account list is not a removal of them all. settingsd omits the group at its defaults, so an older settingsd, a reset and a profile without the group cannot be told apart. While accounts are shown, such a read keeps them (views, connections, passwords) with a status note, until a read lists accounts again or one is removed through Email.
- Migration steps land in order, each durably. First the password is saved to the secrets file. Then the account is applied to settingsd. Then, if settingsd held it under another id, the password is saved under that id. Only then is the schema-1 file rewritten. A failure at any step, including a poison-pill refusal to write the secrets file, leaves the schema-1 file untouched, and the next launch runs the migration again.
- A conflicting change (two writers at once) is refused by the fence.
The email app reads again, retries a few times, and then reports
CONFLICT. - MixOS has no keyring yet. The secrets file has the same trust as the app's other private files. A keyring, when it lands, replaces only that file; the settings group and the verbs stay as they are.