🧩 Introduction

Running a Postfix + Dovecot email server? Over time, your users’ Trash and Spam folders fill up, and even though emails are “deleted,” they still live on the disk, eating up precious space.

This post shows you how to:

  • ✅ Automatically move old spam to the trash
  • Delete emails from the trash after a configurable period
  • Recalculate quotas
  • Log per-user stats on deleted emails and recovered disk space

It’s a plug-and-play Bash script designed for Maildir format with virtual users under /var/vmail.


📦 What the Script Does (In Plain English)

Here’s a breakdown of what the script will do once set up:

🔧 1. Set Cleanup Rules

JUNK_AGE_DAYS="${JUNK_AGE_DAYS:-7}"
TRASH_AGE_DAYS="${TRASH_AGE_DAYS:-14}"
  • JUNK_AGE_DAYS: How many days old a message must be in the Spam/Junk folder to be moved to Trash.
  • TRASH_AGE_DAYS: How old messages in the Trash folder must be before they get permanently deleted.

You can override these on the fly:

JUNK_AGE_DAYS=10 TRASH_AGE_DAYS=30 /opt/mail/mail-cleanup.sh

📜 2. Log and Count Everything

The script prepares:

  • A log file at /var/log/mail_cleanup.log
  • A temporary report file for user-wise output
  • Several counters to track how many messages are moved/deleted and how much space is recovered

👥 3. Get All Mail Users

doveadm user '*@*'

Dovecot returns a list of all valid email accounts configured on the server. The script loops through this list for cleanup.


🗃️ 4. Move Old Spam/Junk to Trash

doveadm move -u "$user" Trash mailbox "$MAILBOX_SPAM" savedbefore "${JUNK_AGE_DAYS}d"

For each user:

  • Finds their Spam or Junk mailbox (some clients name it differently)
  • Searches for emails older than JUNK_AGE_DAYS
  • Moves them to the Trash folder

🧹 5. Delete Old Trash

doveadm expunge -u "$user" mailbox "Trash" savedbefore "${TRASH_AGE_DAYS}d"

Next, the script:

  • Finds emails in the Trash folder older than TRASH_AGE_DAYS
  • Deletes them using doveadm expunge
  • Recalculates the mailbox index and size using: doveadm index -u "$user" "*"

📊 6. Recalculate Quotas

doveadm quota recalc -A

After deletion, the script recalculates quotas for all users so their disk usage reflects reality.


📈 7. Display Per-User and Global Summary

Finally, the script prints:

  • Per-user stats: number of deleted messages and space saved
  • Total stats: total messages processed and cumulative space recovered

🛠️ How to Install the Script

📁 Step 1: Save the Script

Create a file named:

/opt/mail/mail-cleanup.sh

And paste this code inside:

#!/bin/sh

# Configurable retention thresholds
JUNK_AGE_DAYS=${JUNK_AGE_DAYS:-7}
TRASH_AGE_DAYS=${TRASH_AGE_DAYS:-14}

LOG="/var/log/mail_cleanup.log"
TMP_REPORT="/tmp/dovecot_purge_report.tmp"
: > "$TMP_REPORT"

# Global counters
total_users=0
total_spam_matched=0
total_spam_moved=0
total_spam_total=0
total_trash_matched=0
total_trash_deleted=0
total_trash_total=0
total_space_saved=0

RUN_TIME_START=$(date "+%a %b %d %T %Z %Y")
printf "\n[INFO] === Trash Cleanup Run Started: %s ===\n" "$RUN_TIME_START"
printf "[INFO] === Trash Cleanup Run Started: %s ===\n" "$RUN_TIME_START" >> "$LOG"
printf "[INFO] Junk -> Trash cutoff: %s days\n" "$JUNK_AGE_DAYS" | tee -a "$LOG"
printf "[INFO] Trash deletion cutoff: %s days\n" "$TRASH_AGE_DAYS" | tee -a "$LOG"

printf "\n[PHASE 1] Gathering Dovecot Users...\n"
USER_LIST=$(doveadm user '*@*')

if [ -z "$USER_LIST" ]; then
echo "[ERROR] No users returned by 'doveadm user'. Exiting."
exit 1
fi

