21. The administration console

Pepsi ships a small browser console, served by pepsi-httpd under /ui. It exists for the things that are painful on the command line: watching the queue, browsing keys and correspondents, reading the audit log, and changing configuration with validation and provenance visible.

It is a client of the documented administrative API (The administrative API) and nothing more. Every page calls the same library functions the /api/v1 endpoints call, through the same authentication, the same scope checks and the same audit log, so anything the console can do can also be done with curl — and the two cannot disagree about what the queue contains or about whether a configuration value is valid.

Note

There are two HTTP APIs in this server and they are not the same. /api/v1 is Pepsi’s own (The administrative API), which this console is a client of: accounts, scopes, sessions and CSRF, gated on ADMIN = yes. /3.0/ and /3.1/ are the GNU Mailman 3 REST API (The GNU Mailman 3 REST API), reimplemented so that Mailman software works against Pepsi: one shared password, no scopes, somebody else’s shapes, gated on LIST_API = yes. An operator will meet both; they share the server and nothing else, and neither is a version of the other.

21.1. Three listener flags, and two of them have opposite advice

pepsi-httpd serves four things and gates three of them, each on its own [pepsi-httpd-listener-*] flag. Getting these the wrong way round is the single easiest way to mis-deploy Pepsi’s HTTP surface, so they are in one table:

Flag

What it serves

Who it is for

Where to put it

ADMIN

/api/v1, /ui, /metrics

The operator. Bearer tokens, password sessions, SO_PEERCRED, scopes, CSRF.

Behind a tunnel. A UNIX socket, or loopback with a reverse proxy. pepsi-httpd refuses to serve it in cleartext off this host.

LIST_API

/3.0/, /3.1/

Mailing-list software (mailmanclient, Postorius, HyperKitty). One shared password, no accounts, no scopes.

Loopback or TLS. Same refusal as ADMIN, for the same reason: a static site-wide credential must not cross a cleartext network.

LISTS

/lists, /archives/list/…, /robots.txt

The anonymous public. No credential at all.

On the open internet. This is the one surface meant to be reachable, so the cleartext restriction is deliberately not applied — but a subscribe form carries somebody’s address, so use the TLS listener.

(none)

MTA-STS, Web Key Directory, mail autoconfiguration, the secure-link portal, POST /resume, and the Outlook add-in (/addin/, switched on by [pepsi-httpd] ADDIN rather than by a listener flag)

Anybody who needs them, by design.

Wherever the public listener is.

Every gated route answers a plain 404 — byte for byte what an unknown path answers — on a listener without its flag. That is why publishing the public HTTPS listener does not publish administration, and why a public listener cannot be probed for what else this server runs.

21.2. Reaching it safely

The console mounts on exactly the listeners the API does: those flagged ADMIN = yes (see pepsi-httpd(1)). On every other listener the /ui paths answer a plain 404, byte for byte what an unknown path answers, so publishing the public listener does not publish the console and a public listener cannot be probed for whether administration is enabled here.

pepsi-httpd refuses to serve it on a flagged listener that would carry it in cleartext off this host: plaintext TCP is accepted only on a loopback address, while TLS listeners and UNIX sockets always qualify. A socket-activated listener (SERVE = systemd) without TLS never qualifies, whatever its unit binds, since the passed descriptor could be anything and treating it as permitted would let the strictest case through the loosest check — give administration a listener of its own. This is not only about eavesdropping — the session cookie carries the __Host- prefix (RFC 6265bis §4.1.3.2) only when it is set Secure, i.e. only when the client’s connection is HTTPS, since a browser refuses a __Host- cookie that is not. On the plaintext loopback bootstrap path neither is used, so the console’s own authentication is weaker by construction on a cleartext origin. A browser cannot open a UNIX socket, so a typical deployment either

  • binds an administrative listener to 127.0.0.1 and reaches it over an SSH tunnel (ssh -L 8443:127.0.0.1:8443 mail.example.com), or

  • gives the administrative listener TLS and a name of its own, and treats it like any other internet-facing administrative surface.

21.3. Signing in

Two of the API’s three mechanisms are useful from a browser:

