Files
pl_mail/docs/features/admin.md
T
Paul LütznerandClaude Opus 5 e74fa116e5 docs: the handbook learns about reported mail and three new shapes
The radar section gains the three new sources and the one that behaves
differently from the rest — a one-time code carries an expiry rather than
an occurrence, and carries no time at all when the mail states none.

Reporting gets its own subsection in both languages, and it leads with
what reporting costs: the report carries a copy of the mail, that copy is
readable and downloadable by an administrator, and on an installation you
do not run yourself that is worth knowing before you press the button.
Admin gains the panel, the export and the reason downloading marks
nothing handled.

Two traps added per page, both things that actually bite: reporting hands
over a copy of the mail, a code with no stated lifetime gets no expiry,
and the export — unlike the config backup — is plaintext mail.

German re-hashed from the English it was made from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:45:44 +02:00

25 KiB

Administration

Signed in as an administrator, Admin in the user menu opens a panel that says what the instance is doing. It is six sections: System, Database, Logs, Integrations, Users and Reset.

The admin dashboard

Nothing here reads anyone's mail. Being an administrator grants the panel, the user list and the provider configuration, and nothing else — no route in this area touches an account or a message.

The version chip

The panel header carries the build: the release it was built from, and the short commit beside it. It sits in the header rather than in a panel because the question it answers — is this the build I think it is? — is asked while looking at something else.

It is baked in at build time rather than read at runtime; a container has no .git to ask. A checkout that was never built from a tag falls back to git describe, and where nothing knows the answer the chip is absent entirely rather than reading "development" beside every page.

Two images can both call themselves main, which is why the commit is shown next to the label and not instead of it.

System

Live panels, refreshed every ten seconds. Each can be collapsed, and a collapsed card shows a one-line summary in its header — 3 healthy, 2 running · 41 waiting — short enough to read at a glance and specific enough to be worth expanding for. Which cards you collapsed is remembered server-side, so the page never flashes every panel open before folding them.

Card What it shows
Processes Every long-running process that has reported a heartbeat, with type, instance, PID and last beat
Maintenance The verbs — restarts, one-off tasks, pruning
Gmail webhooks Per Gmail account: watch state, expiry, history id, last push, and the delivery path
OAuth token health Refreshes recorded, and tokens nearing expiry
Messenger queues What a worker is holding right now, over the backlog
Failed messages The failure transport, with retry and delete
Accounts Per account: threads, messages, last activity
Table sizes The biggest tables in the database

The queue panel

The one to read when mail stops arriving. Running now names the messages a worker is holding at this instant — the handler, its payload, and how long it has been held — over a searchable list of everything still waiting. A queue that is stuck therefore looks different from a queue that is empty, which is the distinction that matters.

The backlog pages twenty-five at a time and fetches more as you scroll, and the filter runs over the whole queue rather than the page on screen. It has its own endpoint, so searching does not re-render every other panel per keystroke.

Maintenance

Restart workers asks every long-running process to exit; Compose's restart policy brings them back, and in-flight work finishes first. The reason it exists: a worker caches Doctrine metadata for its whole lifetime, so after a migration it can keep querying columns that no longer exist until something restarts it.

Restart now is the other half — the container serving the page you are on. The worker restart cannot reach it, because its mechanism is a timestamp a worker loop rechecks and the web process has no such loop. The usual reason to want it is a rotated secret, which an already-booted kernel has no opportunity to re-read. It costs about two seconds of downtime and the page comes back on its own.

Run a maintenance task now offers four buttons: Sync mail, Renew push subscriptions, Prune monitoring data and Prune blobs. Each is queued for a worker rather than run in the request. These four and no others, because they are the ones the scheduler already runs unattended, which is what proves them safe to expose as buttons.

Prune stale heartbeats clears rows left by processes that died without shutting down cleanly — the ones that would otherwise sit red in the Processes card forever.

The failed-message card adds Retry all and Purge all, both confirmed.

None of these answers with a message. Each redirects back and the panel's own refresh shows the result: the queue depth rises, failed rows disappear, heartbeats come back. The exception is the worker restart, which has no visible effect, so the panel renders a banner saying how long ago it was asked for — a restart clears heartbeat rows rather than reddening them, and rows vanishing is expected rather than an outage.

Database

Connection counts, cache hit ratio, deadlocks and rollback ratio, plus the slowest statements by mean time (anything averaging 5 ms or more) and the heaviest by total time, and what is running right now.

The aggregation needs PostgreSQL's pg_stat_statements, which app:db:migrate enables at boot. If the database role was not allowed to create the extension, the panel says so and offers Enable it now. Statistics start from the moment the extension is created, so queries from before it will never appear. Reset stats clears the collected numbers.

Logs

