Let an installation seed and lock user settings

The first two thirds of #207. A school wanting "warn about outside senders"
on for three thousand pupils cannot ask three thousand pupils, and the
reporter is right that this is a company policy rather than a preference.

Two powers, and the difference between them is the whole request. `defaults`
seed an account that has never had settings of its own and can be changed
afterwards like anything else -- a starting point, not a rule. `enforced` are
reapplied on every load and cannot be changed at all.

Enforced controls stay visible and go dead, with a line saying why. The issue
asked for that by name: a control that is simply missing reads as a bug to
somebody who has used ihasmail without a policy.

The lock is in the settings store rather than only on the controls. There is
one door -- `update` -- and putting it there means an imported settings file,
a settings file synced from a device that predates the policy, and a control
somebody adds later and forgets to check are all covered by construction.
Reset goes back to the installation's answer rather than to ihasmail's, so it
cannot be a way around a policy either.

Configured by environment variable or by a file, because ihasmail's own
production runs read-only with no volume: an installation that cannot mount a
file can still set a variable. Keys this build does not have are dropped, the
same rule an imported settings file already gets -- a policy written against a
newer ihasmail must not put a setting nothing reads into everybody's synced
settings file. Malformed JSON stops the server rather than quietly doing
nothing, since a policy that silently did not apply is indistinguishable from
the feature not working.

Tier three -- enforcing a setting once while still letting readers change it
afterwards -- is not here. It needs a decision the reporter and I have not
made yet, and it is the only part that stores anything new.

Refs #207.
This commit is contained in:
2026-09-02 10:49:55 -07:00
parent a01e1874d8
commit 457ea53ca3
22 changed files with 396 additions and 45 deletions
+9
View File
@@ -19,6 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock";
import { setUnreadBadge } from "@/lib/notify";
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
import { useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
@@ -145,9 +146,17 @@ function AuthedApp() {
}
let cancelled = false;
void (async () => {
/* Before the account's own settings, so both the seeding below and the
enforcement inside `hydrate` have something to apply. */
await loadSettingsPolicy();
if (cancelled) return;
const remote = await loadRemoteSettings();
if (cancelled) return;
if (remote) useSettings.getState().hydrate(remote);
// No settings file: this account has never had settings of its own, so
// the installation's defaults are what it starts on rather than
// ihasmail's. Issue #207.
else useSettings.getState().seedFromPolicy();
// The catalogue for whatever language that turned out to be. Hydrating
// asks for it; this is waiting for the answer.
await whenLanguageReady();