A local operator over the administrative UNIX socket is identified by SO_PEERCRED and needs no password at all. Browsers cannot speak to a UNIX socket, so this path is for curl and for the first-run bootstrap; it is also why the strict session policy below is affordable.

A remote administrator signs in at /ui/login with an account from pepsi.admin_account (create one with POST /api/v1/accounts). Sessions have a 30-minute idle timeout and a 12-hour hard lifetime regardless of activity, and there is no “remember me”. This console can revoke a key, read who corresponds with whom and rewrite the pipeline; a forgotten browser tab should not still hold that tomorrow, and a stolen laptop should not be a standing administrative session.

Bearer tokens work too, but a browser has no way to attach one; they are for automation against /api/v1.

21.4. What the console can and cannot do

The actions are message-level and key-level only:

  • requeue a stuck message at its current stage, route it to a bounce stage, or delete it without delivering;

  • publish or withdraw a local identity, make one primary, delete it, or request its upload to the key server;

  • ask the applier for a server-managed key, to register a user’s own public key, or to revoke an identity (all three through the privileged applier, below);

  • forget a cached correspondent key, and remove a trust anchor. Importing a key by hand and queueing a discovery lookup are not console actions: they are POST /api/v1/peers and POST /api/v1/peers/discover on the API, or pepsi-keys(1), which the page itself points at;

  • revoke a secure-link message;

  • set or remove one configuration override;

  • answer the setup interview, and ask the privileged applier to run certbot, install the schema, provision roles or check DNS.

There is deliberately no restart, no shutdown and no backup button. A web console that can stop the mail server is one that can be tricked into stopping the mail server, and systemd already owns that job. Restart a component with systemctl restart pepsi-ingress (the configuration pages name the unit when a change needs one).

Everything privileged here has that shape: the console records what should happen and a separate root program does it. The console cannot write /etc, run certbot or create a database role, and must not be able to. See “Setup in the browser” below.

Two further things the console cannot do, because the server cannot:

  • Generate or revoke key material itself. That needs the pepsi-crypto database role, which holds the grant on the private column and which pepsi-httpd deliberately does not hold — a web tier that can mint signing keys is a web tier whose compromise mints signing keys. The identities page’s Generate a server-managed key form therefore only asks: it enqueues a generate-identity task that pepsi-setup apply re-validates and performs as pepsi-crypto. Register my own key (paste an armoured OpenPGP public key) enqueues register-client-key the same way, and an identity’s Revoke button, once confirmed, enqueues revoke-identity — revoking a signing-only key destroys its private half, which is a write to that column. Each answers with the queued task’s own page, /ui/setup/tasks/<id>, which the user who asked may open whatever their scopes, and which shows the outcome – done, failed with the reason, or refused – the moment the applier records it. All three answer 503 when the applier package is not installed. Each form also has a Second-factor code field: a user who enrolled a second factor (Key management) types the current code from their authenticator app there, and the applier checks it – the console cannot. CSRs and issued certificates stay with pepsi-keys(1).

  • Write the configuration overlay, unless [pepsi-admin] CONFIG_DB names a connection that authenticates as the pepsi-config role. Without it the configuration pages are read-only and say why.

The console also never shows a message’s headers or body. The queue pages load the envelope, the stage, the status and the state JSON, and nothing else — structurally, because the query behind them does not select those columns. Reading mail is not an administrative function.

21.5. The pages

Path

Scope

What it shows

/ui

queue:read

Dashboard: queue depth by (stage, status) with the oldest message in each bucket, the messages that stopped and why, lifetime counters, per-stage counters, recent outbound TLS outcomes, failing MX addresses, and — with keys:read — the local identities with expiry warnings.

/ui/queue

queue:read

Filterable listing (by stage and status), paginated, with an opt-in 30-second reload.

/ui/queue/<id>

queue:read

One message: envelope, stage, status and pretty-printed state. The requeue / bounce / delete forms need queue:write.

/ui/identities

keys:read

Local key pairs, filterable by address, with the Generate a server-managed key and Register my own key forms (keys:write or the address’s own own: scope; they post to /ui/identities/request/{generate,register}).

