Skip to content

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: postqueue and postsuper live there, the API holds no Docker socket and must not grow one. The API writes a command directory into the mailconfig volume (postfix/queue/pending/<id>/ — an OP file and, for a command, an IDS file) with the same atomic rename the config handshake uses, and reads postfix/queue/results/<id>.json back.
  • GET /queue?queue=&q=&limit=&offset= returns the queue as it is now, oldest first, with takenAt, per-queue counts and reachable. 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). corrupt and maildrop are 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} runs postsuper -h (hold), -H (release) or -d (delete) and returns Postfix’s own summary line plus the count parsed out of it. Ids go to postsuper on 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 deferred and 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_admin sees 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 getQueue tool 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/postfix is the mailqueue volume as of 2026-09-07, so recreating the postfix container — which mailctl upgrade does — keeps everything queued, hold included. docker compose down -v and a lost volume still destroy it, and the queue is not part of mailctl backup, which covers the database, the maildirs and .env.
  • A queue longer than QUEUE_MAX_ENTRIES is truncated, and the response says so. queueTotal is still exact (the agent counts before it caps), but the per-queue tiles and the listing describe only what was read; a domain_admin may find a message beyond the cap invisible and therefore unactionable.
  • An unreachable agent answers reachable: false with an empty list, not an error, so the screen can explain itself; commands against it are a 502 queue_unavailable. A server upgraded from a build predating the agent reads as unreachable until its postfix container is recreated.
  • A message in maildrop has no stable id yet. sendmail writes into maildrop and pickup turns it into a queued message moments later; postqueue reports 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).