A filterable browser over what plMail has written to the database: a minimum level — info, notice, warning (the default), error or critical — and a channel, a hundred entries per page, with a copy button per entry.

How much reaches the database at all is APP_DB_LOG_LEVEL, which defaults to warning. Lowering it to info is what makes successful Gmail push deliveries visible, for instance; they are logged at info and otherwise not stored.

Clear deletes the entries matching the filter currently on screen — what disappears is what you were looking at.

Anything at warning or worse that no administrator has read outlines the user menu on every page, amber for warnings and red for errors, with a count. Opening the log browser is what marks them seen, and the mark is set from the moment it was opened rather than from the newest entry on screen, so anything logged while you were reading is still genuinely unread.

The outline is shown to administrators only. For anyone else it would be an alarm about something they are not allowed to look at.

Unlike the System panels this section does not auto-refresh — reading a stack trace should not get yanked away mid-scroll.

Reported mail

Where Report a missed insight ends up. Each row is a mail somebody thought the radar should have understood, kept as a snapshot rather than a link: sender, subject, when the mail arrived, the beginning of its text, and the reporter's own note about what plMail should have spotted. The snapshot is the point — the mail itself gets archived, deleted or vanishes from the server, and a report that resolves to nothing is a report saying only that something was missed once.

The nav entry carries a count of what is still waiting.

Export hands the pile back as one JSON file: a header saying when it was taken, off which build, and whether it holds everything or only the unhandled part, then every snapshot in the order the mails arrived. That file is what a new extractor gets written from. It is a POST rather than a link, for the reason the Backup export is: what comes back is other people's mail, and a URL that produced it could be fetched with your own cookies from a page somewhere else.

Downloading changes nothing. A report counts as handled when you press Mark handled, not when it has been exported — otherwise a second export would come back empty and the work would be recorded as done by somebody who had not started it. Delete handled reports then sweeps the done pile; it appears only when there is something in it, and names how many.

Integrations

Two things live here: which file services this installation offers, and the OAuth applications users sign into their mail with.

Mail sign-in holds the Google and Microsoft client id and secret, the Microsoft tenant, and for Gmail the Pub/Sub topic and push verification token. Leaving any of them empty falls back to the matching environment variable — GOOGLE_OAUTH_*, MICROSOFT_OAUTH_*, GMAIL_PUBSUB_* — so an installation already configured that way keeps working untouched.

Calendar access needs nothing extra here. It rides the same sign-in: enable the Google Calendar API and add the calendar scope to the consent screen, or add the Calendars.ReadWrite delegated permission to the Entra registration. Without it, mail keeps working and calendars simply do not appear.

Services lists every file provider, whether or not plMail can talk to it yet. Each has:

  • Offer this service to users — off means nobody can connect to it and it stays out of the compose and save-to menus.
  • Client ID and client secret, for the OAuth ones, with the exact Redirect URI to paste into the provider's console.
  • Server address, for the self-hosted ones. Leave it empty and each user enters their own; set it and every user is pinned to that server.

Reuse Gmail credentials and its Microsoft equivalent copy a client id and secret across server-side, without ever showing the secret. One Google Cloud project also covers Drive and Photos; one Entra registration also covers OneDrive. Copying the credential does not grant the extra permission those services need — that is still a change at the provider.

Secrets are write-only throughout: the form shows whether one is on file, never what it is, and submitting with the field blank keeps the stored one. That is what lets an administrator change a base URL without re-pasting a secret they no longer have. Clearing one is an explicit checkbox, which only appears when there is something to clear.

Providers plMail cannot talk to yet are listed anyway, greyed, with their setup notes readable. The credentials can be registered now and are used as soon as support ships.

Per-provider setup steps are on Google, Microsoft and, for the calendar side, CalDAV.

Push

One screen, and it is only about Firebase. Web Push needs nothing here: its VAPID keys are environment variables minted once by app:push:generate-vapid-keys, and they serve browsers, the installed PWA and UnifiedPush distributors alike. A settings page showing them read-only beside an editable Firebase key would suggest they can be changed from here.

Firebase Cloud Messaging is how a native Android app receives notifications in the background, because Android has no other push service and a plain Android app cannot speak Web Push. It is optional in the fullest sense — a user running a UnifiedPush distributor needs none of it, and the browser app never touches it.

Two files, and neither is any use alone:

  • The service-account key, from Firebase console → Project settings → Service accounts → Generate new private key. This is how the server sends. It is stored encrypted with everything else this install holds, and never shown again.
  • google-services.json, from Project settings → Your apps → the Android app. This is how the app initialises Firebase. The plMail Android app is one build published to every installation while every installation has its own Firebase project, so it cannot be compiled in — the values are published in the JMAP session instead and the app builds its FirebaseOptions at runtime. They ship inside every Firebase APK and are public by nature, so they are stored in clear.