/ui/identities/<id>

keys:read

One identity, with its custody (server-managed or the user’s own key) and key-server state, and publish / withdraw / Upload to the key server / make-primary / revoke (keys:write or the address’s own own: scope; revoking is queued for the applier) and delete (keys:write only).

/ui/peers

keys:read

Cached correspondent keys: source, DNSSEC flag, validity, pin state. Forgetting one needs peers:write, and forgetting is the only action here — the page names pepsi-keys peer import for the rest.

/ui/ca-trust

keys:read

The S/MIME trust anchors; removal needs keys:write.

/ui/config

config:read

Every section of the effective configuration for a scope, with how a change takes effect.

/ui/config/<section>

config:read

One section: each option’s value, where it came from (file, global, domain, address), and the forms to set or remove an override (config:write).

/ui/logs

logs:read

The audit log, filterable by kind and actor.

/ui/logs/mail

logs:read or own:

The opt-in per-message log, filterable by address; says so plainly when [pepsi] MAIL_LOG is off. A principal holding only own:<address> sees that address’s records and nobody else’s, as with GET /api/v1/mail-log.

/ui/logs/tls

logs:read

Outbound TLS session outcomes per policy domain.

/ui/logs/dns

logs:read

MX addresses the resolver cache currently marks as failing.

/ui/domains

config:read

The served domains, the MTA-STS mode, how many identities are published under each, and the DNS records to publish with the last live verdict for each. The “check DNS now” button needs setup:write.

/ui/secure

secure:read

Secure-link messages the portal is holding: correspondents, expiry, reads, wrong PINs, lockouts. Revoking needs secure:write.

/ui/secure/<token>

secure:read

One message with its access history.

/ui/setup

setup:write

The setup interview’s entry point; each step is /ui/setup/<step> (GET renders it, POST stores that step’s answers), resuming where it left off.

/ui/setup/tasks

setup:write, or any principal for its own requests

What has been asked of the privileged applier, and a form to ask for more. Without setup:write, only the requests the user made itself.

/ui/setup/tasks/<id>

setup:write, or any principal for its own requests

One action with the applier’s streamed progress, updated as soon as the applier writes something.

No page loads an unbounded result set: every listing goes through a query carrying its own LIMIT/OFFSET, so that is a property of the SQL rather than of what the renderer draws. The queue and the log views paginate with ?limit=/?offset= (clamped to [pepsi-admin] PAGE_LIMIT, at most 1000). The key-store listings — identities, correspondents, trust anchors — fetch one page’s worth, plus one row to learn whether there is more, and say plainly when they were cut short; narrow them with the address filter, or use pepsi-keys(1). The console asks for a COUNT(*) only where it shows a total (the two log views); the API always does, because its clients page programmatically. See The administrative API.

Every destructive action — deleting a queued message, revoking or deleting an identity, removing a trust anchor, removing a configuration override — goes through an explicit confirmation page first. Every mutation is written to the audit log with the same event kind the API records, so /ui/logs shows changes made from the console, from curl and from the command-line tools alike.

Configuration values that could carry a secret are shown as ***, never as values, by the same rule the API masks with. The list is deliberately generous: a value wrongly masked costs an operator a look in the file, a value wrongly shown is published to everyone holding config:read.

21.5.1. Setup in the browser

/ui/setup renders the same interview pepsi-setup --wizard asks at a terminal — literally the same, because the questions, their shapes, their defaults and the conditions under which they are asked live in one place as data (pepsi-setup-model) and both front-ends render it. A question added to one is a question added to both.

Two separate mechanisms keep it safe:

Answers are drafts. Each step writes into pepsi.config_override with the draft flag, which every configuration reader filters out structurally. Nothing takes effect while the interview is in progress, so a session that times out half way through has not half-configured a mail server, and reopening the page resumes where it left off. A step with a bad answer re-renders with the message beside the field and stores nothing — not even the good answers on the same page, because a half-stored step is one an operator has to reconstruct from memory.

