Skip to content

Admin UI

Purpose. Day-to-day administration in the browser, without SSH. The screens below are what the sidebar offers; where a screen has a feature-doc of its own this page links to it rather than restating it. Served at https://postmaster.<your domain>/ once setup is complete, and at https://<hostname>/ as well — the mail hostname keeps working so that an upgrade cannot lock a server out of its own console before the new record is published. Publish postmaster.<domain> as an A record at the host, or a CNAME to the mail hostname; it is one host for the whole server, not one per domain. With TLS on (automatic or your own certificate), http:// on any of these names answers a redirect to the same address on https://, never the page itself, so a password is not sent unencrypted. (apps/admin; React 19 + Vite + TanStack Router/Query + Tailwind, shadcn-style components owned in packages/web-shared/src/components/ui.)

It is its own application, on its own host. The admin panel and the webmail are two builds with two bundles, sharing only packages/web-shared — the API client, the session, the UI kit, i18n and the theme. Neither ships the other’s screens, and moving between them is a full page load rather than a route change. This console registers no service worker and caches nothing offline: a control panel has no useful stale answer to “what is the queue doing?”.

Behaviour.

  • The frame. A sidebar on the left lists the screens, grouped by task: the Dashboard, then Mail (Domains, Migrations), Monitor (Mail queue, Delivery log, Audit log), Access (Users & roles, API tokens, Blocked sign-ins), AI (Providers & limits, Copilot) and Server (Settings, Licence); a domain_admin sees only the groups that hold its four screens. A page inside a screen highlights the screen it belongs to (a mailbox highlights Domains). At the top, a chip names the server you are on and how its licence stands, and for an owner or admin opens the licence page; at the bottom, Webmail opens your own mailbox. The header above each page says where you are (a breadcrumb), has a search box (or press ⌘K on a Mac, Ctrl+K elsewhere) that jumps to any screen or to a domain by name, and ends with the theme menu and your account menu (your initials: Account, Webmail, Sign out). On a phone the sidebar is behind the menu button at the left of the header, as a labelled list. Results of an action you are no longer looking at (a token revoked, a configuration rolled back) appear briefly in the bottom-right corner. Anything that cannot be undone asks first: deleting, revoking a token, rolling back the configuration and cancelling a migration.

  • Webmail (account menu) — leaves this application for the mail client, signed in to the mailbox at the administrator’s own address. Offered to every administrative role, domain_admin included, because it opens nobody else’s mail; the session is an ordinary one, with no banner and no audit entry. An account with no mailbox of its own is told so rather than shown an empty client.

  • Open any mailbox’s webmail — the ⋯ menu on a mailbox row (Domains → a domain → Mailboxes) has Go to webmail, which opens that mailbox in the mail client as its owner sees it, including compose and send. owner and admin only: a domain_admin is refused, even for a domain it administers. Every such session is written to the audit log naming the administrator, and so is every message sent from one; the mail client shows a banner saying whose mailbox is on screen, which cannot be dismissed. The session lasts an hour and the mailbox owner is not notified.

  • Delete for a mailbox also lives in that ⋯ menu now, one click deeper than it used to be.

  • Mail queue — what is waiting in Postfix right now, with hold / release / delete (Mail queue (viewer, hold / release / delete)).

  • Delivery log — searchable Postfix delivery attempts with bounce classification and class tiles (Delivery log & bounce classification).

  • Dashboard — every admin role; for owner/admin it also shows the open alerts under Needs attention (DNS drift, blocklists, rDNS, certificate expiry, outbound abuse, rate-limit bursts, failed TLS sessions from TLS-RPT reports, unreadable DKIM keys, low disk space; Dismiss closes one) and the Server reputation panel from the worker’s hourly check (Blocklist, reverse-DNS and certificate monitoring (alerts)).

  • Sign-in. This application’s own, at /login — the webmail has a separate one. Forgot your password? mails an admin-console account a single-use reset link (Password reset by email). An account that turns out to be a mailbox rather than an administrator is handed to the webmail rather than shown an empty console; that is a courtesy, and the refusal that matters is the API’s, which excludes a mailbox principal from every admin route. Email + password; if the account has two-factor enabled the form asks for the six-digit code after the first attempt (401 totp_required). The 15-minute access token is refreshed silently a minute before expiry and on any 401, using the HTTP-only refresh cookie; when the refresh cookie itself has expired the user lands back on /login and is returned to the page they were on after signing in. Sign out revokes the refresh session.

  • Roles. owner/admin see everything; domain_admin sees the dashboard, domains, mailboxes, aliases, forwarders and catch-all, plus the mail queue and delivery log scoped to its own domains (the API enforces the same rule). mailbox_user has no admin screens — that role signs in to webmail instead.

  • Language. Every screen renders from the translation catalogue; English and Spanish ship (Internationalisation). The admin panel has no language menu: it follows the browser’s preferred languages (navigator.languages), falling back to English. A mailbox user picks a language in webmail Settings, and that choice lives on the mailbox and follows them between devices. Text the server wrote — API errors, alert titles, a remote MTA’s refusal — stays English on purpose.

  • Theming. Light / dark / system and six accent colours, per browser (localStorage), implemented as CSS variables (--background, --primary, --accent-hue, …) on <html>. The server’s own name, logo and default accent are set under Settings → Branding (Branding); a browser that picks its own accent keeps it, and the menu’s Default follows the server again.

  • Dashboard. Four numbers, each a link to the screen that explains it: Domains (and how many an open DNS alert names), Mailboxes (against the licence’s limit, linking to the licence page), Delivered, 24 hours (and the share of outbound outcomes it is — a deferral is retried and counts only by how it ends — linking to the delivery log) and Mail queue (and how many are deferred). Then, for owner/admin: Needs attention (the open alerts below), the licence (state, tier and expiry, last check-in, mailboxes and domains against the plan’s limits), Services (each daemon’s reachability and applied render, from GET /server/status), Server reputation, and AI (the provider in use and the Copilot’s proposals waiting). A domain_admin sees Domains, Delivered and Mail queue, which the API scopes to its domains, and none of the server-wide cards.

  • Domains. List with an active toggle and default quota; add (name + default quota, "5 GB" style input, 0 = unlimited); delete (refused by the API while mailboxes exist). A domain’s page names the domain with its state (and DNS needs attention when the last check found a problem), has Edit domain for its default quota and whether it is active (the name cannot change; an inactive domain receives no mail and its mailboxes cannot sign in), and holds four groups of sections, each at its own address (/domains/<id>/<section>), so a link, a bookmark or an alert opens the exact one; on a phone they are a menu above the content. An alert about a domain opens the section it is about: DNS drift opens DNS & routing, an unreadable key DKIM keys, failed TLS sessions TLS reports.

    • Mail. Mailboxes — create (address, display name, password, quota or domain default), a Used column (bytes used of quota with a bar that turns amber at 90 % and red at 100 %; — until Dovecot has reported), suspend/enable toggle, delete (Maildir kept on disk — see the backup run-book; the confirmation offers Export mail first, owner/admin only), link to the mailbox page. Aliases — address → one or more destinations (local or remote), enable/disable, delete. Catch-all — destination + active flag, or remove to reject unknown recipients (the default).
    • Setup. DNS & routing — Mail routing (This server or Another server), which decides whether the domain’s mail is accepted here and which records the section lists (Domains → Mail routing); the records to publish with copy buttons, Verify now (per-record status against four resolvers, Domain ready banner) and Export / provider notes (zone snippet + Cloudflare/Route 53/generic tips) (DNS records & live verification). DKIM keys — active and retired keys, rotate, delete retired (DKIM signing).
    • Policy. Spam — thresholds and allow/block sender lists for the domain, with the effective value and where it came from (Per-domain and per-mailbox spam settings). Sending limit — the domain’s combined outbound rate limit. Empty means the server default; 0 means unlimited. It is the domain’s aggregate ceiling and does not change what any one mailbox may send (Outbound rate limits). AI (owner/admin) — whether AI may be used for the domain’s mailboxes, whether attachment text may be sent, and the domain’s daily AI limit (AI privacy controls). Categories — whether the domain’s mailboxes may sort their Inbox into categories (on by default; each mailbox still turns them on for itself), and saving pushes every mailbox’s Sieve script again (Inbox categories).
    • Reports. Activity — mail to and from the domain over 7 or 30 days, from the delivery log: received, refused, sent, failed and deferred as tiles, received and sent per day as bars (hover names the day and count), the same as a table, and a link to the delivery log filtered to the domain. Counted per recipient. DMARC — aggregate-report summary for 7/30/90 days with a per-source table (DMARC aggregate reports). TLS reports — inbound SMTP TLS Reporting summaries and failures (MTA-STS and TLS-RPT).
  • Mailbox page. The address with its state (suspended, sending blocked), Delivery log (filtered to it) and Export mail in the header (owner/admin): every folder as an mbox file in one .tar.gz, downloaded through a single-use link (Mailboxes → Export). Six sections, each at its own address (/mailboxes/<id>/<section>), one card per thing and none inside another. Account — display name, quota with the storage used (used of quota, message count, when Dovecot last reported — polled every 60 s), suspend, a sending block and Restore sending, and the password (set twice; when the two differ it says so and sends nothing). Forwarding — forwarders (destination, keep-copy, active, delete). Filters — the same list and editor as the webmail’s (each filter one sentence, switched, reordered, duplicated and deleted in place; the editor with the Sieve it compiles to; More for the raw Sieve editor, export and import), without Try it on your mail, which would show an administrator the mailbox’s mail (Mailbox filters & autoresponders). Autoresponder — enable, subject, message, interval, start/end. Saving filters or the autoresponder pushes to Dovecot — Pigeonhole’s compile errors are shown verbatim and nothing changes on failure (Mailbox filters & autoresponders). Security — app passwords (below). Limits — this mailbox’s overrides: spam (below), sending (its outbound rate limit — empty for the server default, 0 for unlimited; its domain’s ceiling and any warm-up cap still apply on top, Outbound rate limits) and AI requests (requests today and over the last 30 days, and its daily AI limit — empty to inherit its domain’s or the server’s, 0 for no AI at all — with the limit in force and where it comes from, AI privacy controls).

  • Users & roles (owner/admin). List with role selector, active toggle (turning it off ends the user’s sessions, so turning it back on does not restore them — and holds what the user scheduled to send later from a mailbox it opened), 2FA status; add user with a temporary password. A user whose 2FA seed cannot be decrypted (the server’s key changed) shows Unreadable — cannot sign in and a Clear two-factor authentication button, and only then — POST /users/{id}/totp/clear-broken. You cannot change your own role or deactivate yourself.

  • API tokens (owner/admin). Two tabs, each at its own address and listing only its own kind: Server automation (/tokens/server) and Applications (/tokens/applications); Create token creates the tab’s kind. Revoking asks first; the msk_… secret is shown once. Server automation: a name, a role and an optional expiry. Applications (sending mail from an application): a name, the addresses it may send from (one per line, an address or *@domain), whether it may also read the status of messages sent through the API (every sending token’s, not only its own), and an expiry that is set to a year from today unless cleared. A sending token cannot manage the server, and the list shows it by what it may do and the addresses it may send from rather than by a role.

  • Settings (owner/admin; Server → Settings in the sidebar). Nine sections, each at its own address (/server/<section>), in four groups. Each section is its own form: it starts from the live configuration, changes only its own fields, and is applied on its own through the same Validate & apply (PUT /server/config; a rejected render leaves the previous version active and shows the validator output). A bar under the section counts its unsaved changes, with Discard; leaving the section, the screen or the tab with changes unapplied asks first. Server — General: the hostname and TLS mode (set at install), and protocols — whether Dovecot offers POP3 on 110 and 995 at all. On by default. Only clients that download mail and delete it from the server use POP3, and a listener nobody uses is attack surface nobody chose, which was CERT-Bund’s own advice on the report that found the plaintext exposure. Off, Dovecot stops listening while Compose keeps publishing the ports: on Docker Desktop, where a userland proxy fronts each published port, a client’s TCP connection is accepted by the proxy and closed without a banner, measured 2026-09-30; a connection from another machine is forwarded into the container by the kernel’s NAT rule and refused there, while one from the host itself may likewise be accepted by the proxy and closed. mailctl doctor reports off by configuration, and Autoconfig/Autodiscover never advertised POP3. Branding: name, logo and default accent (Branding). Trusted networks. Outbound — Sending limits: message size, per-user and per-domain outbound rate, connection rate. Reputation protection: IP warm-up and containment (Compromised-mailbox containment). Outbound relay (its password is write-only: never shown or returned, kept when the field is left blank for the same host and username, and flagged for re-entry if the server’s encryption key changed). Inbound — Spam thresholds (Rspamd), MTA-STS. Changes — History: every version with status and Roll back to this, which asks first. Each daemon’s reachability and applied render, which was a Status tab here, is the Dashboard’s Services card.

  • Mail queue. The four queues as tiles (incoming / active / deferred / on hold; each is a toggle that filters the list, by mouse or by keyboard), a search over queue id, sender and recipients, and one row per queued message with its size, its recipients and Postfix’s own delay reason. Select rows to Hold, Release or Delete; deleting is behind a confirmation because Postfix has no undelete, and each command shows postsuper’s own summary line. Read live on every load and every 15 s — nothing is cached. domain_admin sees only messages touching its domains and can act only on those; the sidebar entry is not admin-only for the same reason the delivery log’s is not (Mail queue (viewer, hold / release / delete)).

  • Blocked sign-ins (owner/admin). Addresses and IP addresses currently refused after too many failed attempts — what was blocked, why (wrong passwords / wrong two-factor codes / an administrator), which surface counted it (webmail and admin sign-in, or IMAP/POP3/SMTP), the attempt count and the expiry. Clear releases one early. The rows show the address that was tried, which is often one that never existed — most of a brute-force run is guesses, and flattening those would hide the shape of the attack (Login rate limiting & brute-force lockout).

  • Audit log (owner/admin). Paginated, newest first. Filter by who acted, by action, by what it was done to and by day (from, to); the filters are in the address, so a filtered log can be linked to. Each entry links to the thing it is about, named by its address or name where the entry recorded one (the full id on hover), and shows what it changed field by field — only the fields whose value differs, before beside after. Owner/admin only, and a domain_admin gets a 403 rather than a scoped view — unlike the mail queue and the delivery log, which are scoped. An entry names users, tokens and objects across the tenant, and there is no per-domain slice of that to hand out. An action taken in a mailbox an administrator opened reads … by <mailbox> (on behalf of, by <admin email>), and its actorUserId is the administrator, not empty — a mailbox is not a user. Three actions exist only for that case: mailbox.webmail_opened, written when the hand-off is spent, mailbox.webmail_sent, one per message sent from such a session (a scheduled message’s Send now included), and mailbox.webmail_scheduled, one per message such a session schedules to send later or gives a new time. Sign-in bans, their clearing and the setup steps are recorded without a tenant, so they do not appear here.

  • Migrations (owner/admin). The job list — add a job (cPanel/WHM, Plesk, Google Workspace, Microsoft 365, or plain IMAP), with the source credentials probed before the job is created so a wrong token is caught while somebody is still looking at it — and each job as a stepper — Source, Discover, Map mailboxes, Copy, Cut over — each step at its own address (/migrations/<id>/<step>), with Back and Continue between them, and the breadcrumb back to the list. New migration is the first step, a page of its own. Source: where the mail is and whether its credentials are still held; Discover: what is on the source, with sizes (not for plain IMAP, which cannot list its users); Map mailboxes: the mapping with create missing and copy quotas, batch or generated passwords; Copy: the run with live progress and the filterable log (everything / warnings / errors); Cut over: the checklist. A job opens at the step it has reached. Cancel migration… asks first, because it deletes the source credentials. owner/admin only, and deliberately so: a job holds a live credential for another mail server and writes into every mailbox it touches, which is not a domain-admin act. A job whose MIGRATION_SECRET_KEY has changed says its credentials are gone and asks for them again rather than failing mid-copy (Migration). Pre-creating mailboxes here is licence-gated exactly as the Domains page is.

  • AI (owner/admin). The provider and model for this server, whether AI is enabled at all, the server-wide limit per mailbox (Limits, with a link to each domain’s own policy, which is on the domain’s page under AI), and the call log — one row per call with the feature, the mailbox, the outcome, the token count and the latency. Nothing is on by default; a server that was never configured here has AI off (AI providers, AI privacy controls).

  • Copilot (owner/admin). Ask a question about this server in plain language; the model answers from read-only tools over deliverability data, and any change it wants to make arrives as a proposal an administrator approves or rejects — it never applies one itself. The screen says plainly when it cannot help: no AI configured, or a provider whose model has no tool support, in which case an answer would be ungrounded guessing and is refused rather than offered (AI admin copilot).

  • Licence (owner/admin). State with its plain-English reason, the decoded plan (tier, licence id, who it was issued to, issued/expires, features), usage against the plan’s limits (0 = unlimited), this server’s machine ID, the machine the key is bound to, the last check-in and the last check-in error. The owner also gets the activation box: paste an mdl1.… key and it is verified server-side before it is stored, so a bad key is refused where it is pasted. Anyone else sees the page without the box. The sidebar entry is owner/admin, but the page itself reads for a domain_admin too — the banner renders for everyone the licence can block, and it has to lead somewhere for all of them. On a fingerprint mismatch the page names both machine IDs and, for the owner, offers Move this licence to this server — one click, no key to paste, because the licence server accepts the key already installed here as proof. Everyone else sees the diagnosis without the button (Licensing).

  • Licence banner. Rendered above every admin screen for evaluation and grace (amber, with the countdown to what stops next) and for every lapsed stage — degraded, restricted, send_suspended (red, naming what that stage refuses, the countdown to the next stage (none after send_suspended), and that receiving and reading mail are unaffected and renewing restores everything at once). active and unlicensed are silent: unlicensed is now only a server that is not assessed — setup unfinished, a build with no LICENSE_PUBLIC_KEY, or an installed key this build cannot read (signed for another public key, or sealed under another SECRET_ENCRYPTION_KEY) — and enforces nothing; the banner stays silent for all three (license-banner.tsx), and the licence page shows the reason.

  • Account. Password: change your own — the current one, then the new one twice; every other place this account is signed in is signed out by the change, and this browser stays signed in. Where you are signed in: each session’s browser, address and last activity (its last token refresh, so within about fifteen minutes while a tab is open), with Sign out for any other one and Sign out everywhere else; a session signed out here is refused at its next request. Sign out everywhere else and a password change also end any mailbox this account opened from the console, which are not listed here, and hold what it scheduled there to send later. Two-factor authentication: enable it (secret / otpauth:// link → confirm a code) or disable it with a current code. Enabling shows ten single-use recovery codes once, with Copy and Download; afterwards the card shows how many are left, warns at two or fewer, and Generate new codes replaces the set for a current authenticator code (audited auth.totp.recovery_codes_regenerated). The sign-in form’s code field takes either six digits or a recovery code; each code works once, spends the same two-factor budget as a wrong code, and is audited (auth.totp.recovery_code_used). Disabling deletes them.

  • Mailbox page → Security. App passwords for this mailbox’s mail clients — create, see when each was last used, revoke. Two-factor authentication is not offered here: it is enrolled by the owner from their own webmail settings, because an administrator who could turn it off would make it a formality (App passwords and mailbox two-factor authentication). What the page does show is whether it is on and, when it is, how many of the owner’s recovery codes are left — never the codes themselves. Only when the mailbox’s two-factor secret can no longer be decrypted does it offer to clear it, which deletes the recovery codes with it.

  • Spam (per domain, and per mailbox). A Spam section on the domain page and the mailbox page’s Limits section, both rendering one component: the three Rspamd thresholds — reject, mark as spam, greylist — where a blank field inherits from the level above and says which value it will get, plus an allow/block list of senders. A save re-applies and validates like any other configuration change (Per-domain and per-mailbox spam settings).

  • The sign-in → domain → mailbox path is covered in a browser on every CI run. apps/admin/e2e/admin.spec.ts signs in as the owner the wizard created, adds a domain, adds a mailbox in it, follows each one to its own page, and then removes both — including the refusal to delete a domain that still has a mailbox. It runs after the API smoke, in the chromium Playwright project (pnpm e2e); the wizard that created the account it signs in with runs before the smoke, in the setup project. Everything it creates is stamped and cleaned up, because the webmail journey shares the stack.

  • Every screen is loaded on demand. The entry chunk carries the router and its three guards — the setup check, the session bootstrap and the role redirect — and nothing else, because those decide which screen to fetch. Splitting took the initial bundle from 748.75 kB (231.19 kB gzipped) to 350.83 kB (113.49 kB); each route is now its own chunk of a few kB. NotFoundPage stays eager on purpose: it renders when routing has already gone wrong, which is the wrong moment to depend on a chunk fetch.

  • Every mutation goes through the same typed client (packages/web-shared/src/api/client.ts, hand-written over openapi-fetch and typed by src/api/schema.d.ts — the one generated file, produced from the API’s OpenAPI document by pnpm --filter @mailserver/web-shared generate:api), so the UI cannot drift from the API contract without a type error.

Configuration. None at runtime. The web container serves static files for the admin panel on :8080 and for the webmail on :8081; the edge Caddy routes /api/* to the API on every host, postmaster.<domain> and <hostname> to :8080, and webmail.<domain> to :8081 (bootstrap docker/caddy/Caddyfile, which has no hostnames and serves the two on :80 and :8081, then the rendered config from packages/engine-postfix-dovecot/src/caddy.ts). In development pnpm --filter @mailserver/admin dev proxies /api to http://localhost:3000 (API_URL to override).

Limits & edge cases. The server-wide outbound rate limits live under Settings → Sending limits; per-domain and per-mailbox overrides are the domain’s Sending limit section and the mailbox’s Limits section (Outbound rate limits). Backup-MX routing is not offered: a domain is received here or by another server. On a phone the sidebar becomes a labelled drawer behind the header’s menu button.