From a24b4c553852166b4725c0a52d6287be831247a9 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 2 Sep 2026 11:20:34 -0700 Subject: [PATCH] Ship an example settings policy, and name the variables in .env.example #231 added the policy but nothing to copy. The repo already answers this the same way four times over -- Caddyfile.example, deploy.example.sh, nginx.example.conf, .env.example -- and the new feature was the one thing configurable here with no example beside it. settings-policy.example.json carries all three sections with the reasoning in it, including the part worth being deliberate about: a `changes` entry overrides a decision a reader has already made, and if you want it to stay put regardless that is `enforced` instead. JSON has no comments, so the commentary is in `_`-prefixed keys, which is safe because the server reads three names and ignores everything else. A test asserts the shipped example stays valid against the rules the parser enforces -- unique versions, settings objects, no comment key colliding with a real section. An example that has drifted is worse than none: somebody copies it, the server refuses to start, and the first experience of the feature is a crash loop. .env.example gains the four variables, commented out, with the file form and the inline form and the note that the file wins over the variables. Confirmed against the real image on the deploy host rather than reasoned about: an immutable container -- --read-only, IMMUTABLE=1, SESSION_FILE= empty -- starts and serves the policy both with a read-only file mount and with the environment variables alone. The feature costs nothing in immutability, because the only thing it writes is the applied-changes stamp, and that goes in the reader's own settings file on Stalwart like every other setting. --- .env.example | 24 +++++++++++++ README.md | 4 +++ server/src/settingspolicy.test.ts | 55 ++++++++++++++++++++++++++++++ settings-policy.example.json | 56 +++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 server/src/settingspolicy.test.ts create mode 100644 settings-policy.example.json diff --git a/.env.example b/.env.example index 0c20802..5e2d823 100644 --- a/.env.example +++ b/.env.example @@ -69,3 +69,27 @@ APP_NAME=ihasmail # you have patched it, point this at your own tree. Shown on the sign-in page # and in Settings > About. SOURCE_URL=https://github.com/Coffey-Labs/ihasmail + +# ---- Settings this installation decides (all optional) ---- +# +# Seed what a new account starts on, lock what nobody may change, and turn +# something on once for accounts that already exist. Setting none of these -- +# the default -- behaves exactly as ihasmail always has. +# +# A file is easier once there are `changes` in it. See the shipped +# settings-policy.example.json, and mount it read-only: +# +# -v /srv/ihasmail/policy.json:/etc/ihasmail/policy.json:ro +# +# SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json +# +# Or inline, which is what an immutable deployment with no volume wants. These +# are ignored entirely when SETTINGS_POLICY_FILE is set, so a file and a stray +# variable cannot half-apply between them. +# +# SETTINGS_DEFAULTS={"externalSenderBanner":true} +# SETTINGS_ENFORCED={"externalRecipientConfirm":true} +# SETTINGS_CHANGES=[{"version":"20260902084513","settings":{"externalSenderBanner":true}}] +# +# Read once at startup: editing a policy means restarting the container. +# Docs: https://docs.ihasmail.org/configure/#settings-your-installation-decides diff --git a/README.md b/README.md index 7618949..49fb4dd 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,10 @@ docker run -d --name ihasmail \ } ``` +[`settings-policy.example.json`](settings-policy.example.json) in this repo is +that file with every section explained in it — copy it and delete what you do +not want. + Mount it read-only: the server only ever reads it, and `:ro` keeps that true under `--read-only` as well. diff --git a/server/src/settingspolicy.test.ts b/server/src/settingspolicy.test.ts new file mode 100644 index 0000000..6937b9b --- /dev/null +++ b/server/src/settingspolicy.test.ts @@ -0,0 +1,55 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +/** + * The shipped example policy, checked against the rules the server enforces. + * + * An example that has drifted out of step with the parser is worse than no + * example: somebody copies it, the server refuses to start, and the first + * experience of the feature is a crash loop. This does not import the config + * module -- reading it has side effects and wants a whole environment -- so the + * rules it checks are restated here, and both are short enough that saying them + * twice is cheaper than the machinery to say them once. + */ +const EXAMPLE = fileURLToPath(new URL("../../settings-policy.example.json", import.meta.url)); + +test("the example policy is valid JSON", () => { + assert.doesNotThrow(() => JSON.parse(readFileSync(EXAMPLE, "utf8"))); +}); + +test("the example policy has the three sections, in the shapes the server reads", () => { + const p = JSON.parse(readFileSync(EXAMPLE, "utf8")) as Record; + for (const section of ["defaults", "enforced"]) { + const v = p[section]; + assert.ok(v && typeof v === "object" && !Array.isArray(v), `${section} must be an object`); + } + assert.ok(Array.isArray(p.changes), "changes must be a list"); +}); + +test("every change in the example has a unique version and settings", () => { + const p = JSON.parse(readFileSync(EXAMPLE, "utf8")) as { changes: Array<{ version?: unknown; settings?: unknown }> }; + const seen = new Set(); + for (const [i, c] of p.changes.entries()) { + assert.equal(typeof c.version, "string", `changes[${i}] needs a string version`); + assert.ok((c.version as string).trim(), `changes[${i}] needs a non-empty version`); + assert.ok(!seen.has(c.version as string), `changes[${i}] repeats version ${String(c.version)}`); + seen.add(c.version as string); + assert.ok(c.settings && typeof c.settings === "object" && !Array.isArray(c.settings), `changes[${i}] needs a settings object`); + } +}); + +test("the example's commentary cannot be mistaken for a section", () => { + /* + * JSON has no comments, so the example explains itself in `_`-prefixed keys. + * The server reads three names and ignores everything else, which is what + * makes that safe -- but only for as long as no comment key collides with a + * real one. + */ + const p = JSON.parse(readFileSync(EXAMPLE, "utf8")) as Record; + const real = new Set(["defaults", "enforced", "changes"]); + for (const key of Object.keys(p)) { + assert.ok(real.has(key) || key.startsWith("_"), `unexpected top-level key ${key}`); + } +}); diff --git a/settings-policy.example.json b/settings-policy.example.json new file mode 100644 index 0000000..3f423cc --- /dev/null +++ b/settings-policy.example.json @@ -0,0 +1,56 @@ +{ + "_comment": [ + "A settings policy: what this installation decides, rather than each reader.", + "Point at it with SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json and mount it", + "read-only. Read once at startup, so editing it means restarting.", + "Delete the sections you do not want -- all three are optional, and an", + "installation that sets none of them behaves exactly as ihasmail always has.", + "Keys and values are the ones a settings export uses: configure one account", + "by hand, Settings > General > Export, and copy out what you care about.", + "Docs: https://docs.ihasmail.org/configure/#settings-your-installation-decides" + ], + + "_defaults_comment": [ + "A starting point for accounts that have never had settings of their own.", + "The reader can change any of these afterwards. An account that already", + "exists never sees them -- use `changes` below to reach those." + ], + "defaults": { + "externalSenderBanner": true, + "conversationMode": true + }, + + "_enforced_comment": [ + "Reapplied on every load, and the reader cannot change them at all. Their", + "controls stay visible in Settings and go dead with a line saying why.", + "Reset, an imported settings file, and a settings file synced from a device", + "that predates this policy all cannot get around them." + ], + "enforced": { + "externalRecipientConfirm": true + }, + + "_changes_comment": [ + "Applied once each, to everybody, including accounts that already exist --", + "and the reader may change them back afterwards, which sticks.", + "", + "Each entry needs a `version` that is unique in this file. It is opaque: a", + "timestamp sorts and never repeats, but any unique string works. Every", + "account remembers the versions it has had, so a change runs exactly once", + "per person -- not once per browser.", + "", + "Note that a change DOES override a decision a reader has already made. That", + "is the point of it: it reaches people who are already here. If you want it", + "to stay on regardless of what they do next, that is `enforced`, not this." + ], + "changes": [ + { + "version": "20260902084513", + "settings": { "externalSenderBanner": true } + }, + { + "version": "20261014091500", + "settings": { "externalLinkWarning": true } + } + ] +}