Actions are intents. /ui/setup/tasks shows what has been asked of the privileged applier and offers a form to ask for more, from its closed set: run-preflight, obtain-certificate, install-schema, provision-roles. A button writes a pepsi.setup_task row and rings a doorbell; pepsi-setup apply, running as root, does the work and streams its progress back into the row, which /ui/setup/tasks/<id> shows. While the task runs the page reloads itself (a <meta http-equiv="refresh">, not a timer in script), and the reload does not poll: it asks for the page again with what it already showed, and the server holds that request for up to 20 seconds until the applier’s notification says there is a new progress line or an outcome. The result therefore appears the moment it exists. If the server has lost its database listener connection it answers at once and the page falls back to reloading every five seconds. There is no restart or shutdown action; a change that needs a component restarted ends with you restarting it.

Both need setup:write, the reads included: the staged answers are the shape of a deployment being built and the task list is a record of privileged work. The one exception is a user’s own requests: the key actions on the identities pages queue tasks too, and whoever queued one may open it and find it in /ui/setup/tasks, which for them lists nothing else. Somebody else’s task is “not found”.

Two independent things must hold before anything can be saved, and the page tells you which one is missing:

  • the privileged applier must exist — the separate pepsi-httpd-admin Debian package, holding pepsi-setup-apply.socket and its service. It is a Recommends, so it is installed by default and can be removed without taking Pepsi with it; and

  • [pepsi-admin] CONFIG_DB must name a connection authenticating as the pepsi-config role, because the applier refuses any row not written by it.

The applier is checked first, because with no applier configuring CONFIG_DB would not help. When either is missing the interview degrades to read-only rather than banking answers nothing will apply: every value still renders, the inputs come back disabled/readonly, the save button is gone, and a flash names the reason. The corresponding API calls answer 503 setup_applier_unavailable or 503 setup_write_unavailable (see The administrative API). The one deliberate exception is discarding a draft — that cannot cause a privileged change, and a wedged interview must stay clearable.

Note that the /ui/config pages are not affected by a missing applier: they write pepsi.config_override, a database table, not /etc. They need CONFIG_DB and nothing else.

A password-shaped answer is rendered blank every time, never echoed back into a value= attribute, and submitting the step with it blank leaves whatever is stored alone. The audit entry for a step names the question identifiers and no values at all.

21.5.3. DNS on the domains page

/ui/domains shows, beside each served domain, every DNS record Pepsi expects it to publish: the name, the value to publish, whether live DNS carries it, and what to do when it does not. The zone text at the foot of the page is the same block pepsi-setup run prints.

The verdict comes from the applier, not from this server: working out what should be published means reading the signing keys, and comparing it means querying DNS. The page therefore always shows when the answer was computed, and “check DNS now” (setup:write) asks for a fresh one rather than blocking the request on a resolver.

21.5.4. End-user pages

The own:<address> scope is defined and enforced, and a principal holding only it reaches the identity and correspondent pages narrowed to its own address — another address’s pages answer 404, and the shared objects (the correspondent-key cache, the trust anchors) are refused outright. It also reaches the mail log, narrowed to its own address; asking for another address there is refused with 403. On its own identities it may also act: ask for a server-managed key, register its own public key, request a key-server upload, publish, withdraw and ask for a revocation – everything but deleting a row (see Key management). There is no dedicated end-user interface; pepsi-stage-edit-settings(1) lets users change their own settings by e-mail, and the encrypt stage’s pepsi-keys@ control address lets them manage their keys by e-mail.

21.6. Two templating engines, two purposes

Pepsi renders two quite different kinds of text, and uses a different engine for each — so do not try to override a console page the way you override a bounce.

Mail bodies are Mustache templates, shipped as files under [pepsi] TEMPLATE_DIR (bounce-<name>.<lang>.body, payment-request.<lang>.body, edit-settings.<lang>.body, wallet.<lang>.body). They are operator-editable and per-language: an operator is expected to rewrite the prose a correspondent will read, and to add a language, without rebuilding anything. Logic-less templating is right there — there is nothing to compute, and a template that cannot run code is a template an operator can safely be handed.

Console pages are askama templates, compiled into the binary and type-checked at build time against the Rust structs they render. There is nothing to install at runtime, nothing to keep in sync with the binary, and a renamed field is a build error rather than a blank cell in production. The console is mostly tables and conditionals, which logic-less templating makes painful.

