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
@@ -4,6 +4,7 @@ import { Switch, useIsTouch } from "@/ui/misc";
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
import { TRANSLATION_ISSUE_URL, UI_LANGUAGES } from "@/lib/languages";
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
@@ -99,7 +100,7 @@ export function AppearanceSettings() {
<div className="field-row">
<div className="field">
<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="cozy">{translate("Cozy (default)")}</option>
<option value="compact">{translate("Compact")}</option>
@@ -107,7 +108,7 @@ export function AppearanceSettings() {
</div>
<div className="field">
<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="medium">{translate("Medium")}</option>
<option value="large">{translate("Large")}</option>
@@ -192,9 +193,9 @@ export function AppearanceSettings() {
</p>
<h2>{translate("Sidebar")}</h2>
<Switch 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 checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label={translate("Collapse sidebar to icons")} />
<Switch locked={isEnforced("labelsSidebar")} checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label={translate("Show labels in the sidebar")} />
<Switch locked={isEnforced("showHiddenFolders")} checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label={translate("Show unsubscribed (hidden) folders")} />
<Switch locked={isEnforced("sidebarCollapsed")} checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label={translate("Collapse sidebar to icons")} />
</div>
);
}
+7 -6
View File
@@ -3,6 +3,7 @@ import { ColorSwatches, CALENDAR_COLORS, Switch } from "@/ui/misc";
import { promptDialog } from "@/ui/dialog";
import { Plus, Trash2 } from "lucide-react";
import { t } from "@/lib/i18n";
import { isEnforced } from "@/lib/settingsPolicy";
export function CalendarSettings() {
const s = useSettings((st) => st.settings);
@@ -14,7 +15,7 @@ export function CalendarSettings() {
<div className="field-row">
<div className="field">
<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="week">{t("Week")}</option>
<option value="month">{t("Month")}</option>
@@ -23,7 +24,7 @@ export function CalendarSettings() {
</div>
<div className="field">
<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="30">{t("30 minutes")}</option>
<option value="45">{t("45 minutes")}</option>
@@ -34,7 +35,7 @@ export function CalendarSettings() {
</div>
<div className="field">
<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="0">{t("At time of event")}</option>
<option value="5">{t("5 minutes before")}</option>
@@ -116,19 +117,19 @@ export function CalendarSettings() {
<div className="field-row">
<div className="field">
<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>)}
</select>
</div>
<div className="field">
<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>)}
</select>
</div>
<div className="field">
<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="0">{t("Sunday")}</option>
<option value="6">{t("Saturday")}</option>
+17 -16
View File
@@ -24,6 +24,7 @@ import {
withPrefs,
type DateFormat,
} from "@/lib/datetime";
import { isEnforced } from "@/lib/settingsPolicy";
/** Illustrative instant used for the format previews: 22 Nov 2025, 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">
<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="bottom">{t("Below the list")}</option>
<option value="off">{t("Off (open messages full width)")}</option>
@@ -77,7 +78,7 @@ export function GeneralSettings() {
</div>
<div className="field">
<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="2">{t("After 2 seconds")}</option>
<option value="5">{t("After 5 seconds")}</option>
@@ -86,21 +87,21 @@ export function GeneralSettings() {
</div>
<div className="field">
<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="older">{t("Open the next (older) conversation")}</option>
<option value="newer">{t("Open the previous (newer) conversation")}</option>
</select>
</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 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("conversationMode")} checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label={t("Conversation view")} hint={t("Group messages from the same thread together.")} />
<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 locked={isEnforced("showAvatars")} checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label={t("Show sender avatars")} />
<div className="field-row">
<div className="field">
<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="oldest">{t("Oldest first")}</option>
<option value="unreadFirst">{t("Unread first")}</option>
@@ -113,7 +114,7 @@ export function GeneralSettings() {
</div>
<div className="field">
<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="all">{t("Every folder")}</option>
</select>
@@ -172,15 +173,15 @@ export function GeneralSettings() {
<div className="field-row">
<div className="field">
<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="text">{t("Plain text")}</option>
</select>
</div>
</div>
<Switch 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 checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} />
<Switch locked={isEnforced("includeQuote")} checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} />
<Switch locked={isEnforced("signatureAboveQuote")} checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} />
<Switch locked={isEnforced("spellcheck")} checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} />
<h2>{t("Locale")}</h2>
<div className="field-row">
@@ -193,7 +194,7 @@ export function GeneralSettings() {
</div>
<div className="field">
<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="0">{t("Sunday")}</option>
<option value="6">{t("Saturday")}</option>
@@ -203,7 +204,7 @@ export function GeneralSettings() {
<div className="field-row">
<div className="field">
<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>
{localeOptions().map((o) => <option key={o.tag} value={o.tag}>{o.label} {o.tag}</option>)}
</select>
@@ -211,7 +212,7 @@ export function GeneralSettings() {
</div>
<div className="field">
<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) => (
<option key={f.value} value={f.value}>
{t(f.label)} ({withPrefs({ locale: s.locale, dateFormat: f.value }, () => formatDate(SAMPLE))})
@@ -221,7 +222,7 @@ export function GeneralSettings() {
</div>
<div className="field">
<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="24">{t("24-hour clock (18:23)")}</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 { toast } from "@/ui/toast";
import { t } from "@/lib/i18n";
import { isEnforced } from "@/lib/settingsPolicy";
export function NotificationsSettings() {
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.")
}
/>
<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">
<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>
+8 -7
View File
@@ -5,6 +5,7 @@ import { domainOf } from "@/lib/address";
import { Switch } from "@/ui/misc";
import { X } from "lucide-react";
import { t } from "@/lib/i18n";
import { isEnforced } from "@/lib/settingsPolicy";
/**
* Everything about what reaches a sender, and what asks before it happens.
@@ -36,7 +37,7 @@ export function PrivacySettings() {
<h2>{t("Remote content")}</h2>
<div className="field">
<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="contacts">{t("Show automatically from my contacts")}</option>
<option value="always">{t("Always show")}</option>
@@ -67,10 +68,10 @@ export function PrivacySettings() {
)}
<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">
<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="never">{t("Never send one")}</option>
</select>
@@ -108,7 +109,7 @@ export function PrivacySettings() {
<div className="field">
<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="5">{t("5 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>
<div className="field">
<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="5">{t("5 seconds")}</option>
<option value="8">{t("8 seconds")}</option>
@@ -145,8 +146,8 @@ export function PrivacySettings() {
</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>
</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 checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label={t("Confirm before deleting")} />
<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 locked={isEnforced("confirmDelete")} checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label={t("Confirm before deleting")} />
</div>
);
}