total_users=$(echo "$USER_LIST" | wc -l | tr -d '[:space:]')
printf "[INFO] Total users found: %s\n" "$total_users"

# PHASE 2: Move old Spam/Junk to Trash
printf "\n[PHASE 2] Moving %s+ day-old messages from Spam/Junk to Trash...\n" "$JUNK_AGE_DAYS"
while IFS= read -r user; do
MAILBOX_SPAM=$(doveadm mailbox list -u "$user" | grep -Ei '(^Spam$|^Junk$)' | head -n1)

if [ -n "$MAILBOX_SPAM" ]; then
TOTAL=$(doveadm search -u "$user" mailbox "$MAILBOX_SPAM" all | wc -l | tr -d '[:space:]')
total_spam_total=$(expr "$total_spam_total" + "$TOTAL")

MATCHED=$(doveadm search -u "$user" mailbox "$MAILBOX_SPAM" savedbefore "${JUNK_AGE_DAYS}d" all | wc -l | tr -d '[:space:]')
[ -z "$MATCHED" ] && MATCHED=0
total_spam_matched=$(expr "$total_spam_matched" + "$MATCHED")

if [ "$MATCHED" -gt 0 ]; then
printf "[ACTION] %s: Moving %s messages from '%s' to Trash...\n" "$user" "$MATCHED" "$MAILBOX_SPAM"
doveadm move -u "$user" "Trash" mailbox "$MAILBOX_SPAM" savedbefore "${JUNK_AGE_DAYS}d" all
total_spam_moved=$(expr "$total_spam_moved" + "$MATCHED")
echo "[INFO] $user: Spam moved." >> "$LOG"
else
echo "[INFO] $user: No $MAILBOX_SPAM messages older than $JUNK_AGE_DAYS days."
fi
else
echo "[INFO] $user: No Spam/Junk mailbox found."
fi
done <<EOF
$USER_LIST
EOF

# PHASE 3: Expunge old Trash and Rebuild Indexes
printf "\n[PHASE 3] Expunging %s+ day-old messages from Trash and Rebuilding Indexes...\n" "$TRASH_AGE_DAYS"
while IFS= read -r user; do
TOTAL=$(doveadm search -u "$user" mailbox "Trash" all | wc -l | tr -d '[:space:]')
total_trash_total=$(expr "$total_trash_total" + "$TOTAL")

MATCHED=$(doveadm search -u "$user" mailbox "Trash" savedbefore "${TRASH_AGE_DAYS}d" | wc -l | tr -d '[:space:]')
[ -z "$MATCHED" ] && MATCHED=0
total_trash_matched=$(expr "$total_trash_matched" + "$MATCHED")

if [ "$MATCHED" -gt 0 ]; then
DOMAIN_PART=$(echo "$user" | cut -d@ -f2)
USER_PART=$(echo "$user" | cut -d@ -f1)
MAILDIR_PATH="/var/vmail/$DOMAIN_PART/$USER_PART/.Trash/cur"

SIZE_BEFORE=$(du -sb "$MAILDIR_PATH" 2>/dev/null | cut -f1 | tr -d '[:space:]')
[ -z "$SIZE_BEFORE" ] && SIZE_BEFORE=0

printf "[ACTION] %s: Purging %s messages from Trash...\n" "$user" "$MATCHED"
doveadm expunge -u "$user" mailbox "Trash" savedbefore "${TRASH_AGE_DAYS}d"
total_trash_deleted=$(expr "$total_trash_deleted" + "$MATCHED")

printf "[ACTION] %s: Rebuilding mailbox indexes...\n" "$user"
doveadm index -u "$user" "*"

SIZE_AFTER=$(du -sb "$MAILDIR_PATH" 2>/dev/null | cut -f1 | tr -d '[:space:]')
[ -z "$SIZE_AFTER" ] && SIZE_AFTER=0

SPACE_SAVED=$(expr "$SIZE_BEFORE" - "$SIZE_AFTER")
total_space_saved=$(expr "$total_space_saved" + "$SPACE_SAVED")