The practical consequence: there is no way to override a console page, and there is not meant to be. If a page says the wrong thing, that is a bug to report, not a file to edit.

21.7. No scripting, no remote assets

The console ships no JavaScript at all. Every action is a form or a link, so it works with scripting disabled — including the queue’s optional auto-refresh, which is a <meta http-equiv="refresh"> the reader turns on and off with a link rather than a timer. The charts are small hand-written inline SVG generated on the server, so they render in the same request as the rest of the page.

Nothing is loaded from another host: one stylesheet, compiled into the binary and served from /ui/static/console.css. The Content-Security-Policy therefore forbids script outright rather than allowlisting the console’s own, and forbids framing (frame-ancestors 'none', with X-Frame-Options: DENY beside it). Pages are sent Cache-Control: no-store, so administrative data does not sit in a shared cache or in the back button after a sign-out.

Cross-site request forgery is refused twice over. Every form carries the session’s CSRF token as a hidden field, checked against a digest stored with the session; and a mutating request whose Origin header names another host is refused before its body is even read — which is what covers the login form, the one form that by definition has no session yet.

Values that arrive from outside — a subject, a sender’s display name, a header value, a bounce reply text, a key’s user id — are escaped by default when they are rendered, and the tests exercise that with hostile input rather than trusting it. The only markup the console emits unescaped is the SVG of its own charts, which contains numbers and nothing else by construction.

21.8. Accessibility

The console is plain HTML: a skip link to the main content, one <h1> per page, tables with <caption> and <th scope=…>, a label for every form field, and aria-current="page" on the navigation entry you are on. Every action is a real <button> inside a form, so it is reachable and operable from the keyboard with no key bindings to learn, and the focus ring is drawn explicitly because the default one disappears against some backgrounds. Light and dark both come from prefers-color-scheme; there is no theme switch, because a switch needs script or a cookie and the operating system already knows the answer.

The charts carry aria-hidden and are never the only presentation of anything: the same numbers are in the table beside every figure.

Navigation offers only what your credentials allow — a link that answers 403 is worse than no link.

21.9. The public mailing-list pages

A second, separate browser surface, gated on LISTS = yes (see Three listener flags, and two of them have opposite advice) and documented here because an operator meets both:

/lists

The index of advertised lists, grouped by domain. A list whose advertised attribute is false is absent from the index and still reachable by URL — that is upstream’s meaning of the attribute and it is not a security boundary. “Unadvertised” reads like “hidden”; it is not.

/lists/<list-id>

A list’s description, its info text, its posting address, the subscribe and unsubscribe forms, a link to the archive when the policy allows one, and the roster only when member_roster_visibility is public.

/lists/<list-id>/confirm/<token>

What a confirmation link points at. A ``GET`` completes nothing: it renders what the token is for and asks for one POST. Mail clients, link previewers and security scanners fetch links, several of them before the person does. This is a deliberate difference from the -confirm+<token> mail address, where arrival is the confirmation because the token travelled in the envelope and only its owner could have put it there.

/archives/list/<list@domain>/…

The archive: overview, month, thread, message, attachment, search. The URL shapes are HyperKitty’s, so every Archived-At: header a migrated deployment ever emitted keeps working. See Archives.

Three things about these pages are worth knowing before deploying them.

They ship no JavaScript, at all. The Content-Security-Policy therefore has no script-src — not even 'self' — and says default-src 'none'. The cost is the interactive parts of HyperKitty: thread collapsing is <details>, there is no live search and no in-page reply. That was decided deliberately, and a policy that allows what is not used is a policy that will one day allow what is injected.

Message bodies are escaped text, never markup. Upstream’s archive_rendering_mode attribute offers text or markdown; Pepsi stores and exposes the attribute — the attribute set is the compatibility contract — and renders text for both. Rendering user-supplied markdown into our own origin is precisely what the policy above exists to make impossible, and a markdown renderer is an HTML-injection surface with extra steps.

