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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user