Merge pull request #232 from Coffey-Labs/docs/policy-example

Ship an example settings policy, and name the variables in .env.example
This commit is contained in:
Coffey Labs
2026-09-02 11:23:15 -07:00
committed by GitHub
4 changed files with 139 additions and 0 deletions
+24
View File
@@ -69,3 +69,27 @@ APP_NAME=ihasmail
# you have patched it, point this at your own tree. Shown on the sign-in page # you have patched it, point this at your own tree. Shown on the sign-in page
# and in Settings > About. # and in Settings > About.
SOURCE_URL=https://github.com/Coffey-Labs/ihasmail 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
+4
View File
@@ -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 Mount it read-only: the server only ever reads it, and `:ro` keeps that true
under `--read-only` as well. under `--read-only` as well.
+55
View File
@@ -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<string, unknown>;
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<string>();
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<string, unknown>;
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}`);
}
});
+56
View File
@@ -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 }
}
]
}