Shore Up
MailLinux

Automate Cleanup of Stalled Messages in the Postfix Queue

Ketan Aagja9 min read
No ratings yet

Postfix is good at retrying mail on its own, but over time a busy server collects two kinds of junk in its spool: messages that have been deferred for days and will never deliver, and partial or misplaced files left behind by a crash or an abrupt restart. This guide sets up a safe, scheduled cleanup for both — using Postfix's own tools, not manual file surgery.

Before you run this

What this does and why. It does two separate jobs. First, it runs Postfix's built-in queue structure repair to fix or remove incomplete and misplaced queue files left over from a crash. Second, it deletes messages that have been sitting in the deferred queue longer than a cutoff you choose (default five days), because those are almost always dead mail that Postfix will keep retrying pointlessly.

Privileges. postsuper must run as root — it modifies files under /var/spool/postfix. postqueue (used only to read the queue) can run as an ordinary user, but since the whole script needs root for the delete step, run it as root or via sudo. Schedule it from root's crontab.

Test first. Read the script. The age-based deletion ships with DRY_RUN=1, so by default it prints what it would remove and deletes nothing. Run it in dry-run mode on your real server and eyeball the list before you ever set DRY_RUN=0. If you have a spare VM or test instance, confirm the parsing there first.

This is irreversible. postsuper -d permanently deletes mail. There is no undo and no trash. If a message is deleted, the sender's content is gone. If you are unsure whether a batch is safe to remove, use the hold variant described at the end instead of delete — holding is fully reversible.

Not a firewall change, but still a live service. You are touching a running mail system. Do the first real run in a quiet window, keep an eye on /var/log/mail.log, and make sure you have a current backup of /etc/postfix and, ideally, the spool before a large delete.

Assumptions

  • Debian 12 / Ubuntu 22.04, Postfix 3.x (3.1 or newer — this is what gives us postqueue -j JSON output), systemd, bash, and python3 present (it ships by default on both).
  • Postfix stores its queue under /var/spool/postfix (the default). If you moved queue_directory, the tools still work — they read the compiled-in location.
  • On RHEL/Alma the commands are identical; only the package manager and log file path (/var/log/maillog) differ.

Know the queues before you touch them

Postfix splits the spool into several queues. The ones that matter here:

  • deferred — mail that failed a delivery attempt and is waiting to retry. This is where stalled mail accumulates.
  • incoming / active — mail in flight; leave these alone.
  • corrupt — files Postfix could not parse. Rare, and worth inspecting by hand rather than mass-deleting.

Always look before you prune:

# Summary of what's queued and why
postqueue -p | tail -n 20

# Age/size breakdown per queue — very useful for choosing a cutoff
qshape deferred

qshape ships with Postfix; if it's missing it lives under the Postfix examples/aux tools for your distro. The columns are message age buckets, so you can see at a glance whether your deferred mail is hours old or weeks old.

Step 1 — Repair incomplete queue files

For partial files, wrong-place files, and stale temporary files left by a crash, use postsuper's own repair rather than deleting anything by hand:

# Structure check and repair: fix/relocate malformed queue files
sudo postsuper -s

# Purge leftover temporary files from an unclean shutdown
sudo postsuper -p

-s renames or moves files that ended up in the wrong queue and removes ones it can't repair; -p clears stale temp files. Check the exact behaviour for your version with man postsuper before relying on it — the manual is the authority, and these flags are stable across Postfix 3.x. This part is safe to run routinely; it only acts on genuinely broken files, not valid mail.

Step 2 — Delete old deferred mail by age

This is the destructive part, so it's gated behind a dry-run flag. It reads the queue as JSON (postqueue -j, one JSON object per line), keeps only deferred messages whose arrival_time is older than the cutoff, and feeds those queue IDs to postsuper -d -, which reads IDs from standard input.

#!/usr/bin/env bash
# /usr/local/sbin/postfix-queue-cleanup.sh
# Delete deferred Postfix messages older than MAX_AGE_DAYS.
set -euo pipefail

MAX_AGE_DAYS=5      # change to your policy
DRY_RUN=1          # 1 = show only, 0 = actually delete

cutoff=$(( $(date +%s) - MAX_AGE_DAYS * 86400 ))

