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 } + } + ] +}