A pair from two different projects is refused, saying which is which. Nothing downstream can detect that mistake: the app registers happily against one project, the server sends happily to the other, every message is rejected into a log file and the user's symptom is that notifications do not work. This screen is the only place both halves are in one person's hands.

The same goes for the wrong file. The Firebase console offers four downloads that are all valid JSON, so a rejection names the keys the file is missing rather than saying it is invalid.

The toggle is separate from the credentials, so switching FCM off is not the same as losing the key. It cannot be turned on until both files are present — enabling early would advertise FCM to every client and then refuse every registration, which a client cannot tell apart from a bug at its own end. The chip beside the heading distinguishes Live, Configured, switched off, Half configured and Not configured for exactly that reason.

Nothing here needs a restart. That is the whole reason this is a database row rather than an environment variable.

Recent deliveries

Under the Firebase form, and it covers both transports: every attempt to wake a device, newest first, filterable by user, transport and outcome. It exists because push was the one thing this server did that left no trace — a notification that never arrived looked exactly like one the user did not notice, and the only evidence was a log line written when something went wrong.

Each row is one attempt: when, which user, which device (the id the client chose for itself), the transport, what was being carried, how long it took, and what the far end said.

Outcome Means
Accepted The transport took it. Not proof it was displayed — proof it was handed over
Failed Refused or unreachable, and the device was kept. The detail column has the status or the FCM error name
Device dropped The address proved permanently dead (a 410, or UNREGISTERED) and the subscription was deleted. This row is the only explanation of why the device disappeared from the user's list
Skipped Nothing was sent: the transport is not configured, or the row cannot address it. Not a failure — a deployment that has not been finished

The Skipped distinction is the one to read carefully. An install with no VAPID keys, or with Firebase switched off, produces a skip per device per state change, and that is a configuration answer rather than a broken device.

What was pushed is deliberately not recorded. The log holds the payload's type — StateChange or PushVerification — and nothing else of it. A StateChange names the accounts and state tokens that moved, so keeping it would turn this table into a retained, admin-readable index of when each person's mail arrives, which is a bigger thing than the question it would help answer.

Users see their own half of this, without the other users, in Settings → Notifications: each registered device with its transport, whether the verification handshake completed, and its last delivery.

Retention is 30 days, swept nightly by app:monitoring:prune; --push-days=N changes it.

Users

A searchable, paged list of everyone who can sign in, with when they last signed in and whether they have two-factor authentication on.

Add user takes an email address, a name, an initial password of at least twelve characters, and an Administrator checkbox. The length floor is higher than you might set for yourself on purpose: the person choosing this password is not the person who will use it, so length is the only control available.

Three things an administrator deliberately cannot do, all for one reason — an admin session must not become a second way into every mailbox on the install:

  • Change an existing user's password. The field exists on create and not on edit. Someone who has not signed in yet has no mail, so setting their initial password discloses nothing; changing it afterwards would.
  • Remove anyone's second factor. That is app:user:2fa-disable on the console, and only there.
  • Read anyone's mail. Nothing in this area touches an account or a message.

Remove user is a soft delete. The address and the display name are freed — the address is unique, so leaving it would stop the same person ever being re-added — and the row stops being able to authenticate, but the accounts, messages, labels and app passwords hanging off it stay where they are. A cascade from a misclick in an admin panel is not a recoverable mistake.

Two removals are refused outright: your own account, and the last remaining administrator. The second is the one that looks fine at the time — an admin removes a colleague, and nobody notices until the next time somebody needs the panel. Demoting yourself, or the last administrator, is refused for the same reason.

Backup

Everything this installation is configured with, in one password-encrypted file — and the way to put it back. Not mail: no messages, no calendars, no user accounts. See Configuration backup for the whole story, including the file format, which is documented so the file never depends on plMail being able to run.

Export asks for a password twice and downloads plmail-config-<date>.backup. The password is never stored and there is no recovery for it. The file carries every secret the install has, including the decrypted contents of the encrypted columns — that is deliberate, because the install it will be opened on has a different APP_ENCRYPTION_KEY and ciphertext would be unreadable there. Keep the file as carefully as you keep the key itself.

Import takes the file and the password and shows a review before anything is written. The review has three parts, in descending order of how much they should concern you:

  • What plMail writes itself, which is nearly everything: the Firebase project, the mail OAuth registrations and the integration providers, re-encrypted with this install's key and live on commit — plus the JWT keypair and every environment value, into var/secrets/generated.env and the files beside it. Those are the files the container entrypoint reads when it starts, so they are in place at once and in force after one restart. The page says that once, for the whole list.
  • What is still yours to do, which on a stock stack is two or three names at most: a value your compose file pins to something non-empty overrides the restored one at the next start, and POSTGRES_PASSWORD belongs to a role inside a database plMail is only a client of. Each comes back with the exact line and the reason it is here.
  • Worth knowing: APP_ENCRYPTION_KEY, which is deliberately not written, because the credentials the import just wrote are encrypted with the key currently in force.

