Merge pull request #231 from Coffey-Labs/feat/policy-change-once

Apply installation policy changes once each, per account
This commit is contained in:
Coffey Labs
2026-09-02 11:03:50 -07:00
committed by GitHub
15 changed files with 327 additions and 25 deletions
+87 -15
View File
@@ -128,34 +128,106 @@ thousand pupils is not a plan.
-e SETTINGS_ENFORCED='{"externalRecipientConfirm":true}'
```
Or, where mounting a file is easier than quoting JSON in a unit file:
Three powers, and the differences between them matter:
| Section | Applies to | Reader can change it |
| --- | --- | --- |
| `defaults` | accounts that have never had settings of their own | yes, at any time |
| `enforced` | everyone, on every load | no — the control goes dead |
| `changes` | everyone, **once each**, including existing accounts | yes, afterwards, and it stays changed |
`changes` is the one that needs explaining. It turns something on for people who
are *already here* — the reason a plain default is not enough — while still
leaving them the last word. Each entry carries its own `version`, which every
account remembers once it has had it, so the change is applied exactly once per
person and a reader who turns it back off keeps it off. It is a schema migration
in shape, and that is deliberately whose idea it was ([#207]).
Nothing is configured by default: an installation that sets none of these
behaves exactly as ihasmail always has.
### Passing a policy to Docker
Where a file is easier to manage than JSON quoted in a unit file — and it
usually is once there are `changes` in it — mount one and name it:
```bash
-e SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json
docker run -d --name ihasmail \
-e STALWART_URL=https://mail.example.org \
-e APP_SECRET="$(openssl rand -hex 32)" \
-e SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json \
-v /srv/ihasmail/policy.json:/etc/ihasmail/policy.json:ro \
-p 8080:8080 ghcr.io/coffey-labs/ihasmail:latest
```
```json
{
"defaults": { "externalSenderBanner": true },
"enforced": { "externalRecipientConfirm": true }
"enforced": { "externalRecipientConfirm": true },
"changes": [
{ "version": "20260902084513", "settings": { "externalSenderBanner": true } },
{ "version": "20261014091500", "settings": { "externalLinkWarning": true } }
]
}
```
The two are different powers. **`defaults`** seed an account that has never had
settings of its own; the reader can change any of them afterwards, and they are
a starting point rather than a rule. **`enforced`** are reapplied on every load
and cannot be changed at all — their controls stay visible in Settings and go
dead with a line saying why, because a control that is simply missing reads as a
bug to anyone who has used ihasmail without a policy.
Mount it read-only: the server only ever reads it, and `:ro` keeps that true
under `--read-only` as well.
Both are given in the same names and values a settings export uses, so
`Settings → General → Export` on a configured account is the quickest way to
write one. Keys this build does not have are ignored rather than stored, and
malformed JSON stops the server at startup rather than silently doing nothing.
Or without a file at all, which is what an immutable deployment with no volume
wants:
```bash
docker run -d --name ihasmail --read-only --tmpfs /tmp \
-e IMMUTABLE=1 -e SESSION_FILE= \
-e STALWART_URL=https://mail.example.org \
-e APP_SECRET="$(openssl rand -hex 32)" \
-e SETTINGS_DEFAULTS='{"externalSenderBanner":true}' \
-e SETTINGS_ENFORCED='{"externalRecipientConfirm":true}' \
-e SETTINGS_CHANGES='[{"version":"20260902084513","settings":{"externalSenderBanner":true}}]' \
-p 8080:8080 ghcr.io/coffey-labs/ihasmail:latest
```
In `docker-compose.yml`:
```yaml
services:
ihasmail:
image: ghcr.io/coffey-labs/ihasmail:latest
environment:
SETTINGS_POLICY_FILE: /etc/ihasmail/policy.json
volumes:
- ./policy.json:/etc/ihasmail/policy.json:ro
```
A policy is read once at startup, so **editing it means restarting the
container**. There is no reload signal, deliberately: an installation-wide
setting changing under a running instance would be harder to reason about than
one that changes when you say so.
### Writing a policy
Both sections take the same names and values a settings export uses, so
`Settings → General → Export` on one account you have configured by hand is the
quickest way to write one — copy the keys you care about out of the file.
Three checks worth knowing about, because they fail loudly rather than quietly:
- **Malformed JSON stops the server at startup.** A policy that silently did not
apply is indistinguishable from the feature not working.
- **Every change needs a unique `version`.** Two changes sharing one, or a change
with no `version` or no `settings`, is a startup error.
- **Keys this build does not have are dropped**, the same rule an imported
settings file gets. A `changes` entry whose keys are *all* unknown is dropped
whole rather than recorded as applied, so it still runs on an ihasmail that
does have the setting.
Enforcement is applied in the settings store rather than only on the controls,
so an imported settings file or a settings file synced from a device that
predates the policy cannot get around it.
so an imported settings file, a settings file synced from a device that predates
the policy, and "reset to defaults" cannot get around it. Reset returns to your
defaults, not to ihasmail's.
[#207]: https://github.com/Coffey-Labs/ihasmail/issues/207
## Architecture
+31 -1
View File
@@ -124,12 +124,16 @@ if (immutable) assertImmutable(sessionFile, fileURLToPath(new URL("../..", impor
* - `enforced` are applied on every load and cannot be changed here at all. The
* controls stay visible and go dead, which the issue asked for by name: a
* missing control confuses somebody who has used ihasmail elsewhere.
* - `changes` are applied once each, to everybody, including accounts that
* already exist -- and can be changed back afterwards. Each carries its own
* `version`, which is how an account remembers the ones it has had. The
* reporter's own analogy is a schema migration and this is that shape.
*
* Read from a file or straight from the environment, because ihasmail's own
* production runs read-only with no volume -- an installation that cannot mount
* a file can still set a variable.
*/
function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Record<string, unknown> } {
function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Record<string, unknown>; changes: Array<{ version: string; settings: Record<string, unknown> }> } {
const parse = (raw: string, where: string): Record<string, unknown> => {
try {
const v = JSON.parse(raw) as unknown;
@@ -142,6 +146,30 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Re
}
};
/**
* A change list, checked rather than trusted.
*
* Every entry needs a `version` that is unique within the file: it is what an
* account stores to say it has had this one, so a duplicate would make two
* changes indistinguishable and a missing one would apply for ever.
*/
const parseChanges = (v: unknown, where: string): Array<{ version: string; settings: Record<string, unknown> }> => {
if (v === undefined) return [];
if (!Array.isArray(v)) throw new Error(`Invalid ${where}: "changes" must be a list`);
const seen = new Set<string>();
return v.map((entry, i) => {
const e = entry as { version?: unknown; settings?: unknown };
const version = typeof e.version === "string" ? e.version.trim() : "";
if (!version) throw new Error(`Invalid ${where}: changes[${i}] has no "version"`);
if (seen.has(version)) throw new Error(`Invalid ${where}: two changes share the version "${version}"`);
seen.add(version);
if (!e.settings || typeof e.settings !== "object" || Array.isArray(e.settings)) {
throw new Error(`Invalid ${where}: changes[${i}] ("${version}") has no "settings" object`);
}
return { version, settings: e.settings as Record<string, unknown> };
});
};
const file = process.env.SETTINGS_POLICY_FILE;
if (file) {
if (!existsSync(file)) throw new Error(`SETTINGS_POLICY_FILE does not exist: ${file}`);
@@ -149,11 +177,13 @@ function readSettingsPolicy(): { defaults: Record<string, unknown>; enforced: Re
return {
defaults: (whole.defaults as Record<string, unknown>) ?? {},
enforced: (whole.enforced as Record<string, unknown>) ?? {},
changes: parseChanges(whole.changes, `SETTINGS_POLICY_FILE (${file})`),
};
}
return {
defaults: process.env.SETTINGS_DEFAULTS ? parse(process.env.SETTINGS_DEFAULTS, "SETTINGS_DEFAULTS") : {},
enforced: process.env.SETTINGS_ENFORCED ? parse(process.env.SETTINGS_ENFORCED, "SETTINGS_ENFORCED") : {},
changes: process.env.SETTINGS_CHANGES ? parseChanges(JSON.parse(process.env.SETTINGS_CHANGES), "SETTINGS_CHANGES") : [],
};
}
+16 -3
View File
@@ -9,7 +9,7 @@ import { useFiles } from "@/store/files";
import { useSieve } from "@/store/sieve";
import { push } from "@/jmap/push";
import { client } from "@/jmap/client";
import { ToastHost } from "@/ui/toast";
import { ToastHost, toast } from "@/ui/toast";
import { ConfirmHost } from "@/ui/dialog";
import { Spinner } from "@/ui/misc";
import { LoginPage } from "@/views/Login";
@@ -21,9 +21,9 @@ 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 { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
import { BASE_PATH } from "@/lib/basePath";
import { BASE_PATH, withBase } from "@/lib/basePath";
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
@@ -157,6 +157,19 @@ function AuthedApp() {
// the installation's defaults are what it starts on rather than
// ihasmail's. Issue #207.
else useSettings.getState().seedFromPolicy();
/*
* After both, and for everybody: a change the installation wants applied
* once has to reach accounts that already exist, which is the whole of
* why it is not just a default. Each is remembered, so a reader who turns
* one back off keeps it off. Issue #207.
*/
const applied = useSettings.getState().applyPolicyChanges();
if (applied.length) {
toast.show(plural(applied.length, {
one: "Your administrator changed {n} setting",
other: "Your administrator changed {n} settings",
}), { action: { label: t("Settings"), onClick: () => { window.location.href = withBase("/settings/general"); } } });
}
// The catalogue for whatever language that turned out to be. Hydrating
// asks for it; this is waiting for the answer.
await whenLanguageReady();
+37 -4
View File
@@ -14,12 +14,26 @@ import { DEFAULT_SETTINGS, type Settings } from "@/store/settings";
* sign-in page, so this costs nothing on a cold load and is available before
* anybody's settings are read.
*/
export interface PolicyChange {
/** Unique in the policy; what an account stores to say it has had this one. */
version: string;
settings: Partial<Settings>;
}
export interface SettingsPolicy {
defaults: Partial<Settings>;
enforced: Partial<Settings>;
/**
* Applied once each, to everybody, and changeable afterwards.
*
* The third power in #207, and the one that needed somewhere to remember: an
* admin turning something on for existing accounts, without it snapping back
* on for a reader who then turned it off.
*/
changes: PolicyChange[];
}
const EMPTY: SettingsPolicy = { defaults: {}, enforced: {} };
const EMPTY: SettingsPolicy = { defaults: {}, enforced: {}, changes: [] };
let policy: SettingsPolicy = EMPTY;
let fetched: Promise<SettingsPolicy> | null = null;
@@ -45,10 +59,18 @@ export async function loadSettingsPolicy(): Promise<SettingsPolicy> {
try {
const res = await fetch(withBase("/api/config"), { credentials: "same-origin" });
if (!res.ok) return EMPTY;
const body = (await res.json()) as { settingsPolicy?: { defaults?: Record<string, unknown>; enforced?: Record<string, unknown> } };
const body = (await res.json()) as {
settingsPolicy?: { defaults?: Record<string, unknown>; enforced?: Record<string, unknown>; changes?: Array<{ version: string; settings: Record<string, unknown> }> };
};
policy = {
defaults: known(body.settingsPolicy?.defaults ?? {}),
enforced: known(body.settingsPolicy?.enforced ?? {}),
/* A change whose every key this build does not have is dropped whole:
applying nothing and then recording it as applied would mean it never
ran on the ihasmail that does have the setting. */
changes: (body.settingsPolicy?.changes ?? [])
.map((c) => ({ version: c.version, settings: known(c.settings ?? {}) }))
.filter((c) => c.version && Object.keys(c.settings).length),
};
return policy;
} catch {
@@ -75,8 +97,19 @@ export function isEnforced(key: keyof Settings): boolean {
return key in policy.enforced;
}
/** Changes the installation wants applied once each. */
export function policyChanges(): PolicyChange[] {
return policy.changes;
}
/** Only for tests: forget what was fetched. */
export function resetSettingsPolicyForTest(next: SettingsPolicy = EMPTY): void {
policy = { defaults: known(next.defaults as Record<string, unknown>), enforced: known(next.enforced as Record<string, unknown>) };
export function resetSettingsPolicyForTest(next: Partial<SettingsPolicy> = {}): void {
policy = {
defaults: known((next.defaults ?? {}) as Record<string, unknown>),
enforced: known((next.enforced ?? {}) as Record<string, unknown>),
changes: (next.changes ?? [])
.map((c) => ({ version: c.version, settings: known(c.settings as Record<string, unknown>) }))
.filter((c) => c.version && Object.keys(c.settings).length),
};
fetched = Promise.resolve(policy);
}
+1
View File
@@ -1079,6 +1079,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Hier ist nichts ungelesen",
},
plurals: {
"Your administrator changed {n} settings": { one: "Ihre Administration hat {n} Einstellung geändert", other: "Ihre Administration hat {n} Einstellungen geändert" },
"Already here: {n} contacts, nothing imported": { one: "Bereits vorhanden: {n} Kontakt, nichts importiert", other: "Bereits vorhanden: {n} Kontakte, nichts importiert" },
"All {n} are already in your contacts": { one: "Bereits in Ihren Kontakten", other: "Alle {n} sind bereits in Ihren Kontakten" },
"Exported {n} events": { one: "{n} Termin exportiert", other: "{n} Termine exportiert" },
+1
View File
@@ -1052,6 +1052,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Aquí no hay nada sin leer",
},
plurals: {
"Your administrator changed {n} settings": { one: "Tu administración cambió {n} ajuste", other: "Tu administración cambió {n} ajustes" },
"Already here: {n} contacts, nothing imported": { one: "Ya estaba aquí: {n} contacto, no se importó nada", other: "Ya estaban aquí: {n} contactos, no se importó nada" },
"All {n} are already in your contacts": { one: "Ya está en tus contactos", other: "Los {n} ya están en tus contactos" },
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
+1
View File
@@ -1057,6 +1057,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Rien de non lu ici",
},
plurals: {
"Your administrator changed {n} settings": { one: "Votre administration a modifié {n} paramètre", other: "Votre administration a modifié {n} paramètres" },
"Already here: {n} contacts, nothing imported": { one: "Déjà présent : {n} contact, rien dimporté", other: "Déjà présents : {n} contacts, rien dimporté" },
"All {n} are already in your contacts": { one: "Déjà dans vos contacts", other: "Les {n} sont déjà dans vos contacts" },
"Exported {n} events": { one: "{n} événement exporté", other: "{n} événements exportés" },
+1
View File
@@ -1060,6 +1060,7 @@ export const catalog: Catalog = {
"Nothing unread here": "ここに未読はありません",
},
plurals: {
"Your administrator changed {n} settings": { other: "管理者が {n} 件の設定を変更しました" },
"Already here: {n} contacts, nothing imported": { other: "すでに存在: {n} 件、インポートなし" },
"All {n} are already in your contacts": { other: "{n} 件はすでに連絡先にあります" },
"Exported {n} events": { other: "{n} 件の予定をエクスポートしました" },
+1
View File
@@ -1048,6 +1048,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Hier is niets ongelezen",
},
plurals: {
"Your administrator changed {n} settings": { one: "Uw beheerder heeft {n} instelling gewijzigd", other: "Uw beheerder heeft {n} instellingen gewijzigd" },
"Already here: {n} contacts, nothing imported": { one: "Al aanwezig: {n} contact, niets geïmporteerd", other: "Al aanwezig: {n} contacten, niets geïmporteerd" },
"All {n} are already in your contacts": { one: "Staat al in uw contacten", other: "Alle {n} staan al in uw contacten" },
"Exported {n} events": { one: "{n} afspraak geëxporteerd", other: "{n} afspraken geëxporteerd" },
+1
View File
@@ -1055,6 +1055,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Não há nada não lido aqui",
},
plurals: {
"Your administrator changed {n} settings": { one: "Sua administração alterou {n} configuração", other: "Sua administração alterou {n} configurações" },
"Already here: {n} contacts, nothing imported": { one: "Já estava aqui: {n} contato, nada importado", other: "Já estavam aqui: {n} contatos, nada importado" },
"All {n} are already in your contacts": { one: "Já está nos seus contatos", other: "Todos os {n} já estão nos seus contatos" },
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
+1
View File
@@ -1054,6 +1054,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Здесь нет непрочитанного",
},
plurals: {
"Your administrator changed {n} settings": { one: "Администратор изменил {n} настройку", few: "Администратор изменил {n} настройки", many: "Администратор изменил {n} настроек", other: "Администратор изменил {n} настройки" },
"Already here: {n} contacts, nothing imported": { one: "Уже есть: {n} контакт, ничего не импортировано", few: "Уже есть: {n} контакта, ничего не импортировано", many: "Уже есть: {n} контактов, ничего не импортировано", other: "Уже есть: {n} контакта, ничего не импортировано" },
"All {n} are already in your contacts": { one: "Уже в ваших контактах", few: "Все {n} уже в ваших контактах", many: "Все {n} уже в ваших контактах", other: "Все {n} уже в ваших контактах" },
"Exported {n} events": { one: "Экспортировано {n} событие", few: "Экспортировано {n} события", many: "Экспортировано {n} событий", other: "Экспортировано {n} события" },
+1
View File
@@ -1048,6 +1048,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Тут немає непрочитаного",
},
plurals: {
"Your administrator changed {n} settings": { one: "Адміністратор змінив {n} налаштування", few: "Адміністратор змінив {n} налаштування", many: "Адміністратор змінив {n} налаштувань", other: "Адміністратор змінив {n} налаштування" },
"Already here: {n} contacts, nothing imported": { one: "Уже є: {n} контакт, нічого не імпортовано", few: "Уже є: {n} контакти, нічого не імпортовано", many: "Уже є: {n} контактів, нічого не імпортовано", other: "Уже є: {n} контакти, нічого не імпортовано" },
"All {n} are already in your contacts": { one: "Уже у ваших контактах", few: "Усі {n} уже у ваших контактах", many: "Усі {n} уже у ваших контактах", other: "Усі {n} уже у ваших контактах" },
"Exported {n} events": { one: "Експортовано {n} подію", few: "Експортовано {n} події", many: "Експортовано {n} подій", other: "Експортовано {n} події" },
+1
View File
@@ -1059,6 +1059,7 @@ export const catalog: Catalog = {
"Nothing unread here": "这里没有未读邮件",
},
plurals: {
"Your administrator changed {n} settings": { other: "管理员更改了 {n} 项设置" },
"Already here: {n} contacts, nothing imported": { other: "已存在 {n} 个,未导入" },
"All {n} are already in your contacts": { other: "这 {n} 个已在您的联系人中" },
"Exported {n} events": { other: "已导出 {n} 个日程" },
@@ -122,3 +122,96 @@ describe("reset, where the installation has chosen defaults", () => {
expect(useSettings.getState().settings.conversationMode).toBe(false);
});
});
/*
* The third power: applied once each, to everybody, and changeable afterwards.
*
* The difference from `enforced` is entirely in the remembering. Both reach an
* account that already exists; only this one lets the reader have the last
* word, and only because the version is stored.
*/
describe("changes an installation wants applied once", () => {
const change = (version: string, settings: Record<string, unknown>) => ({ version, settings } as never);
it("applies one the account has not had", () => {
resetSettingsPolicyForTest({ changes: [change("20260902", { conversationMode: false })] });
const applied = useSettings.getState().applyPolicyChanges();
expect(applied.map((c) => c.version)).toEqual(["20260902"]);
expect(useSettings.getState().settings.conversationMode).toBe(false);
});
it("remembers it, so the next sign-in does not do it again", () => {
resetSettingsPolicyForTest({ changes: [change("20260902", { conversationMode: false })] });
useSettings.getState().applyPolicyChanges();
// The reader decides otherwise, which is the whole difference from enforcing.
useSettings.getState().update({ conversationMode: true });
expect(useSettings.getState().applyPolicyChanges()).toEqual([]);
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("reaches an account that had already chosen otherwise", () => {
/*
* Confirmed as intended on #207: the point is to reach everybody who is
* already here, so somebody who turned it off last week does get it turned
* back on -- once.
*/
useSettings.getState().update({ conversationMode: false });
resetSettingsPolicyForTest({ changes: [change("20260902", { conversationMode: true })] });
useSettings.getState().applyPolicyChanges();
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("applies only the ones that are new, keeping what it has seen", () => {
resetSettingsPolicyForTest({ changes: [change("A", { conversationMode: false })] });
useSettings.getState().applyPolicyChanges();
resetSettingsPolicyForTest({
changes: [change("A", { conversationMode: false }), change("B", { showAvatars: false })],
});
const applied = useSettings.getState().applyPolicyChanges();
expect(applied.map((c) => c.version)).toEqual(["B"]);
expect(useSettings.getState().settings.appliedPolicyChanges).toEqual(["A", "B"]);
});
it("does not skip a change dated earlier than one already applied", () => {
// Ids, not a high-water mark. An admin backfilling a change must not find
// it silently ignored because a later one went first.
resetSettingsPolicyForTest({ changes: [change("20260902", { conversationMode: false })] });
useSettings.getState().applyPolicyChanges();
resetSettingsPolicyForTest({
changes: [change("20260101", { showAvatars: false }), change("20260902", { conversationMode: false })],
});
expect(useSettings.getState().applyPolicyChanges().map((c) => c.version)).toEqual(["20260101"]);
expect(useSettings.getState().settings.showAvatars).toBe(false);
});
it("goes out as one write however many changes are pending", () => {
resetSettingsPolicyForTest({
changes: [change("A", { conversationMode: false }), change("B", { showAvatars: false })],
});
const applied = useSettings.getState().applyPolicyChanges();
expect(applied).toHaveLength(2);
expect(useSettings.getState().settings.conversationMode).toBe(false);
expect(useSettings.getState().settings.showAvatars).toBe(false);
});
it("does nothing, and says so, when there are none", () => {
expect(useSettings.getState().applyPolicyChanges()).toEqual([]);
});
it("cannot undo an enforced setting, which outranks it", () => {
resetSettingsPolicyForTest({
enforced: { conversationMode: true } as never,
changes: [change("A", { conversationMode: false })],
});
useSettings.getState().applyPolicyChanges();
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("drops a change whose settings this build does not have, rather than recording it", () => {
// Recording it as applied would mean it never runs on the ihasmail that
// does have the setting.
resetSettingsPolicyForTest({ changes: [change("A", { notARealSetting: true })] });
expect(useSettings.getState().applyPolicyChanges()).toEqual([]);
expect(useSettings.getState().settings.appliedPolicyChanges).toEqual([]);
});
});
+53 -1
View File
@@ -4,7 +4,7 @@ import { hasCachedJson, loadJson, saveJson } from "@/lib/storage";
import { effectiveMode, legacyTheme, migrateTheme, type Mode, type PaletteId } from "@/lib/palette";
import type { SortLevel, SortPreset } from "@/lib/listSort";
import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync";
import { policyDefaults, policyEnforced } from "@/lib/settingsPolicy";
import { policyChanges, policyDefaults, policyEnforced, type PolicyChange } from "@/lib/settingsPolicy";
import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
import type { SwipeAction } from "@/lib/swipe";
import { resolveUiLanguage } from "@/lib/languages";
@@ -228,6 +228,24 @@ export interface Settings {
* id belonging to another account simply never matches.
*/
hiddenIdentities: string[];
/**
* Installation policy changes this account has already had applied.
*
* The third power in #207: an admin turns a setting on for everybody who is
* already here, and readers may still turn it back off afterwards. That only
* works if "already applied" is remembered, or the next sign-in would undo
* their decision again and the setting would be enforcement wearing a
* different hat.
*
* Ids, not a high-water mark. The reporter's analogy is a schema migration,
* where each change carries its own version, and remembering the set rather
* than the maximum is what lets an admin add a change dated earlier than one
* already applied without it being silently skipped.
*
* Synced with the rest, so it is per account and not per browser: signing in
* on a phone must not apply everything a second time.
*/
appliedPolicyChanges: string[];
}
export const DEFAULT_SETTINGS: Settings = {
@@ -314,6 +332,7 @@ export const DEFAULT_SETTINGS: Settings = {
],
defaultIdentityByAccount: {},
hiddenIdentities: [],
appliedPolicyChanges: [],
};
/**
@@ -411,6 +430,14 @@ interface SettingsState {
* settings would be overwriting choices rather than defaulting them.
*/
seedFromPolicy(): void;
/**
* Apply the installation's change list, each entry once.
*
* Returns the changes that were applied, so the caller can say what moved --
* a setting changing under somebody without a word is the part of this the
* reporter was uneasy about, and rightly.
*/
applyPolicyChanges(): PolicyChange[];
}
const initialSettings = loadJson<Settings>("settings", DEFAULT_SETTINGS);
@@ -463,6 +490,31 @@ export const useSettings = create<SettingsState>((set, get) => ({
if (!Object.keys(defaults).length) return;
get().update(defaults);
},
/*
* The third power in #207, and the only one that remembers anything.
*
* A change is applied when this account has not already had it, whatever the
* setting currently says: the point is to reach everybody who is already
* here, so somebody who had turned it off before the admin decided does get
* it turned back on. That is intended and the reporter has confirmed it --
* the difference from `enforced` is that they may turn it off again
* afterwards and it will stay off, because the version is remembered.
*
* Ids rather than a high-water mark, so a change dated earlier than one
* already applied is not silently skipped.
*
* One `update` for the lot, not one per change: each would push a settings
* file, and a policy with four changes on a first sign-in would write four.
*/
applyPolicyChanges() {
const seen = new Set(get().settings.appliedPolicyChanges ?? []);
const pending = policyChanges().filter((c) => !seen.has(c.version));
if (!pending.length) return [];
let patch: Partial<Settings> = {};
for (const c of pending) patch = { ...patch, ...c.settings };
get().update({ ...patch, appliedPolicyChanges: [...seen, ...pending.map((c) => c.version)] });
return pending;
},
reset() {
/* Back to how this installation starts an account, not to how ihasmail
starts one: resetting must not be a way around a policy, and the defaults