Sender addresses are obscured for anonymous viewers. An archive page shows alice@... rather than the whole address. The archive keeps the real one — obscuring on the way in cannot be undone, and the bounce processor needs it — so this is a rendering decision. It is not a security control, and it is not meant to be one: somebody who is on the list has the address in their own copy of the message. What it stops is the bulk harvest that makes a public archive a spam source.

21.9.1. Forms that make the server send mail

Subscribing is a request for the server to send a message to an address nobody has proved they control, which is a harassment amplifier if it is not throttled — and Pepsi ships no CAPTCHA, by the same decision that rules out JavaScript. So:

  • each such form is rate-limited on the target address as well as on the client address;

  • a second submission while a token is still live re-uses that token rather than minting another, so ten submissions are not ten messages;

  • the answer is identical whether or not the address is already a member, so the form is not a membership oracle.

The last one is why the page says “if that address can be subscribed, a confirmation has been sent to it” rather than anything more helpful.

21.10. Two account systems, and neither grants the other anything

This is the single most misunderstandable thing about the web tier, so it is stated as plainly as possible.

Pepsi has two kinds of browser account, in two tables, with two sets of cookies, and they are not related:

The operator’s console

The list member’s account

Who it is for

Whoever runs the mail server

Anybody who subscribes to a list, and the owners and moderators of those lists

Account table

pepsi.admin_account

pepsi.list_user

Session table

pepsi.admin_session

pepsi.list_session

Cookies

pepsi_session / pepsi_csrf

pepsi_list_session / pepsi_list_csrf

Listener flag

ADMIN = yes

LISTS = yes

Where it should be reachable

Loopback, over an SSH tunnel

The public internet

Created by

An operator, through POST /api/v1/accounts (the first one over the local socket or with a pepsi-setup bootstrap token)

Registering at /lists/register on the public site

What it can do

The configuration, the queue, the keys and the logs. Not the lists: those are managed with pepsi-list(1) or through the Mailman /3.x REST API, whose shared [pepsi-list] API_USER/API_PASS is a separate credential; neither /api/v1 nor /ui reaches them

Its own subscriptions, plus whichever lists its address is an owner or moderator of

Neither is a weaker version of the other. An operator account is not a member of anything, and a member account — even a list owner’s — cannot read the queue, the configuration or anybody’s keys. The two are separate because their deployment advice is opposite: the console is the thing the manual tells you to keep off the internet, and a list owner obviously cannot be asked to open an SSH tunnel to approve a held message.

21.10.1. How a list owner’s authority actually works

There is no “list administrator” account and no permission table. Authority over a list is a roster query: a list_member row for that list with role = owner or role = moderator. This is Postorius’ model, kept deliberately.

Three consequences follow, each of which looks like an oversight until you see where it comes from:

  • Removing somebody from the roster takes their powers away immediately, with no session to invalidate, because every page re-asks. Their account survives — it is not their membership — and so do their other subscriptions.

  • A moderator is not an owner. A moderator handles held messages and subscription requests; an owner does that and also changes settings, the roster, the bans and the header matches. That split is upstream’s.

  • A site owner owns every list. list_user.is_server_owner is upstream’s flag for that, and it is the only global authority in this tier. It is not an operator account either.

A non-owner asking for /lists/<list-id>/admin gets the ordinary “there is nothing at this address” page — the same one they would get for a list that does not exist. That is on purpose: on a public surface, a page that says “this exists but you may not see it” has told a stranger that it exists.

21.10.2. If you serve both surfaces from one hostname

The wizard warns against it and cannot prevent it. Cookies are scoped by host, not by listener, so a deployment that puts ADMIN = yes and LISTS = yes behind one name has exactly two things keeping the surfaces apart: the cookie names, and the guard’s check of which table a session came from.

That is a thin line, and it is thin by design rather than by accident. It is tested in both directions — a member session presented to an operator route is refused exactly as no cookie would be, and an operator session presented to a member route likewise, with neither producing a distinguishable error. Still, prefer two names, or better, two listeners: ADMIN on loopback and LISTS in public is the layout the rest of this chapter assumes.

21.11. The member’s pages

/lists/register

Creates an account and sends a verification link. Non-oracular and throttled, like the subscribe form above and for the same reason.

