Kalau kamu admin Zimbra Mail Server, pasti pernah lihat monitoring mailq atau dashboard Zimbra menampilkan angka deferred email yang tidak kunjung habis, bahkan terus bertambah tiap hari. Setelah dicek satu-satu, penyebabnya ternyata sama: mailbox tujuan (kebanyakan Gmail) sudah penuh, jadi Google membalas dengan bounce message:
The recipient's inbox is out of storage space.
Kabar baiknya: ini bukan masalah di server Zimbra kamu. Kabar kurang enaknya: kalau dibiarkan, Postfix (mesin pengirim email di balik Zimbra) akan terus mencoba mengirim ulang email tersebut selama beberapa hari (default maximal queue lifetime Postfix adalah 5 hari), sehingga antrian deferred menumpuk dan bikin laporan queue jadi berantakan.
Di artikel ini saya akan share script bash yang saya pakai untuk otomatis menghapus email deferred yang bouncenya karena mailbox penuh dan sudah nyangkut lebih dari 24 jam, lengkap dengan error-error yang saya temui saat development-nya (biar kalau kamu ketemu error yang sama, tidak perlu pusing lagi).
Kenapa Tidak Dibiarkan Saja?
Beberapa alasan kenapa email seperti ini sebaiknya dibersihkan lebih cepat daripada menunggu Postfix menyerah sendiri:
- Antrian jadi bersih — memudahkan monitoring, jadi kamu bisa lebih cepat notice kalau ada masalah pengiriman yang beneran perlu ditindak (misal domain di-blacklist, DNS salah, dsb), bukan tenggelam di antara ratusan email "mailbox penuh".
- Resource server lebih hemat — Postfix tetap melakukan percobaan ulang (retry) untuk tiap pesan deferred secara berkala, yang berarti ada beban tambahan walau kecil.
- Realistis — mailbox Gmail yang penuh biasanya tidak akan tiba-tiba kosong dalam hitungan jam. Kalau setelah 24 jam masih gagal dengan alasan yang sama, kemungkinan besar akan tetap gagal sampai limit hari ke-5 juga.
Persiapan
Sebelum mulai, pastikan:
- Kamu punya akses root atau sudo ke server Zimbra.
- Tahu di mana lokasi binary
postqueuedanpostsuperdi server kamu. Default-nya ada di:/opt/zimbra/common/sbin/postqueue /opt/zimbra/common/sbin/postsuper - Backup / catat dulu kalau perlu — walaupun script ini sudah ada mode "cek dulu" sebelum hapus beneran.
Kenalan Dulu dengan Istilahnya
Biar tidak bingung, ini penjelasan singkat istilah yang dipakai:
| Istilah | Penjelasan |
|---|---|
| Queue | Antrian email yang sedang diproses Postfix |
| Deferred | Status email yang gagal terkirim dan akan dicoba lagi nanti |
| Bounce | Pesan error balasan dari server tujuan kenapa email gagal masuk |
postqueue -p |
Perintah untuk melihat isi antrian email (mirip mailq) |
postsuper -d |
Perintah untuk menghapus email tertentu dari antrian |
Script-nya
Simpan kode berikut sebagai deferred-cleanup.sh di server Zimbra kamu, misalnya di /opt/zimbra/scripts/.
#!/bin/bash
#
# deferred-cleanup.sh
#
# Menghapus pesan di deferred queue Zimbra/Postfix yang:
# 1. Alasan bounce-nya mengandung frasa tertentu (default: "out of storage space")
# 2. Sudah berada di queue lebih dari N jam (default: 24 jam)
#
# Jalankan sebagai root:
# sudo ./deferred-cleanup.sh # mode interaktif (tanya konfirmasi)
# sudo ./deferred-cleanup.sh -y # auto-confirm (cocok untuk cron)
# sudo ./deferred-cleanup.sh -n # dry-run, cuma tampilkan tanpa hapus
#
# Bisa juga dijadwalkan lewat cron root, misal tiap jam:
# 0 * * * * /opt/zimbra/scripts/deferred-cleanup.sh -y >> /opt/zimbra/log/bersihkan-deferred.log 2>&1
set -euo pipefail
# ---------- KONFIGURASI ----------
POSTQUEUE_BIN="/opt/zimbra/common/sbin/postqueue"
POSTSUPER_BIN="/opt/zimbra/common/sbin/postsuper"
REASON_PATTERN="out of storage space" # ubah/tambah kalau mau cocokkan pola lain (case-insensitive)
MAX_AGE_HOURS=24
AUTO_YES=0
DRY_RUN=0
LOG_DIR="/opt/zimbra/log" # ubah lewat -L kalau perlu
# ----------------------------------
while getopts "ynL:" opt; do
case $opt in
y) AUTO_YES=1 ;;
n) DRY_RUN=1 ;;
L) LOG_DIR="$OPTARG" ;;
*) echo "Usage: $0 [-y] [-n] [-L log_dir]"; exit 1 ;;
esac
done
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/deferred-cleanup-$(date +%Y%m%d-%H%M%S).log"
if [[ ! -x "$POSTQUEUE_BIN" ]]; then
echo "postqueue tidak ditemukan di $POSTQUEUE_BIN, sesuaikan path-nya." >&2
exit 1
fi
NOW_EPOCH=$(date +%s)
MAX_AGE_SECONDS=$((MAX_AGE_HOURS * 3600))
DELETE_LIST=()
DELETE_INFO=() # baris ringkasan "ID | tanggal | sender -> recipient(s)" untuk ditampilkan ke user
CURRENT_ID=""
CURRENT_DATE=""
CURRENT_SENDER=""
CURRENT_STATUS=""
CURRENT_RECIPIENTS=()
MATCH_REASON=0
EMAIL_REGEX='^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'
process_block() {
if [[ -n "$CURRENT_ID" && "$CURRENT_STATUS" == "deferred" && $MATCH_REASON -eq 1 ]]; then
local ARR_EPOCH
ARR_EPOCH=$(date -d "$CURRENT_DATE" +%s 2>/dev/null || echo "")
if [[ -n "$ARR_EPOCH" ]]; then
local AGE=$((NOW_EPOCH - ARR_EPOCH))
if [[ $AGE -gt $MAX_AGE_SECONDS ]]; then
DELETE_LIST+=("$CURRENT_ID")
local RCPT_JOINED
RCPT_JOINED=$(IFS=,; echo "${CURRENT_RECIPIENTS[*]}")
DELETE_INFO+=("$CURRENT_ID | $CURRENT_DATE | $CURRENT_SENDER -> $RCPT_JOINED")
fi
fi
fi
CURRENT_ID=""
CURRENT_DATE=""
CURRENT_SENDER=""
CURRENT_STATUS=""
CURRENT_RECIPIENTS=()
MATCH_REASON=0
}
# Baris header tiap pesan di output "postqueue -p" contohnya:
# A1B2C3D4E5F6 1234 Mon Aug 24 09:12:34 sender@domain.com
# (kalau ada "!" atau "*" setelah queue id, artinya statusnya bukan deferred biasa)
HEADER_REGEX='^([A-F0-9]{6,})(\*|!)?[[:space:]]+([0-9]+)[[:space:]]+([A-Za-z]{3}[[:space:]]+[A-Za-z]{3}[[:space:]]+[0-9]+[[:space:]]+[0-9:]+)[[:space:]]+(.*)$'
while IFS= read -r line; do
if [[ -z "$line" ]]; then
process_block
continue
fi
if [[ "$line" =~ $HEADER_REGEX ]]; then
process_block
CURRENT_ID="${BASH_REMATCH[1]}"
CURRENT_DATE="${BASH_REMATCH[4]}"
CURRENT_SENDER="${BASH_REMATCH[5]}"
if [[ "${BASH_REMATCH[2]}" == "!" ]]; then
CURRENT_STATUS="hold"
else
CURRENT_STATUS="deferred"
fi
continue
fi
TRIMMED_LINE="$(echo "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if echo "$line" | grep -qi "$REASON_PATTERN"; then
MATCH_REASON=1
fi
# baris recipient: tidak diawali "(" dan berbentuk alamat email
if [[ "$TRIMMED_LINE" =~ $EMAIL_REGEX ]]; then
CURRENT_RECIPIENTS+=("$TRIMMED_LINE")
fi
done < <("$POSTQUEUE_BIN" -p)
process_block
{
echo "=== Ringkasan ==="
echo "Waktu jalan : $(date '+%Y-%m-%d %H:%M:%S')"
echo "Pola alasan bounce : \"$REASON_PATTERN\""
echo "Batas umur : > $MAX_AGE_HOURS jam"
echo "Jumlah ditemukan : ${#DELETE_LIST[@]}"
echo
} | tee "$LOG_FILE"
if [[ ${#DELETE_LIST[@]} -eq 0 ]]; then
echo "Tidak ada pesan yang cocok kriteria. Selesai." | tee -a "$LOG_FILE"
exit 0
fi
{
echo "QUEUE_ID | ARRIVAL_DATE | SENDER -> RECIPIENT(S)"
printf '%s\n' "${DELETE_INFO[@]}"
echo
} | tee -a "$LOG_FILE"
if [[ $DRY_RUN -eq 1 ]]; then
echo "(dry-run) Tidak ada yang dihapus." | tee -a "$LOG_FILE"
echo "Log tersimpan di: $LOG_FILE"
exit 0
fi
if [[ $AUTO_YES -eq 0 ]]; then
read -r -p "Hapus ${#DELETE_LIST[@]} pesan di atas dari queue? (y/n) " CONFIRM
if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then
echo "Dibatalkan." | tee -a "$LOG_FILE"
echo "Log tersimpan di: $LOG_FILE"
exit 0
fi
fi
# postsuper -d hanya menerima SATU queue id per flag -d.
# Kalau banyak id ditaruh setelah satu -d, id ke-2 dst akan dianggap sebagai
# nama direktori queue -> muncul error "invalid directory name: <id>".
# Jadi flag -d harus diulang untuk tiap id.
POSTSUPER_ARGS=()
for qid in "${DELETE_LIST[@]}"; do
POSTSUPER_ARGS+=("-d" "$qid")
done
"$POSTSUPER_BIN" "${POSTSUPER_ARGS[@]}"
{
echo "Status : BERHASIL DIHAPUS"
echo "Selesai : $(date '+%Y-%m-%d %H:%M:%S')"
echo "Berhasil menghapus ${#DELETE_LIST[@]} pesan dari deferred queue."
} | tee -a "$LOG_FILE"
echo
echo "Log lengkap tersimpan di: $LOG_FILE"
Kasih izin eksekusi:
chmod +x deferred-cleanup.sh
Cara Pakai
1. Cek dulu sebelum menghapus (mode dry-run)
Selalu mulai dengan mode -n supaya kamu bisa lihat dulu email mana saja yang akan terhapus — lengkap dengan pengirim dan penerimanya — sebelum ada yang benar-benar dihapus:
sudo ./deferred-cleanup.sh -n
Contoh outputnya:
=== Ringkasan ===
Waktu jalan : 2026-08-25 14:02:11
Pola alasan bounce : "out of storage space"
Batas umur : > 24 jam
Jumlah ditemukan : 268
QUEUE_ID | ARRIVAL_DATE | SENDER -> RECIPIENT(S)
F20803F98AAB | Thu Aug 20 20:28:56 | sender@domainku.com -> receiver1@gmail.com
3DBA13FC0D27 | Sat Aug 22 10:01:02 | sender@domainku.com -> receiver2@gmail.com
...
(dry-run) Tidak ada yang dihapus.
2. Kalau sudah yakin, jalankan mode hapus
sudo ./deferred-cleanup.sh -y
Flag -y artinya "auto-confirm", cocok kalau nanti mau dijadwalkan otomatis lewat cron. Kalau mau tetap ditanya konfirmasi dulu, jalankan tanpa flag apa pun.
3. Semua hasilnya otomatis tersimpan sebagai log
Setiap kali dijalankan (baik -n maupun -y), script otomatis membuat file log baru berisi ringkasan dan daftar email yang diproses, jadi kamu tidak perlu repot redirect manual (> log.txt) lagi. Default-nya tersimpan di:
/opt/zimbra/log/deferred-cleanup-<tanggal-jam>.log
Mau simpan di lokasi lain? Tinggal tambahkan flag -L:
sudo ./deferred-cleanup.sh -y -L /backup/zimbra/logs
Biar Otomatis Jalan Sendiri (Cron)
Kalau sudah yakin script-nya bekerja dengan baik, jadwalkan supaya jalan otomatis, misalnya tiap jam:
sudo crontab -e
Tambahkan baris berikut:
0 * * * * /opt/zimbra/scripts/deferred-cleanup.sh -y
Dengan begini, tiap jam server akan otomatis membersihkan deferred queue yang bouncenya karena mailbox penuh dan sudah lebih dari 24 jam, tanpa perlu kamu jalankan manual lagi.
Error yang Mungkin Kamu Temui (dan Solusinya)
Waktu develop script ini, saya sendiri sempat ketemu dua error berikut. Saya tulis di sini biar kalau kamu ketemu error yang sama, sudah tahu solusinya.
❌ Error 1: postsuper: fatal: use of this command is reserved for the superuser
Penyebab: postsuper -d (perintah untuk menghapus dari queue) hanya boleh dijalankan oleh root, atau user yang terdaftar sebagai mail_owner di konfigurasi Postfix (biasanya postfix, bukan zimbra). Kalau kamu menjalankan script ini lewat su - zimbra, perintah postqueue -p (baca antrian) memang bisa jalan, tapi postsuper -d akan gagal.
Solusi: Jalankan script langsung sebagai root/sudo, bukan lewat user zimbra:
sudo ./deferred-cleanup.sh -y
❌ Error 2: postsuper: fatal: invalid directory name: <queue_id>
Penyebab: Ternyata postsuper -d cuma menerima satu ID per flag -d. Kalau kita kasih banyak ID sekaligus setelah satu -d (misalnya postsuper -d ID1 ID2 ID3), Postfix akan menganggap ID kedua dan seterusnya sebagai nama direktori queue — bukan ID pesan — makanya muncul error "invalid directory name".
Solusi: Ulangi flag -d untuk setiap ID (postsuper -d ID1 -d ID2 -d ID3 ...). Script di atas sudah menangani ini otomatis lewat bagian:
POSTSUPER_ARGS=()
for qid in "${DELETE_LIST[@]}"; do
POSTSUPER_ARGS+=("-d" "$qid")
done
"$POSTSUPER_BIN" "${POSTSUPER_ARGS[@]}"
Tips Tambahan
- Ubah kata kunci bounce: kalau selain "out of storage space" kamu juga mau cover kasus lain seperti "quota" atau "mailbox is full", cukup ubah variabel
REASON_PATTERNdi bagian atas script. - Ubah batas waktu: mau lebih agresif (misal 12 jam) atau lebih longgar (48 jam)? Tinggal ubah
MAX_AGE_HOURS. - Penghapusan ini permanen — email yang dihapus dengan
postsuper -dtidak akan mengirim notifikasi bounce balik ke pengirim aslinya (beda dengan menunggu Postfix menyerah sendiri setelahmaximal_queue_lifetimehabis, yang otomatis mengirim NDR/bounce ke pengirim). Kalau pengirim perlu tahu emailnya gagal terkirim, pertimbangkan untuk menurunkanmaximal_queue_lifetimedi Zimbra saja, daripada menghapus paksa.
Penutup
Dengan script sederhana ini, deferred queue Zimbra yang tadinya menumpuk ratusan email gara-gara mailbox Gmail penuh bisa otomatis dibersihkan setiap jam, lengkap dengan log untuk audit. Semoga tutorial ini membantu kamu yang lagi struggle sama masalah yang sama ya!
Kalau ada pertanyaan atau ketemu error lain, tulis di kolom komentar 👇