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
+40
View File
@@ -117,6 +117,46 @@ nowhere to live across a restart. Removing it means moving the session upstream
into a token Stalwart itself issues and can revoke, which is what the OAuth work into a token Stalwart itself issues and can revoke, which is what the OAuth work
in [ROADMAP.md](ROADMAP.md) is for. in [ROADMAP.md](ROADMAP.md) is for.
### Settings the installation decides
A deployment can seed and lock user settings, which is what a school wanting
"warn about outside senders" on for three thousand pupils needs — asking three
thousand pupils is not a plan.
```bash
-e SETTINGS_DEFAULTS='{"externalSenderBanner":true}' \
-e SETTINGS_ENFORCED='{"externalRecipientConfirm":true}'
```
Or, where mounting a file is easier than quoting JSON in a unit file:
```bash
-e SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json
```
```json
{
"defaults": { "externalSenderBanner": true },
"enforced": { "externalRecipientConfirm": 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.
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.
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.
## Architecture ## Architecture
``` ```
+3
View File
@@ -175,6 +175,9 @@ export function createApp(basePath = config.basePath): Hono<Env> {
sourceUrl: config.sourceUrl, sourceUrl: config.sourceUrl,
imageProxy: config.imageProxy, imageProxy: config.imageProxy,
maxUploadBytes: config.maxUploadBytes, maxUploadBytes: config.maxUploadBytes,
/* Sent before sign-in like the rest of this: it says what the
installation has decided, not anything about who is asking. */
settingsPolicy: config.settingsPolicy,
}), }),
); );
+47
View File
@@ -111,9 +111,56 @@ export function assertImmutable(sessionFile: string, root: string): void {
if (immutable) assertImmutable(sessionFile, fileURLToPath(new URL("../..", import.meta.url))); if (immutable) assertImmutable(sessionFile, fileURLToPath(new URL("../..", import.meta.url)));
/**
* Settings an installation decides, rather than each reader.
*
* A school turning on "warn about outside senders" for three thousand pupils
* cannot ask three thousand pupils to turn it on -- issue #207. Two sections,
* which are two different powers:
*
* - `defaults` seed an account that has never had settings of its own. The
* reader can change any of them afterwards; they are a starting point, not a
* rule.
* - `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.
*
* 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> } {
const parse = (raw: string, where: string): Record<string, unknown> => {
try {
const v = JSON.parse(raw) as unknown;
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not a JSON object");
return v as Record<string, unknown>;
} catch (err) {
/* Loud, and fatal. A policy that silently did not apply would look like
the feature not working, and the admin would have no way to tell. */
throw new Error(`Invalid ${where}: ${(err as Error).message}`);
}
};
const file = process.env.SETTINGS_POLICY_FILE;
if (file) {
if (!existsSync(file)) throw new Error(`SETTINGS_POLICY_FILE does not exist: ${file}`);
const whole = parse(readFileSync(file, "utf8"), `SETTINGS_POLICY_FILE (${file})`);
return {
defaults: (whole.defaults as Record<string, unknown>) ?? {},
enforced: (whole.enforced as Record<string, unknown>) ?? {},
};
}
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") : {},
};
}
export const config = { export const config = {
isProd, isProd,
appName: env("APP_NAME", "ihasmail"), appName: env("APP_NAME", "ihasmail"),
settingsPolicy: readSettingsPolicy(),
/** /**
* What this build calls itself: `2.16.57`. Set by the image build from * What this build calls itself: `2.16.57`. Set by the image build from
* `--build-arg IHASMAIL_VERSION`, since `.dockerignore` keeps `.git` out of * `--build-arg IHASMAIL_VERSION`, since `.dockerignore` keeps `.git` out of
+9
View File
@@ -19,6 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock";
import { setUnreadBadge } from "@/lib/notify"; import { setUnreadBadge } from "@/lib/notify";
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings"; import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync"; import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable"; import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
import { useLanguageVersion, whenLanguageReady } from "@/lib/i18n"; import { useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges"; import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
@@ -145,9 +146,17 @@ function AuthedApp() {
} }
let cancelled = false; let cancelled = false;
void (async () => { 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(); const remote = await loadRemoteSettings();
if (cancelled) return; if (cancelled) return;
if (remote) useSettings.getState().hydrate(remote); 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 // The catalogue for whatever language that turned out to be. Hydrating
// asks for it; this is waiting for the answer. // asks for it; this is waiting for the answer.
await whenLanguageReady(); await whenLanguageReady();
+82
View File
@@ -0,0 +1,82 @@
import { withBase } from "@/lib/basePath";
import { DEFAULT_SETTINGS, type Settings } from "@/store/settings";
/**
* What the installation has decided about settings, rather than the reader.
*
* Two powers, from #207. `defaults` seed an account that has never had settings
* of its own and can be changed afterwards like anything else. `enforced` are
* applied on every load and cannot be changed here at all -- their controls stay
* visible and go dead, which is what the issue asked for: hiding them confuses
* somebody who has used ihasmail somewhere without a policy.
*
* Fetched once. `/api/config` is unauthenticated and already fetched by the
* sign-in page, so this costs nothing on a cold load and is available before
* anybody's settings are read.
*/
export interface SettingsPolicy {
defaults: Partial<Settings>;
enforced: Partial<Settings>;
}
const EMPTY: SettingsPolicy = { defaults: {}, enforced: {} };
let policy: SettingsPolicy = EMPTY;
let fetched: Promise<SettingsPolicy> | null = null;
/**
* Keys the installation names that this build does not have.
*
* A policy written against a newer ihasmail, or with a typo in it, must not
* introduce a setting that nothing reads: `update` would carry it around and
* `syncedPart` would push it to the reader's settings file for ever. Anything
* not in `DEFAULT_SETTINGS` is dropped, which is the same rule `importJson`
* already applies to a settings file somebody hands us.
*/
function known(obj: Record<string, unknown>): Partial<Settings> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) if (k in DEFAULT_SETTINGS) out[k] = v;
return out as Partial<Settings>;
}
export async function loadSettingsPolicy(): Promise<SettingsPolicy> {
if (fetched) return fetched;
fetched = (async () => {
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> } };
policy = {
defaults: known(body.settingsPolicy?.defaults ?? {}),
enforced: known(body.settingsPolicy?.enforced ?? {}),
};
return policy;
} catch {
/* No policy is the ordinary case and an unreachable one must not stop a
sign-in: an installation that sets nothing looks exactly like this. */
return EMPTY;
}
})();
return fetched;
}
/** What the installation has settled, for a reader who has none of their own. */
export function policyDefaults(): Partial<Settings> {
return policy.defaults;
}
/** What the installation has settled that a reader may not change. */
export function policyEnforced(): Partial<Settings> {
return policy.enforced;
}
/** Whether this setting belongs to the administrator rather than the reader. */
export function isEnforced(key: keyof Settings): boolean {
return key in policy.enforced;
}
/** 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>) };
fetched = Promise.resolve(policy);
}
+1
View File
@@ -55,6 +55,7 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Set for everyone here. You cannot change this.": "Für alle hier festgelegt. Sie können dies nicht ändern.",
"Export iCAL file": "iCAL-Datei exportieren", "Export iCAL file": "iCAL-Datei exportieren",
"Could not export this calendar: {error}": "Dieser Kalender konnte nicht exportiert werden: {error}", "Could not export this calendar: {error}": "Dieser Kalender konnte nicht exportiert werden: {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
+1
View File
@@ -47,6 +47,7 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Set for everyone here. You cannot change this.": "Definido para todos aquí. No puedes cambiarlo.",
"Export iCAL file": "Exportar archivo iCAL", "Export iCAL file": "Exportar archivo iCAL",
"Could not export this calendar: {error}": "No se pudo exportar este calendario: {error}", "Could not export this calendar: {error}": "No se pudo exportar este calendario: {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
+1
View File
@@ -52,6 +52,7 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Set for everyone here. You cannot change this.": "Défini pour tout le monde ici. Vous ne pouvez pas le modifier.",
"Export iCAL file": "Exporter un fichier iCAL", "Export iCAL file": "Exporter un fichier iCAL",
"Could not export this calendar: {error}": "Impossible dexporter ce calendrier : {error}", "Could not export this calendar: {error}": "Impossible dexporter ce calendrier : {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
+1
View File
@@ -46,6 +46,7 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Set for everyone here. You cannot change this.": "この環境全体で設定されています。変更できません。",
"Export iCAL file": "iCAL ファイルをエクスポート", "Export iCAL file": "iCAL ファイルをエクスポート",
"Could not export this calendar: {error}": "このカレンダーをエクスポートできませんでした: {error}", "Could not export this calendar: {error}": "このカレンダーをエクスポートできませんでした: {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
+1
View File
@@ -43,6 +43,7 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Set for everyone here. You cannot change this.": "Hier voor iedereen ingesteld. U kunt dit niet wijzigen.",
"Export iCAL file": "iCAL-bestand exporteren", "Export iCAL file": "iCAL-bestand exporteren",
"Could not export this calendar: {error}": "Kon deze agenda niet exporteren: {error}", "Could not export this calendar: {error}": "Kon deze agenda niet exporteren: {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
+1
View File
@@ -50,6 +50,7 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Set for everyone here. You cannot change this.": "Definido para todos aqui. Você não pode alterar isto.",
"Export iCAL file": "Exportar arquivo iCAL", "Export iCAL file": "Exportar arquivo iCAL",
"Could not export this calendar: {error}": "Não foi possível exportar esta agenda: {error}", "Could not export this calendar: {error}": "Não foi possível exportar esta agenda: {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
+1
View File
@@ -49,6 +49,7 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Set for everyone here. You cannot change this.": "Задано для всех здесь. Изменить нельзя.",
"Export iCAL file": "Экспортировать файл iCAL", "Export iCAL file": "Экспортировать файл iCAL",
"Could not export this calendar: {error}": "Не удалось экспортировать этот календарь: {error}", "Could not export this calendar: {error}": "Не удалось экспортировать этот календарь: {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
+1
View File
@@ -43,6 +43,7 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Set for everyone here. You cannot change this.": "Задано для всіх тут. Змінити не можна.",
"Export iCAL file": "Експортувати файл iCAL", "Export iCAL file": "Експортувати файл iCAL",
"Could not export this calendar: {error}": "Не вдалося експортувати цей календар: {error}", "Could not export this calendar: {error}": "Не вдалося експортувати цей календар: {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
+1
View File
@@ -45,6 +45,7 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Set for everyone here. You cannot change this.": "已为此处所有人设定,您无法更改。",
"Export iCAL file": "导出 iCAL 文件", "Export iCAL file": "导出 iCAL 文件",
"Could not export this calendar: {error}": "无法导出此日历:{error}", "Could not export this calendar: {error}": "无法导出此日历:{error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
import { isEnforced, policyDefaults, policyEnforced, resetSettingsPolicyForTest } from "@/lib/settingsPolicy";
/*
* Settings an installation decides, from #207.
*
* A school turning on "warn about outside senders" for three thousand pupils
* cannot ask three thousand pupils. Two powers, and the difference between them
* is the whole point: defaults are a starting point the reader may change,
* enforced settings are not.
*/
vi.mock("@/lib/settingsSync", () => ({
queueSettingsPush: vi.fn(),
pendingSettingsKeys: () => new Set<string>(),
}));
beforeEach(() => {
resetSettingsPolicyForTest();
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
});
afterEach(() => {
resetSettingsPolicyForTest();
vi.restoreAllMocks();
});
describe("what the installation has decided", () => {
it("keeps to the settings this build actually has", () => {
/*
* A policy written against a newer ihasmail, or with a typo in it, must not
* introduce a key nothing reads: it would be carried around and pushed to
* the reader's settings file for ever. Same rule an imported settings file
* already gets.
*/
resetSettingsPolicyForTest({
defaults: { conversationMode: false, notARealSetting: true } as never,
enforced: { alsoNotReal: 1 } as never,
});
expect(policyDefaults()).toEqual({ conversationMode: false });
expect(policyEnforced()).toEqual({});
});
it("says which settings belong to the administrator", () => {
// The setting the issue was actually about: the outside-sender banner.
resetSettingsPolicyForTest({ defaults: {}, enforced: { externalSenderBanner: true } as never });
expect(isEnforced("externalSenderBanner")).toBe(true);
expect(isEnforced("conversationMode")).toBe(false);
});
});
describe("defaults, for an account that has none of its own", () => {
it("seeds them", () => {
resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} });
useSettings.getState().seedFromPolicy();
expect(useSettings.getState().settings.conversationMode).toBe(false);
});
it("leaves everything it does not name alone", () => {
resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} });
useSettings.getState().seedFromPolicy();
expect(useSettings.getState().settings.showAvatars).toBe(DEFAULT_SETTINGS.showAvatars);
});
it("can still be changed afterwards, being a starting point and not a rule", () => {
resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} });
useSettings.getState().seedFromPolicy();
useSettings.getState().update({ conversationMode: true });
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("does nothing at all when the installation has set none", () => {
const before = useSettings.getState().settings;
useSettings.getState().seedFromPolicy();
expect(useSettings.getState().settings).toBe(before);
});
});
describe("enforced settings, which the reader may not change", () => {
beforeEach(() => {
resetSettingsPolicyForTest({ defaults: {}, enforced: { conversationMode: true } as never });
});
it("survives an update that tries to change it", () => {
useSettings.getState().update({ conversationMode: false });
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("does not stop the rest of that same update", () => {
// The one key is refused; the others are the reader's business.
useSettings.getState().update({ conversationMode: false, showAvatars: false });
expect(useSettings.getState().settings.conversationMode).toBe(true);
expect(useSettings.getState().settings.showAvatars).toBe(false);
});
it("survives a settings file arriving from another device", () => {
// An older sign-in wrote past the policy before it existed. Hydrating must
// not put that back.
useSettings.getState().hydrate({ conversationMode: false, showAvatars: false });
expect(useSettings.getState().settings.conversationMode).toBe(true);
expect(useSettings.getState().settings.showAvatars).toBe(false);
});
it("survives a reset", () => {
// Resetting must not be the way around a policy.
useSettings.getState().reset();
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("survives an imported settings file", () => {
useSettings.getState().importJson(JSON.stringify({ conversationMode: false }));
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
});
describe("reset, where the installation has chosen defaults", () => {
it("goes back to the installation's answer rather than to ihasmail's", () => {
resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} });
useSettings.getState().update({ conversationMode: true });
useSettings.getState().reset();
expect(useSettings.getState().settings.conversationMode).toBe(false);
});
});
+36 -8
View File
@@ -4,6 +4,7 @@ import { hasCachedJson, loadJson, saveJson } from "@/lib/storage";
import { effectiveMode, legacyTheme, migrateTheme, type Mode, type PaletteId } from "@/lib/palette"; import { effectiveMode, legacyTheme, migrateTheme, type Mode, type PaletteId } from "@/lib/palette";
import type { SortLevel, SortPreset } from "@/lib/listSort"; import type { SortLevel, SortPreset } from "@/lib/listSort";
import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync"; import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync";
import { policyDefaults, policyEnforced } from "@/lib/settingsPolicy";
import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime"; import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
import type { SwipeAction } from "@/lib/swipe"; import type { SwipeAction } from "@/lib/swipe";
import { resolveUiLanguage } from "@/lib/languages"; import { resolveUiLanguage } from "@/lib/languages";
@@ -402,6 +403,14 @@ interface SettingsState {
importJson(json: string): boolean; importJson(json: string): boolean;
/** Apply the account's settings file over the cached ones. */ /** Apply the account's settings file over the cached ones. */
hydrate(remote: Record<string, unknown>): void; hydrate(remote: Record<string, unknown>): void;
/**
* Seed an account that has never had settings of its own.
*
* Only for that case, which is why it is not `update`: these are a starting
* point the reader may change, so applying them to somebody who already has
* settings would be overwriting choices rather than defaulting them.
*/
seedFromPolicy(): void;
} }
const initialSettings = loadJson<Settings>("settings", DEFAULT_SETTINGS); const initialSettings = loadJson<Settings>("settings", DEFAULT_SETTINGS);
@@ -428,7 +437,14 @@ export const useSettings = create<SettingsState>((set, get) => ({
* change the theme cannot forget to update it and strand an older device * change the theme cannot forget to update it and strand an older device
* on a theme nobody picked. * on a theme nobody picked.
*/ */
const merged = { ...get().settings, ...patch }; /*
* Enforcement lives here rather than only on the controls. The controls are
* disabled and say why, which is the part a reader sees -- but a setting
* the installation has decided must not be changeable through an imported
* settings file, a keyboard shortcut, or a control somebody adds later and
* forgets to check. There is one door, so the lock is on it. Issue #207.
*/
const merged = { ...get().settings, ...patch, ...policyEnforced() };
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches); const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
const settings = { ...merged, theme: legacyTheme({ palette: merged.palette, mode: merged.mode }, prefersDark) }; const settings = { ...merged, theme: legacyTheme({ palette: merged.palette, mode: merged.mode }, prefersDark) };
saveJson("settings", settings); saveJson("settings", settings);
@@ -442,13 +458,22 @@ export const useSettings = create<SettingsState>((set, get) => ({
queueSettingsPush(syncedPart(settings)); queueSettingsPush(syncedPart(settings));
} }
}, },
seedFromPolicy() {
const defaults = policyDefaults();
if (!Object.keys(defaults).length) return;
get().update(defaults);
},
reset() { reset() {
saveJson("settings", DEFAULT_SETTINGS); /* Back to how this installation starts an account, not to how ihasmail
set({ settings: DEFAULT_SETTINGS }); starts one: resetting must not be a way around a policy, and the defaults
applyTheme(DEFAULT_SETTINGS); an admin chose are the honest meaning of "reset" where there are any. */
applyDateTimePrefs(DEFAULT_SETTINGS); const base = { ...DEFAULT_SETTINGS, ...policyDefaults(), ...policyEnforced() };
applyLang(DEFAULT_SETTINGS); saveJson("settings", base);
queueSettingsPush(syncedPart(DEFAULT_SETTINGS)); set({ settings: base });
applyTheme(base);
applyDateTimePrefs(base);
applyLang(base);
queueSettingsPush(syncedPart(base));
}, },
exportJson() { exportJson() {
return JSON.stringify(get().settings, null, 2); return JSON.stringify(get().settings, null, 2);
@@ -463,7 +488,10 @@ export const useSettings = create<SettingsState>((set, get) => ({
} }
}, },
hydrate(remote) { hydrate(remote) {
const settings = mergeRemote(get().settings, remote, pendingSettingsKeys()); /* Enforced values win over what the account's own file says: a policy that
an older sign-in has already written past would otherwise stay written
past for ever. */
const settings = { ...mergeRemote(get().settings, remote, pendingSettingsKeys()), ...policyEnforced() };
// Cache it, so the next first frame on this browser is already right. // Cache it, so the next first frame on this browser is already right.
saveJson("settings", settings); saveJson("settings", settings);
set({ settings }); set({ settings });
+6 -2
View File
@@ -20,9 +20,9 @@ export function Avatar({ who, size, className }: { who: EmailAddress | { name?:
); );
} }
export function Switch({ checked, onChange, label, hint, disabled }: { checked: boolean; onChange: (v: boolean) => void; label?: ReactNode; hint?: ReactNode; disabled?: boolean }) { export function Switch({ checked, onChange, label, hint, disabled, locked }: { checked: boolean; onChange: (v: boolean) => void; label?: ReactNode; hint?: ReactNode; disabled?: boolean; locked?: boolean }) {
const sw = ( const sw = (
<button type="button" role="switch" aria-checked={checked} className="switch" onClick={() => !disabled && onChange(!checked)} disabled={disabled} /> <button type="button" role="switch" aria-checked={checked} className="switch" onClick={() => !disabled && !locked && onChange(!checked)} disabled={disabled || locked} />
); );
if (!label) return sw; if (!label) return sw;
return ( return (
@@ -30,6 +30,10 @@ export function Switch({ checked, onChange, label, hint, disabled }: { checked:
<div className="switch-text"> <div className="switch-text">
<span>{label}</span> <span>{label}</span>
{hint && <span className="hint">{hint}</span>} {hint && <span className="hint">{hint}</span>}
{/* Shown rather than hidden, and said rather than implied: a control
that is simply missing reads as a bug to somebody who has used
ihasmail without a policy. Issue #207. */}
{locked && <span className="hint">{t("Set for everyone here. You cannot change this.")}</span>}
</div> </div>
{sw} {sw}
</div> </div>
@@ -4,6 +4,7 @@ import { Switch, useIsTouch } from "@/ui/misc";
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe"; import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
import { TRANSLATION_ISSUE_URL, UI_LANGUAGES } from "@/lib/languages"; import { TRANSLATION_ISSUE_URL, UI_LANGUAGES } from "@/lib/languages";
import { t as translate, tNode } from "@/lib/i18n"; import { t as translate, tNode } from "@/lib/i18n";
import { isEnforced } from "@/lib/settingsPolicy";
/** /**
* A swatch for each palette, drawn from the colours that palette actually * A swatch for each palette, drawn from the colours that palette actually
@@ -99,7 +100,7 @@ export function AppearanceSettings() {
<div className="field-row"> <div className="field-row">
<div className="field"> <div className="field">
<label>{translate("Display density")}</label> <label>{translate("Display density")}</label>
<select className="select" value={s.density} onChange={(e) => update({ density: e.target.value as typeof s.density })}> <select disabled={isEnforced("density")} className="select" value={s.density} onChange={(e) => update({ density: e.target.value as typeof s.density })}>
<option value="comfortable">{translate("Comfortable")}</option> <option value="comfortable">{translate("Comfortable")}</option>
<option value="cozy">{translate("Cozy (default)")}</option> <option value="cozy">{translate("Cozy (default)")}</option>
<option value="compact">{translate("Compact")}</option> <option value="compact">{translate("Compact")}</option>
@@ -107,7 +108,7 @@ export function AppearanceSettings() {
</div> </div>
<div className="field"> <div className="field">
<label>{translate("Text size")}</label> <label>{translate("Text size")}</label>
<select className="select" value={s.fontSize} onChange={(e) => update({ fontSize: e.target.value as typeof s.fontSize })}> <select disabled={isEnforced("fontSize")} className="select" value={s.fontSize} onChange={(e) => update({ fontSize: e.target.value as typeof s.fontSize })}>
<option value="small">{translate("Small")}</option> <option value="small">{translate("Small")}</option>
<option value="medium">{translate("Medium")}</option> <option value="medium">{translate("Medium")}</option>
<option value="large">{translate("Large")}</option> <option value="large">{translate("Large")}</option>
@@ -192,9 +193,9 @@ export function AppearanceSettings() {
</p> </p>
<h2>{translate("Sidebar")}</h2> <h2>{translate("Sidebar")}</h2>
<Switch checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label={translate("Show labels in the sidebar")} /> <Switch locked={isEnforced("labelsSidebar")} checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label={translate("Show labels in the sidebar")} />
<Switch checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label={translate("Show unsubscribed (hidden) folders")} /> <Switch locked={isEnforced("showHiddenFolders")} checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label={translate("Show unsubscribed (hidden) folders")} />
<Switch checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label={translate("Collapse sidebar to icons")} /> <Switch locked={isEnforced("sidebarCollapsed")} checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label={translate("Collapse sidebar to icons")} />
</div> </div>
); );
} }
+7 -6
View File
@@ -3,6 +3,7 @@ import { ColorSwatches, CALENDAR_COLORS, Switch } from "@/ui/misc";
import { promptDialog } from "@/ui/dialog"; import { promptDialog } from "@/ui/dialog";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { isEnforced } from "@/lib/settingsPolicy";
export function CalendarSettings() { export function CalendarSettings() {
const s = useSettings((st) => st.settings); const s = useSettings((st) => st.settings);
@@ -14,7 +15,7 @@ export function CalendarSettings() {
<div className="field-row"> <div className="field-row">
<div className="field"> <div className="field">
<label>{t("Default view")}</label> <label>{t("Default view")}</label>
<select className="select" value={s.calendarDefaultView} onChange={(e) => update({ calendarDefaultView: e.target.value as typeof s.calendarDefaultView })}> <select disabled={isEnforced("calendarDefaultView")} className="select" value={s.calendarDefaultView} onChange={(e) => update({ calendarDefaultView: e.target.value as typeof s.calendarDefaultView })}>
<option value="day">{t("Day")}</option> <option value="day">{t("Day")}</option>
<option value="week">{t("Week")}</option> <option value="week">{t("Week")}</option>
<option value="month">{t("Month")}</option> <option value="month">{t("Month")}</option>
@@ -23,7 +24,7 @@ export function CalendarSettings() {
</div> </div>
<div className="field"> <div className="field">
<label>{t("Default event length")}</label> <label>{t("Default event length")}</label>
<select className="select" value={String(s.defaultEventDuration)} onChange={(e) => update({ defaultEventDuration: Number(e.target.value) })}> <select disabled={isEnforced("defaultEventDuration")} className="select" value={String(s.defaultEventDuration)} onChange={(e) => update({ defaultEventDuration: Number(e.target.value) })}>
<option value="15">{t("15 minutes")}</option> <option value="15">{t("15 minutes")}</option>
<option value="30">{t("30 minutes")}</option> <option value="30">{t("30 minutes")}</option>
<option value="45">{t("45 minutes")}</option> <option value="45">{t("45 minutes")}</option>
@@ -34,7 +35,7 @@ export function CalendarSettings() {
</div> </div>
<div className="field"> <div className="field">
<label>{t("Default reminder")}</label> <label>{t("Default reminder")}</label>
<select className="select" value={String(s.defaultAlertMinutes)} onChange={(e) => update({ defaultAlertMinutes: Number(e.target.value) })}> <select disabled={isEnforced("defaultAlertMinutes")} className="select" value={String(s.defaultAlertMinutes)} onChange={(e) => update({ defaultAlertMinutes: Number(e.target.value) })}>
<option value="-1">{t("None")}</option> <option value="-1">{t("None")}</option>
<option value="0">{t("At time of event")}</option> <option value="0">{t("At time of event")}</option>
<option value="5">{t("5 minutes before")}</option> <option value="5">{t("5 minutes before")}</option>
@@ -116,19 +117,19 @@ export function CalendarSettings() {
<div className="field-row"> <div className="field-row">
<div className="field"> <div className="field">
<label>{t("Working hours start")}</label> <label>{t("Working hours start")}</label>
<select className="select" value={String(s.workDayStart)} onChange={(e) => update({ workDayStart: Number(e.target.value) })}> <select disabled={isEnforced("workDayStart")} className="select" value={String(s.workDayStart)} onChange={(e) => update({ workDayStart: Number(e.target.value) })}>
{[...Array(24)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)} {[...Array(24)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
</select> </select>
</div> </div>
<div className="field"> <div className="field">
<label>{t("Working hours end")}</label> <label>{t("Working hours end")}</label>
<select className="select" value={String(s.workDayEnd)} onChange={(e) => update({ workDayEnd: Number(e.target.value) })}> <select disabled={isEnforced("workDayEnd")} className="select" value={String(s.workDayEnd)} onChange={(e) => update({ workDayEnd: Number(e.target.value) })}>
{[...Array(25)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)} {[...Array(25)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
</select> </select>
</div> </div>
<div className="field"> <div className="field">
<label>{t("Week starts on")}</label> <label>{t("Week starts on")}</label>
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}> <select disabled={isEnforced("weekStart")} className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
<option value="1">{t("Monday")}</option> <option value="1">{t("Monday")}</option>
<option value="0">{t("Sunday")}</option> <option value="0">{t("Sunday")}</option>
<option value="6">{t("Saturday")}</option> <option value="6">{t("Saturday")}</option>
+17 -16
View File
@@ -24,6 +24,7 @@ import {
withPrefs, withPrefs,
type DateFormat, type DateFormat,
} from "@/lib/datetime"; } from "@/lib/datetime";
import { isEnforced } from "@/lib/settingsPolicy";
/** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */ /** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */
const SAMPLE = new Date(2025, 10, 22, 18, 23); const SAMPLE = new Date(2025, 10, 22, 18, 23);
@@ -69,7 +70,7 @@ export function GeneralSettings() {
<div className="field-row"> <div className="field-row">
<div className="field"> <div className="field">
<label>{t("Reading pane")}</label> <label>{t("Reading pane")}</label>
<select className="select" value={s.readingPane} onChange={(e) => update({ readingPane: e.target.value as typeof s.readingPane })}> <select disabled={isEnforced("readingPane")} className="select" value={s.readingPane} onChange={(e) => update({ readingPane: e.target.value as typeof s.readingPane })}>
<option value="right">{t("Right of the list")}</option> <option value="right">{t("Right of the list")}</option>
<option value="bottom">{t("Below the list")}</option> <option value="bottom">{t("Below the list")}</option>
<option value="off">{t("Off (open messages full width)")}</option> <option value="off">{t("Off (open messages full width)")}</option>
@@ -77,7 +78,7 @@ export function GeneralSettings() {
</div> </div>
<div className="field"> <div className="field">
<label>{t("Mark as read")}</label> <label>{t("Mark as read")}</label>
<select className="select" value={String(s.markReadDelay)} onChange={(e) => update({ markReadDelay: Number(e.target.value) })}> <select disabled={isEnforced("markReadDelay")} className="select" value={String(s.markReadDelay)} onChange={(e) => update({ markReadDelay: Number(e.target.value) })}>
<option value="0">{t("Immediately when opened")}</option> <option value="0">{t("Immediately when opened")}</option>
<option value="2">{t("After 2 seconds")}</option> <option value="2">{t("After 2 seconds")}</option>
<option value="5">{t("After 5 seconds")}</option> <option value="5">{t("After 5 seconds")}</option>
@@ -86,21 +87,21 @@ export function GeneralSettings() {
</div> </div>
<div className="field"> <div className="field">
<label>{t("After archiving or deleting")}</label> <label>{t("After archiving or deleting")}</label>
<select className="select" value={s.autoAdvance} onChange={(e) => update({ autoAdvance: e.target.value as typeof s.autoAdvance })}> <select disabled={isEnforced("autoAdvance")} className="select" value={s.autoAdvance} onChange={(e) => update({ autoAdvance: e.target.value as typeof s.autoAdvance })}>
<option value="list">{t("Go back to the list")}</option> <option value="list">{t("Go back to the list")}</option>
<option value="older">{t("Open the next (older) conversation")}</option> <option value="older">{t("Open the next (older) conversation")}</option>
<option value="newer">{t("Open the previous (newer) conversation")}</option> <option value="newer">{t("Open the previous (newer) conversation")}</option>
</select> </select>
</div> </div>
</div> </div>
<Switch checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label={t("Conversation view")} hint={t("Group messages from the same thread together.")} /> <Switch locked={isEnforced("conversationMode")} checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label={t("Conversation view")} hint={t("Group messages from the same thread together.")} />
<Switch checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label={t("Show message snippets")} hint={t("Preview the first line of each message in the list.")} /> <Switch locked={isEnforced("showPreview")} checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label={t("Show message snippets")} hint={t("Preview the first line of each message in the list.")} />
<Switch checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label={t("Show sender avatars")} /> <Switch locked={isEnforced("showAvatars")} checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label={t("Show sender avatars")} />
<div className="field-row"> <div className="field-row">
<div className="field"> <div className="field">
<label>{t("Message order")}</label> <label>{t("Message order")}</label>
<select className="select" value={s.listSortPreset} onChange={(e) => update({ listSortPreset: e.target.value as SortPreset })}> <select disabled={isEnforced("listSortPreset")} className="select" value={s.listSortPreset} onChange={(e) => update({ listSortPreset: e.target.value as SortPreset })}>
<option value="newest">{t("Newest first")}</option> <option value="newest">{t("Newest first")}</option>
<option value="oldest">{t("Oldest first")}</option> <option value="oldest">{t("Oldest first")}</option>
<option value="unreadFirst">{t("Unread first")}</option> <option value="unreadFirst">{t("Unread first")}</option>
@@ -113,7 +114,7 @@ export function GeneralSettings() {
</div> </div>
<div className="field"> <div className="field">
<label>{t("Applies to")}</label> <label>{t("Applies to")}</label>
<select className="select" value={s.listSortScope} onChange={(e) => update({ listSortScope: e.target.value as "inbox" | "all" })}> <select disabled={isEnforced("listSortScope")} className="select" value={s.listSortScope} onChange={(e) => update({ listSortScope: e.target.value as "inbox" | "all" })}>
<option value="inbox">{t("The Inbox only")}</option> <option value="inbox">{t("The Inbox only")}</option>
<option value="all">{t("Every folder")}</option> <option value="all">{t("Every folder")}</option>
</select> </select>
@@ -172,15 +173,15 @@ export function GeneralSettings() {
<div className="field-row"> <div className="field-row">
<div className="field"> <div className="field">
<label>{t("Default format")}</label> <label>{t("Default format")}</label>
<select className="select" value={s.composeFormat} onChange={(e) => update({ composeFormat: e.target.value as typeof s.composeFormat })}> <select disabled={isEnforced("composeFormat")} className="select" value={s.composeFormat} onChange={(e) => update({ composeFormat: e.target.value as typeof s.composeFormat })}>
<option value="html">{t("Rich text (HTML)")}</option> <option value="html">{t("Rich text (HTML)")}</option>
<option value="text">{t("Plain text")}</option> <option value="text">{t("Plain text")}</option>
</select> </select>
</div> </div>
</div> </div>
<Switch checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} /> <Switch locked={isEnforced("includeQuote")} checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} />
<Switch checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} /> <Switch locked={isEnforced("signatureAboveQuote")} checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} />
<Switch checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} /> <Switch locked={isEnforced("spellcheck")} checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} />
<h2>{t("Locale")}</h2> <h2>{t("Locale")}</h2>
<div className="field-row"> <div className="field-row">
@@ -193,7 +194,7 @@ export function GeneralSettings() {
</div> </div>
<div className="field"> <div className="field">
<label>{t("Week starts on")}</label> <label>{t("Week starts on")}</label>
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}> <select disabled={isEnforced("weekStart")} className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
<option value="1">{t("Monday")}</option> <option value="1">{t("Monday")}</option>
<option value="0">{t("Sunday")}</option> <option value="0">{t("Sunday")}</option>
<option value="6">{t("Saturday")}</option> <option value="6">{t("Saturday")}</option>
@@ -203,7 +204,7 @@ export function GeneralSettings() {
<div className="field-row"> <div className="field-row">
<div className="field"> <div className="field">
<label>{t("Language & region")}</label> <label>{t("Language & region")}</label>
<select className="select" value={s.locale} onChange={(e) => update({ locale: e.target.value })}> <select disabled={isEnforced("locale")} className="select" value={s.locale} onChange={(e) => update({ locale: e.target.value })}>
<option value="">{t("Automatic ({locale})", { locale: localeLabel(autoLocale) })}</option> <option value="">{t("Automatic ({locale})", { locale: localeLabel(autoLocale) })}</option>
{localeOptions().map((o) => <option key={o.tag} value={o.tag}>{o.label} {o.tag}</option>)} {localeOptions().map((o) => <option key={o.tag} value={o.tag}>{o.label} {o.tag}</option>)}
</select> </select>
@@ -211,7 +212,7 @@ export function GeneralSettings() {
</div> </div>
<div className="field"> <div className="field">
<label>{t("Date format")}</label> <label>{t("Date format")}</label>
<select className="select" value={s.dateFormat} onChange={(e) => update({ dateFormat: e.target.value as DateFormat })}> <select disabled={isEnforced("dateFormat")} className="select" value={s.dateFormat} onChange={(e) => update({ dateFormat: e.target.value as DateFormat })}>
{DATE_FORMATS.map((f) => ( {DATE_FORMATS.map((f) => (
<option key={f.value} value={f.value}> <option key={f.value} value={f.value}>
{t(f.label)} ({withPrefs({ locale: s.locale, dateFormat: f.value }, () => formatDate(SAMPLE))}) {t(f.label)} ({withPrefs({ locale: s.locale, dateFormat: f.value }, () => formatDate(SAMPLE))})
@@ -221,7 +222,7 @@ export function GeneralSettings() {
</div> </div>
<div className="field"> <div className="field">
<label>{t("Time format")}</label> <label>{t("Time format")}</label>
<select className="select" value={s.timeFormat} onChange={(e) => update({ timeFormat: e.target.value as typeof s.timeFormat })}> <select disabled={isEnforced("timeFormat")} className="select" value={s.timeFormat} onChange={(e) => update({ timeFormat: e.target.value as typeof s.timeFormat })}>
<option value="auto">{t("Automatic ({example})", { example: withPrefs({ locale: s.locale, timeFormat: "auto" }, () => formatClock(SAMPLE)) })}</option> <option value="auto">{t("Automatic ({example})", { example: withPrefs({ locale: s.locale, timeFormat: "auto" }, () => formatClock(SAMPLE)) })}</option>
<option value="24">{t("24-hour clock (18:23)")}</option> <option value="24">{t("24-hour clock (18:23)")}</option>
<option value="12">{t("12-hour clock (6:23 PM)")}</option> <option value="12">{t("12-hour clock (6:23 PM)")}</option>
@@ -7,6 +7,7 @@ import { disableWebPush, enableWebPush, webPushActive } from "@/lib/webpushEnabl
import { supportsEmailPush, webPushAvailable } from "@/lib/webpush"; import { supportsEmailPush, webPushAvailable } from "@/lib/webpush";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { isEnforced } from "@/lib/settingsPolicy";
export function NotificationsSettings() { export function NotificationsSettings() {
const s = useSettings((st) => st.settings); const s = useSettings((st) => st.settings);
@@ -76,7 +77,7 @@ export function NotificationsSettings() {
: t("Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.") : t("Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.")
} }
/> />
<Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label={t("Play a sound for new mail")} /> <Switch locked={isEnforced("notificationSound")} checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label={t("Play a sound for new mail")} />
<div className="row mt-16"> <div className="row mt-16">
<button className="btn" onClick={() => { showNotification(t("ihasmail test"), { body: t("This is what a new-mail notification looks like.") }); playNewMailSound(); }}>{t("Test notification")}</button> <button className="btn" onClick={() => { showNotification(t("ihasmail test"), { body: t("This is what a new-mail notification looks like.") }); playNewMailSound(); }}>{t("Test notification")}</button>
</div> </div>
+8 -7
View File
@@ -5,6 +5,7 @@ import { domainOf } from "@/lib/address";
import { Switch } from "@/ui/misc"; import { Switch } from "@/ui/misc";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { isEnforced } from "@/lib/settingsPolicy";
/** /**
* Everything about what reaches a sender, and what asks before it happens. * Everything about what reaches a sender, and what asks before it happens.
@@ -36,7 +37,7 @@ export function PrivacySettings() {
<h2>{t("Remote content")}</h2> <h2>{t("Remote content")}</h2>
<div className="field"> <div className="field">
<label>{t("Remote images")}</label> <label>{t("Remote images")}</label>
<select className="select" value={s.imagePolicy} onChange={(e) => update({ imagePolicy: e.target.value as typeof s.imagePolicy })}> <select disabled={isEnforced("imagePolicy")} className="select" value={s.imagePolicy} onChange={(e) => update({ imagePolicy: e.target.value as typeof s.imagePolicy })}>
<option value="ask">{t("Ask before showing (recommended)")}</option> <option value="ask">{t("Ask before showing (recommended)")}</option>
<option value="contacts">{t("Show automatically from my contacts")}</option> <option value="contacts">{t("Show automatically from my contacts")}</option>
<option value="always">{t("Always show")}</option> <option value="always">{t("Always show")}</option>
@@ -67,10 +68,10 @@ export function PrivacySettings() {
)} )}
<h2>{t("Read receipts")}</h2> <h2>{t("Read receipts")}</h2>
<Switch checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label={t("Always request read receipts")} /> <Switch locked={isEnforced("requestReadReceipt")} checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label={t("Always request read receipts")} />
<div className="field"> <div className="field">
<label>{t("When someone requests a read receipt")}</label> <label>{t("When someone requests a read receipt")}</label>
<select className="select" value={s.readReceiptPolicy} onChange={(e) => update({ readReceiptPolicy: e.target.value as ReadReceiptPolicy })}> <select disabled={isEnforced("readReceiptPolicy")} className="select" value={s.readReceiptPolicy} onChange={(e) => update({ readReceiptPolicy: e.target.value as ReadReceiptPolicy })}>
<option value="ask">{t("Ask me on each message")}</option> <option value="ask">{t("Ask me on each message")}</option>
<option value="never">{t("Never send one")}</option> <option value="never">{t("Never send one")}</option>
</select> </select>
@@ -108,7 +109,7 @@ export function PrivacySettings() {
<div className="field"> <div className="field">
<label>{t("Ask before sending to a large group")}</label> <label>{t("Ask before sending to a large group")}</label>
<select className="select" value={String(s.replyAllThreshold)} onChange={(e) => update({ replyAllThreshold: Number(e.target.value) })}> <select disabled={isEnforced("replyAllThreshold")} className="select" value={String(s.replyAllThreshold)} onChange={(e) => update({ replyAllThreshold: Number(e.target.value) })}>
<option value="0">{t("Never ask")}</option> <option value="0">{t("Never ask")}</option>
<option value="5">{t("5 people or more")}</option> <option value="5">{t("5 people or more")}</option>
<option value="10">{t("10 people or more")}</option> <option value="10">{t("10 people or more")}</option>
@@ -136,7 +137,7 @@ export function PrivacySettings() {
<h2>{t("Before it happens")}</h2> <h2>{t("Before it happens")}</h2>
<div className="field"> <div className="field">
<label>{t("Undo send window")}</label> <label>{t("Undo send window")}</label>
<select className="select" value={String(s.undoSendSeconds)} onChange={(e) => update({ undoSendSeconds: Number(e.target.value) })}> <select disabled={isEnforced("undoSendSeconds")} className="select" value={String(s.undoSendSeconds)} onChange={(e) => update({ undoSendSeconds: Number(e.target.value) })}>
<option value="0">{t("Off")}</option> <option value="0">{t("Off")}</option>
<option value="5">{t("5 seconds")}</option> <option value="5">{t("5 seconds")}</option>
<option value="8">{t("8 seconds")}</option> <option value="8">{t("8 seconds")}</option>
@@ -145,8 +146,8 @@ export function PrivacySettings() {
</select> </select>
<p className="hint">{t("The message is held in this browser and has not been submitted yet, so taking it back costs nothing.")}</p> <p className="hint">{t("The message is held in this browser and has not been submitted yet, so taking it back costs nothing.")}</p>
</div> </div>
<Switch checked={s.attachmentReminder} onChange={(v) => update({ attachmentReminder: v })} label={t("Attachment reminder")} hint={t("Warn when the message mentions an attachment but none is attached.")} /> <Switch locked={isEnforced("attachmentReminder")} checked={s.attachmentReminder} onChange={(v) => update({ attachmentReminder: v })} label={t("Attachment reminder")} hint={t("Warn when the message mentions an attachment but none is attached.")} />
<Switch checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label={t("Confirm before deleting")} /> <Switch locked={isEnforced("confirmDelete")} checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label={t("Confirm before deleting")} />
</div> </div>
); );
} }