# Extract queue IDs of deferred messages older than the cutoff.
# postqueue -j emits one JSON object per message (Postfix 3.1+).
ids=$(postqueue -j | python3 -c '
import sys, json
cutoff = int(sys.argv[1])
for line in sys.stdin:
    line = line.strip()
    if not line:
        continue
    m = json.loads(line)
    if m.get("queue_name") == "deferred" and m.get("arrival_time", 0) < cutoff:
        print(m["queue_id"])
' "$cutoff")

if [ -z "$ids" ]; then
    echo "Nothing older than ${MAX_AGE_DAYS} days in the deferred queue."
    exit 0
fi

count=$(printf '%s\n' "$ids" | wc -l)
echo "${count} deferred message(s) older than ${MAX_AGE_DAYS} days."

if [ "$DRY_RUN" -eq 1 ]; then
    echo "DRY RUN — would delete these queue IDs:"
    printf '%s\n' "$ids"
    exit 0
fi

# postsuper -d - reads queue IDs from stdin, one per line.
printf '%s\n' "$ids" | postsuper -d -
echo "Deleted ${count} message(s)."

Install and test it:

sudo install -m 0750 postfix-queue-cleanup.sh /usr/local/sbin/postfix-queue-cleanup.sh
sudo /usr/local/sbin/postfix-queue-cleanup.sh        # dry run — read the list

Only after you're satisfied with the list, edit the script to set DRY_RUN=0 and run it again.

The field name arrival_time and the queue_name/queue_id keys come from Postfix's JSON queue format; if your postqueue -j output differs, print one line (postqueue -j | head -1) and match the keys before trusting the filter. An older, pre-JSON alternative parses mailq output with awk — it exists and still works, but the JSON path is cleaner and I'd use it on any 3.1+ server.

Step 3 — Schedule it

Put it in root's crontab so the delete step has the privileges it needs:

sudo crontab -e
# Repair queue structure and prune old deferred mail, 03:30 daily
30 3 * * * /usr/bin/postsuper -s >/dev/null 2>&1 ; /usr/local/sbin/postfix-queue-cleanup.sh >> /var/log/postfix-cleanup.log 2>&1

A systemd timer is the other common approach and gives you journalctl logging and OnCalendar scheduling; cron is the mainstream choice here and is enough for this job.

Verify it worked

After a run, confirm the queue shrank and nothing broke:

# Count of messages still queued
postqueue -p | tail -n 1

# Confirm the deferred queue no longer holds ancient mail
qshape deferred

# Watch for errors around the cleanup time
sudo tail -n 50 /var/log/mail.log

A specific deleted ID should no longer appear in postqueue -p. The cron log at /var/log/postfix-cleanup.log records each run's count.

Undo and the safer alternative

A completed postsuper -d cannot be undone — that's why the dry-run default matters. If you want a reversible workflow, hold the messages instead of deleting them. Swap the final line of the script to pipe the IDs to postsuper -h -, which moves them to the hold queue where they stop retrying but stay on disk. Inspect them later with postqueue -p, and release anything worth keeping back into normal flow:

# Release everything from the hold queue
sudo postsuper -H ALL

That gives you a review step between "stalled" and "gone" — the right default when you're not yet sure five days is the correct cutoff for your mail flow. Confirm exact -h / -H syntax in man postsuper before wiring it into the scheduled job.

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.

Alert on a Growing Postfix Mail Queue with a Cron Script

A mail queue that quietly grows is one of those failures you notice late — usually when a user asks why their mail from three hours ago hasn't arrived. A backlog can mean a dead relay host, a DNS problem, a downstream server rejecting everything, or an outbound spam run from a compromised account. This guide sets up a small cron job that counts the queue and emails you when it crosses a threshold, so you hear about it early.

9 min read

Email a Daily Postfix Delivery Summary with Bash

This guide sets up a small bash script that reads yesterday's Postfix log, runs it through pflogsumm to build a delivery summary (messages received/delivered/deferred/ bounced, top senders, deferral reasons), and emails that summary to you once a day from cron.

8 min read

Audit Postfix for Open Relay Vulnerabilities with Bash

This script reads your Postfix relay-control settings with postconf and flags configurations that could let outsiders relay mail through your server — the classic "open relay" that gets you onto blocklists and turns your box into a spam cannon. It is read-only. It changes nothing, writes nothing, and reloads nothing , so there is no config to back up and nothing to roll back. It simply reports.

9 min read

Monitor Dovecot Process Memory and Alert on Runaway Processes

This guide sets up a small bash script, run on a schedule, that reads the resident memory (RSS) of every process inside the dovecot.service cgroup and warns you when one crosses a threshold you set. It only reads and reports — it writes a line to syslog and optionally sends an email. It does not kill processes, change Dovecot config, or touch any mailbox.

8 min read