Mail queue (viewer, hold / release / delete)
Purpose. “What is stuck?” answered without SSH. The delivery log says what already happened to mail Postfix tried to deliver; this says what is waiting, why, and lets an administrator hold, release or delete it. A message scheduled with the webmail’s Send later is not here until it is due: it waits in the database and is submitted by the API when its time comes, or when the API is next up if it was down then (Compose, send and drafts).
Behaviour.
- The queue agent (
docker/common/queue-agent.sh) runs inside the postfix container beside the config watcher and is the only channel to the queue:postqueueandpostsuperlive there, the API holds no Docker socket and must not grow one. The API writes a command directory into themailconfigvolume (postfix/queue/pending/<id>/— anOPfile and, for a command, anIDSfile) with the same atomicrenamethe config handshake uses, and readspostfix/queue/results/<id>.jsonback. GET /queue?queue=&q=&limit=&offset=returns the queue as it is now, oldest first, withtakenAt, per-queue counts andreachable. There is no table behind it and nothing is cached: a stale answer about a queue is worse than a slow one. The UI re-reads every 15 seconds and after every command.- Six queues, four tiles.
incoming(accepted, not yet sorted) ·active(being delivered now) ·deferred(failed, will be retried) ·hold(held by an administrator).corruptandmaildropare shown in the table if anything lands in them but get no tile — a permanent pair of zeroes teaches people to stop reading the row. - Each row carries the queue id, arrival time, size, envelope sender, every recipient and Postfix’s
own delay reason, lifted from the first recipient that has one so the list can show one line per
message.
<>in the From column is the null sender: a bounce Postfix generated itself. POST /queue/actions {action, ids}runspostsuper -h(hold),-H(release) or-d(delete) and returns Postfix’s own summary line plus the count parsed out of it. Ids go topostsuperon stdin, never as arguments, and are validated by the request schema, by the route and again inside the agent.- Release is the inverse of hold, not “deliver now”. A released message returns to
deferredand waits for the next queue run. Forcing delivery (postqueue -i/-f) is a different verb and is deliberately not offered — a stuck queue is usually stuck for a reason, and flushing it is how a blocklisted server gets more listed. - Every command is audited (
queue.hold,queue.release,queue.delete) with the ids it was given and the output it got, because a deleted message leaves no other trace anywhere. domain_adminsees only its own mail — an entry whose sender or any recipient is one of its domains, the same rule the delivery log uses — and may act only on what it can see. A command naming anything else is refused whole (403 queue_forbidden), not half-applied.- The copilot’s
getQueuetool reads the same snapshot, scoped to AI-enabled domains (AI admin copilot).
Where it is. The mailqueue Compose volume, mounted at /var/spool/postfix. mailctl doctor
reports its depth as an info check — or as a warn reading “could not be read”, rather than
“empty”, when Postfix cannot be asked — and mailctl upgrade prints the same before it touches
anything.
Configuration. API: QUEUE_TIMEOUT_MS (10 000) — how long to wait for the agent before answering
“unreachable”; deliberately shorter than ENGINE_TIMEOUT_MS, because this is a page load rather than
a render. Postfix container: QUEUE_MAX_ENTRIES (5000), the cap on rows one listing returns. Neither is passed
through by compose/compose.yaml, so setting them in .env changes nothing; they are code defaults.
Limits & edge cases.
- Deleting is irreversible and silent. Postfix has no undelete, the sender is not told, and nothing appears in the delivery log because no delivery was attempted. Holding is the reversible option and the UI says so at the point of the click.
- The queue survives its container but not its volume.
/var/spool/postfixis themailqueuevolume as of 2026-09-07, so recreating the postfix container — whichmailctl upgradedoes — keeps everything queued,holdincluded.docker compose down -vand a lost volume still destroy it, and the queue is not part ofmailctl backup, which covers the database, the maildirs and.env. - A queue longer than
QUEUE_MAX_ENTRIESis truncated, and the response says so.queueTotalis still exact (the agent counts before it caps), but the per-queue tiles and the listing describe only what was read; adomain_adminmay find a message beyond the cap invisible and therefore unactionable. - An unreachable agent answers
reachable: falsewith an empty list, not an error, so the screen can explain itself; commands against it are a502 queue_unavailable. A server upgraded from a build predating the agent reads as unreachable until its postfix container is recreated. - A message in
maildrophas no stable id yet.sendmailwrites intomaildropandpickupturns it into a queued message moments later;postqueuereports it in the meantime, but the id it shows is the maildrop file’s name, not a queue id, and it is replaced. A hold or delete aimed at one of those races and reports zero messages changed. It is a sub-second window on a working server, and only reachable at all for locally-submitted mail. - Counts and rows are two different reads of one snapshot: the tiles keep counting the whole visible queue while a filter is on, or clicking one tile would empty the others.
- The queue is not the delivery log and neither substitutes for the other. A message absent from the queue was not necessarily delivered — it may have bounced hours ago (Delivery log & bounce classification).