Each line also says whether it is new here, replaces something different, or already matches. That middle state is worth stopping at: restoring onto a running install replaces live credentials.

The same import runs during first-time setup, below the account form on /install, so a new installation can be brought up configured before its administrator exists — upload and password are the whole job there, and the instance comes up as the old one after a restart. That entry point closes with a 404 the moment the first account is created.

Reset

app:reset, as buttons. Six stages, each deleting everything the one above it does and more:

Stage Deletes
Synced mail Messages, threads, parts and anything queued. Accounts, folders and labels stay; sync cursors are cleared
Mail and mailbox structure The above, plus folders and labels. The next sync rebuilds both
Mail, structure and contacts The above, plus harvested contacts. Address autocomplete starts empty
Mail, structure, contacts and accounts The above, plus the accounts and their aliases. Every mailbox password and OAuth connection has to be set up again. Your own sign-in is untouched
Full reset Every user, you included, every stored password, and the files on disk
Full reset and new secrets The above, and the generated secrets

The top four are confirmed with a dialog: the worst case is a resync, which costs hours and no information. The bottom two require the instance name — the host plMail answers on — to be typed into the form, and that is checked on the server, not in JavaScript. A click on its own is not enough for an operation nothing brings back.

A full reset does not redirect anywhere. It cannot: the user who performed it no longer exists, so there is no page left behind the firewall, and the response itself is the only chance to say what happened and what still needs doing.

Monitoring data is kept by every stage. Clear the logs from Logs, and stale heartbeats from Maintenance.

Where to read further

Things that bite

The reported-mail export is other people's mail. It is a plain, unencrypted JSON file holding the text of messages your users decided to hand over — unlike the config backup, which is encrypted with a password you choose. Keep the file where you would keep the mailbox itself, and delete it when the work is done.

Rotating the secrets without restarting the whole stack breaks half of it. Every other service keeps the old APP_ENCRYPTION_KEY in process memory until it restarts, so anything saved in the meantime becomes unreadable to the services that did not. The panel says so; the restart is not optional.

POSTGRES_PASSWORD is never rotated, deliberately. Postgres was initialised with it and keeps its own copy, so regenerating it would leave plMail unable to log in to the database it just reset. Changing that one means wiping the database volume, which the panel cannot do.

The maintenance buttons queue work, they do not do it. Nothing happens if no worker is consuming — the scheduler and worker containers have to be running. Without them, none of the scheduled sweeps fire either.

A worker holding stale Doctrine metadata survives a migration and fails oddly. Long-running processes cache mappings for their whole lifetime, so a column added by a migration is invisible to them until Restart workers. This is the thing to try first when a queue starts failing straight after an upgrade.

Clearing logs deletes what the filter matches, not just the page. The count in the confirmation is the real number.

Successful Gmail pushes are invisible at the default log level. They are logged at info and not stored, so "no events" in the webhook panel does not mean "nothing was delivered" until APP_DB_LOG_LEVEL=info is set.

Enabling pg_stat_statements from the panel starts collection from that moment. Queries from before it will never appear, so an empty panel immediately afterwards is expected.

Copying credentials with "Reuse …" does not grant the extra permission. It copies a client id and secret and nothing else; the scope or delegated permission still has to be added at the provider, or connecting fails at consent time.

You cannot remove the last administrator, or yourself. Both are refused rather than warned about, which occasionally reads as a broken button.

Rotating APP_ENCRYPTION_KEY takes the Firebase key with it. The service-account JSON is stored encrypted like every other credential, so a changed key makes it unreadable — push over FCM goes quietly off, the session starts saying fcm: false, and the fix is to paste the key again. The google-services values survive, because they were never encrypted.

Replacing only one of the two Firebase files is refused if the projects differ. That is deliberate and it is the message to read rather than work around: a service-account key from one project with a google-services.json from another produces an installation where everything looks configured and nothing is ever delivered.

An empty delivery log means nothing was attempted, not that everything worked. Push only fires when a state actually changes and only to devices that finished the verification handshake, so a fresh install with one browser registered can sit empty for hours and be perfectly healthy. The panel distinguishes "nothing matches this filter" from "nothing has ever been pushed" for exactly this reason — the second line is the one that means check the configuration above.

"Accepted" is not "delivered to a human". It means the push service took the message. A phone in a doze state, a browser that revoked permission at the OS level, and a notification the user swiped away all look identical from here, and the next thing to check is the device rather than this page.

The delivery log is pruned at 30 days, so a device retired six weeks ago has no row explaining it. If somebody reports notifications stopping "a while ago", look before assuming nothing was ever tried.