/lists/verify/<token>

Confirms the address and sets the password, in one step. There is no separate “choose a password” page, because a verification link that only verifies leaves an account nobody can sign in to.

/lists/sign-in, /lists/sign-out

Sign in with any verified address of the account and the account’s one password. A member may hold several addresses — upstream’s model is one user with many addresses — which is what makes “I subscribed with my work address” work. An unverified address cannot sign in: it has proved nothing.

/lists/reset

Sends a reset link. A link, never a password: a mailed password is a password in a mailbox for ever. pepsi-list owner reset-password remains the operator’s escape hatch for when mail is broken, and it prints rather than mails by default for exactly that reason.

/lists/me

Their addresses, and their subscriptions across every list, each with its delivery mode and status.

21.11.1. Why a preference may not apply where you expect

The six delivery preferences are looked up through a chain — this subscription, then this address, then this account, then the list’s own default — and any level may be unset. So a member who sets a preference on their account and finds it not applying to one list has usually set it at a level that a more specific one overrides.

/lists/me therefore shows, for each subscription, which level the value came from. That column is the answer to the only question this design reliably provokes.

21.12. The owner and moderator console

/lists/<list-id>/admin

The one page to start from: the held messages, the subscription requests, and (for an owner) links to the settings screens and the roster.

/lists/<list-id>/admin/held/<request-id>

One held message, with its exact bytes shown as text. Accepting releases those bytes, not a re-rendering of them — a held message is kept in its wire form precisely so that a release can be delivered for real, signatures and attachments intact.

The page is the most hostile one on the site: a stranger’s mail, rendered where a moderator’s click can act. So the body is escaped text inside a <pre>, the policy allows no script, and every button carries a CSRF token.

/lists/<list-id>/admin/settings/<screen>

The writable list settings, on eleven screens in Postorius’ own grouping — because a migrating owner will look for a setting where it used to be. The help text beside each field is Postorius’ own, imported; see the attribution note in Mailing lists.

The screens are generated from the same declaration that drives the REST API and the CLI, so a value refused here is refused there, in the same words, and a setting cannot be present in one place and missing from another.

The six *_uri template attributes are on these screens too, so an owner who wants their own welcome message or footer sets the URI where every other setting lives rather than on a page of its own.

/lists/<list-id>/admin/roster

Members, owners and moderators; the list’s bans; and its header matches, with the site-wide bans and matches shown read-only beside them — because the site-wide ones take precedence, and an owner debugging a rule that never fires needs to see the one that fired first.

Adding an address is the one action in this tier that subscribes somebody who has not agreed. It is an owner action, it is written to the audit log with the owner as the actor, and the added address is not marked verified: an owner’s say-so is not proof that a mailbox exists.

Everything in this console is audited through the same log as every operator action, with the member’s account as the actor. detail never carries message content — a held-message accept records the request and the message’s hash, not the body — and the log is pruned on the operator’s [pepsi-admin] EVENT_RETENTION_DAYS timer, which is worth checking against how long you want moderation history kept.

21.13. The interface’s language, and why it is not ten

The public pages are served in English, German or French, chosen from the request’s Accept-Language header. A regional tag is served its language (de-CH gets the German pages, because one variant of a language is closer to it than English is), the header’s q order is honoured rather than its document order, and a q=0 is read as the refusal it is — so de, en;q=0 means German and not “German, then whatever”.

Three languages here and ten for mail is a real seam and is stated rather than hidden. The notice templates — the welcome message, the confirmation request, the bounce warnings — exist in ten languages, because they came from GNU Mailman, which has them in thirty-four (see Mailing lists). The interface’s labels are ours, and they exist in the three the manual exists in. A member who reads a German welcome message and follows its link lands on a German page; a Swedish one lands on an English page. Adding a language to the interface is translating about 140 short labels, which is an afternoon and a patch, not a project.

The choice is made from the header alone, before any database access. That is deliberate: a public page is reachable by anybody, and picking a word by reading a row would mean an unauthenticated request touching the database, on a server whose worker pool is one connection. A signed-in member’s own stored preference therefore does not override the header.