if command -v numfmt >/dev/null 2>&1; then
SPACE_HR=$(numfmt --to=iec-i --suffix=B "$SPACE_SAVED")
else
SPACE_HR="${SPACE_SAVED} bytes"
fi

printf "[RESULT] %s: Deleted %s emails, Freed %s\n" "$user" "$MATCHED" "$SPACE_HR" | tee -a "$TMP_REPORT"
else
echo "[INFO] $user: No Trash messages older than $TRASH_AGE_DAYS days."
fi
done <<EOF
$USER_LIST
EOF

# PHASE 4: Recalculate Quotas
printf "\n[PHASE 4] Recalculating quota across all users...\n"
doveadm quota recalc -A
echo "[INFO] Quota recalculation complete." | tee -a "$LOG"

# PHASE 5: Per-User Summary
printf "\n[PHASE 5] Final Cleanup Summary (Per-User)\n"
printf "\n[SUMMARY]\n" | tee -a "$LOG"
cat "$TMP_REPORT" | tee -a "$LOG"
rm -f "$TMP_REPORT"

# Final readable disk space
if command -v numfmt >/dev/null 2>&1; then
TOTAL_SPACE_HR=$(numfmt --to=iec-i --suffix=B "$total_space_saved")
else
TOTAL_SPACE_HR="${total_space_saved} bytes"
fi

# PHASE 6: Global Totals
printf "\n[PHASE 6] Global Totals Summary\n"
echo "-----------------------------------------------------"
printf "Total Users Processed : %s\n" "$total_users"
printf "Total Spam Messages (All Ages) : %s\n" "$total_spam_total"
printf "Matched Spam (>%s days) : %s\n" "$JUNK_AGE_DAYS" "$total_spam_matched"
printf "Moved to Trash : %s\n" "$total_spam_moved"
printf "Total Trash Messages (All Ages) : %s\n" "$total_trash_total"
printf "Matched Trash (>%s days) : %s\n" "$TRASH_AGE_DAYS" "$total_trash_matched"
printf "Deleted from Trash : %s\n" "$total_trash_deleted"
printf "Total Disk Space Recovered : %s\n" "$TOTAL_SPACE_HR"
echo "-----------------------------------------------------"

{
echo ""
echo "[TOTALS] Users processed: $total_users"
echo "[TOTALS] Spam total: $total_spam_total"
echo "[TOTALS] Spam matched >$JUNK_AGE_DAYS days: $total_spam_matched"
echo "[TOTALS] Spam moved to Trash: $total_spam_moved"
echo "[TOTALS] Trash total: $total_trash_total"
echo "[TOTALS] Trash matched >$TRASH_AGE_DAYS days: $total_trash_matched"
echo "[TOTALS] Trash deleted: $total_trash_deleted"
echo "[TOTALS] Disk space saved: $TOTAL_SPACE_HR"
} >> "$LOG"

RUN_TIME_END=$(date "+%a %b %d %T %Z %Y")
printf "\n[INFO] === Trash Cleanup Run Completed: %s ===\n" "$RUN_TIME_END"
echo "[INFO] === Trash Cleanup Run Completed: $RUN_TIME_END ===" >> "$LOG"

Then run:

chmod +x /opt/mail/mail-cleanup.sh

⏰ Step 2: Automate with Cron

Open root’s crontab:

crontab -e

Add this to run daily at 3:00 AM:

0 0 * * * bash /opt/mail/mail-cleanup.sh

🔍 Sample Output

[INFO] === Trash Cleanup Run Started: Sat Jul 20 03:00:01 CEST 2025 ===
[ACTION] info@example.com: Purging 53 messages from Trash...
[RESULT] info@example.com: Deleted 53 emails, Freed 72.1MiB
[SUMMARY]
info@example.com: Deleted 53 emails, Freed 72.1MiB
[INFO] === Trash Cleanup Run Completed: Sat Jul 20 03:00:23 CEST 2025 ===

🎯 Final Thoughts

This script provides a safe, configurable, and fully-automated way to clean up user mailboxes and reclaim disk space — while logging every action for transparency.

Let your mail server stay lean and efficient, even if users don’t empty their trash.