Sending mailbox quota warnings on Enhance (the ones Enhance does not send)

July 2, 2026

If you run mail for clients on the Enhance control panel, you have probably hit this: a client stops receiving email, nobody knows why, and it turns out their mailbox quietly filled up days ago. Enhance does not send any warning as a mailbox approaches its quota, and it does not tell the user when they have gone over it. The first anyone hears about it is a complaint that email has "stopped working".

This post walks through a small, self-contained script that fixes that. It watches every mailbox on the server, warns users before they hit the wall, tells them plainly when they are full, and sends you an admin digest so nothing slips past. It runs on stock Enhance with no plugins and no risky config edits.

The problem in a bit more detail

Dovecot, which Enhance uses under the bonnet for IMAP and POP3, already tracks each mailbox's quota. You can see it any time with doveadm quota get -A. The information is right there. What is missing is anything that acts on it. There is no built-in job that says "this mailbox is at 90%, send the owner a heads-up".

Dovecot does actually have a native feature for exactly this, called quota_warning, which fires a script the moment a delivery pushes a mailbox across a threshold. It is the textbook answer, and on a hand-built mail server it is what you would reach for. The trouble on Enhance is that Enhance owns the Dovecot configuration and regenerates it whenever mailboxes are added or changed. Any quota_warning block you add by hand is liable to be wiped the next time the panel rewrites the config. You would be building on sand.

The approach

Rather than fight the panel, the script sits entirely outside it. It is a plain cron job that:

  1. Asks Dovecot for every mailbox's current usage, using the same doveadm quota get -A you can run by hand.
  2. Works out which usage band each mailbox is in (80%, 90%, 95%, 100% by default).
  3. Sends an alert only when a mailbox rises into a new band, so a mailbox parked at 87% for a fortnight generates one warning, not one every fifteen minutes.
  4. Optionally delivers a plain-English warning straight into the user's own mailbox, with a different message once they are actually full.
  5. Emails you, the admin, a single digest of everything that moved.

Because it only ever reads quota state and sends mail, it touches none of Enhance's managed configuration. An Enhance config regeneration cannot break it. That is the whole point of doing it this way.

One environment note worth knowing. As of Enhance v12, the mail stack runs as native systemd services rather than Docker containers, so Dovecot is a normal dovecot.service unit and both doveadm and dovecot-lda are callable directly on the host. There is no docker exec and no namespace wrapping to worry about. If you are on an older Enhance that still uses Docker, the calls to doveadm and dovecot-lda would need wrapping in docker exec, but on v12 and later it is all direct.

Before you start

You will need root on the mail server, and you should confirm three binary paths, since distributions vary a little:

command -v doveadm
ls -l /usr/lib/dovecot/dovecot-lda
command -v sendmail

On a current Ubuntu-based Enhance box these are typically /usr/bin/doveadm, /usr/lib/dovecot/dovecot-lda and /usr/sbin/sendmail. If yours differ, note them, as they go into the config block of the script.

It is also worth running doveadm quota get -A once by hand just to see the output. Each mailbox produces a STORAGE row and a MESSAGE row, and the script reads the percentage from the last column of the STORAGE rows.

The script

Save this as mailquota-notify.sh. Every setting you are likely to change lives in the clearly marked CONFIG block near the top, and the rest is commented so you can follow what it does.

#!/usr/bin/env bash
#
# mailquota-notify.sh
# ==================================================================
# Sends mailbox quota notifications for a Dovecot mail server, the
# feature the Enhance control panel does not provide out of the box.
#
# What it does:
#   * Polls every mailbox's quota with `doveadm quota get -A`.
#   * When a mailbox rises into a new usage band (80/90/95/100% by
#     default) it sends ONE alert. It will not nag on every run while
#     the mailbox sits in the same band.
#   * Optionally delivers a plain-English warning straight into the
#     user's own mailbox, with a distinct "you are now full" message
#     once they hit 100%.
#   * Emails the admin a single digest of everything that crossed a
#     band this run.
#   * A separate weekly mode emails a capacity-planning summary of
#     every mailbox above a chosen percentage.
#
# Why a cron poller and not Dovecot's own quota_warning feature:
# Enhance regenerates the Dovecot configuration when mailboxes change,
# so hand-edited quota_warning blocks get wiped. This script touches
# NONE of Enhance's managed config. It only reads quota state and
# sends mail, so a config regeneration cannot break it.
#
# Environment note (Enhance v12): Dovecot runs as a native systemd
# unit, so `doveadm` and `dovecot-lda` are callable directly on the
# host. No Docker, no `docker exec`, no namespace juggling.
#
# ------------------------------------------------------------------
# INSTALL
#   sudo install -o root -g root -m 750 \
#        mailquota-notify.sh /usr/local/sbin/mailquota-notify.sh
#   sudo mkdir -p /var/lib/mailquota-notify
#
# SCHEDULE (add to /etc/cron.d/mailquota-notify, see the blog post):
#   */15 * * * * root /usr/local/sbin/mailquota-notify.sh >/dev/null 2>&1
#   0 8 * * 1   root /usr/local/sbin/mailquota-notify.sh weekly >/dev/null 2>&1
#
# MODES
#   (no argument)   poll quotas and alert on band crossings
#   weekly          email the planning summary, then exit
# ------------------------------------------------------------------

set -euo pipefail

######################### CONFIG ##########################
# Everything you are likely to change lives in this block.

# Usage bands, in percent. An alert fires only when a mailbox RISES
# into a higher band, so a box parked at 87% will not re-alert every
# run. 100 is included so crossing into "full" always sends a fresh
# notice, even if the box was already in the 95 band beforehand.
BANDS=(80 90 95 100)

# Restrict to specific domains (space-separated). Empty string = every
# mailbox on the server. Handy for a first test: set this to a single
# domain you own, prove it works, then set it back to "".
#   Example test value: DOMAIN_FILTER="example.com"
DOMAIN_FILTER=""

# Where the admin digest and weekly summary are sent.
# IMPORTANT: use an address that is NOT hosted on this server. If this
# server's mail breaks, an on-server address means you never get the
# very alert that would tell you.
ADMIN_EMAIL="[email protected]"

# From: header AND envelope sender for all outgoing mail this script
# sends. The envelope sender matters: if you relay through a shared
# smarthost such as MailChannels, this address must be one the
# smarthost is authorised to send for, or the mail is rejected. See
# the "MailChannels gotcha" section of the blog post.
MAIL_FROM="[email protected]"

# Deliver an individual warning INTO the user's own mailbox?
#   0 = admin digest only
#   1 = also notify the end user. Delivery uses dovecot-lda with a
#       noenforcing quota override, so the warning still lands even
#       when the mailbox is already at or over 100%.
NOTIFY_USER=1

# Weekly summary threshold: list every mailbox at or above this
# percentage. Independent of the band-crossing alerts above.
SUMMARY_MIN_PCT=50

# Local-parts never sent a user notification (they still appear in the
# admin digest and weekly summary). Matched case-insensitively against
# the part before the @. Keeps role and no-reply boxes quiet.
SKIP_LOCALPARTS_REGEX='^(test|donotreply|do-not-reply|noreply|no-reply|admin|postmaster|abuse)$'

# Binary paths. Confirm on your box with:
#   command -v doveadm ; ls -l /usr/lib/dovecot/dovecot-lda ; command -v sendmail
DOVEADM="/usr/bin/doveadm"
LDA="/usr/lib/dovecot/dovecot-lda"
SENDMAIL="/usr/sbin/sendmail"

# Where per-mailbox alert state is remembered (one small file each).
STATE_DIR="/var/lib/mailquota-notify"

# syslog tag. Read the script's own log with:  journalctl -t mailquota-notify
LOG_TAG="mailquota-notify"

####################### END CONFIG ########################

mkdir -p "$STATE_DIR"

log() { logger -t "$LOG_TAG" -- "$*"; }

# Return the highest band whose threshold is <= pct, else 0.
band_for() {
  local pct="$1" b out=0
  for b in "${BANDS[@]}"; do
    (( pct >= b )) && out="$b"
  done
  echo "$out"
}

# Turn an email address into a filesystem-safe state key.
state_file_for() {
  local user="$1"
  echo "$STATE_DIR/${user//[^A-Za-z0-9._-]/_}"
}

# True if the address is inside DOMAIN_FILTER (or the filter is empty).
in_scope() {
  local domain="${1##*@}" d
  [[ -z "$DOMAIN_FILTER" ]] && return 0
  for d in $DOMAIN_FILTER; do [[ "$domain" == "$d" ]] && return 0; done
  return 1
}

# Weekly capacity-planning summary. Admin only, never mails users.
weekly_summary() {
  local rows=() user pct value limit
  while read -r user pct value limit; do
    [[ "$pct" =~ ^[0-9]+$ ]] || continue
    (( pct >= SUMMARY_MIN_PCT )) || continue
    in_scope "$user" || continue
    rows+=("$pct|$user|$value|$limit")
  done < <("$DOVEADM" quota get -A | awk '$4=="STORAGE" { print $1, $NF, $5, $6 }')

  if (( ${#rows[@]} == 0 )); then
    log "weekly summary: no mailboxes at or above ${SUMMARY_MIN_PCT}%"
    return
  fi

  {
    echo "From: $MAIL_FROM"
    echo "To: $ADMIN_EMAIL"
    echo "Subject: [mail quota] Weekly summary - ${#rows[@]} mailbox(es) at/above ${SUMMARY_MIN_PCT}%"
    echo
    echo "Mailboxes on $(hostname -f) at or above ${SUMMARY_MIN_PCT}% usage, highest first:"
    echo
    printf '%-45s %6s  %9s  %9s\n' "MAILBOX" "USED" "SIZE" "LIMIT"
    printf '%-45s %6s  %9s  %9s\n' "-------" "----" "----" "-----"
    printf '%s\n' "${rows[@]}" | sort -t'|' -k1,1 -rn | while IFS='|' read -r pct user value limit; do
      hsize="$(numfmt --to=iec --suffix=B $(( value * 1024 )) 2>/dev/null || echo "${value}K")"
      if [[ "$limit" == "-" ]]; then
        hlimit="none"
      else
        hlimit="$(numfmt --to=iec --suffix=B $(( limit * 1024 )) 2>/dev/null || echo "${limit}K")"
      fi
      printf '%-45s %5s%%  %9s  %9s\n' "$user" "$pct" "$hsize" "$hlimit"
    done
    echo
    echo "Any box at or over 100% is no longer receiving mail."
  } | "$SENDMAIL" -t -f "$MAIL_FROM"
  log "weekly summary sent to $ADMIN_EMAIL (${#rows[@]} mailbox(es))"
}

# Mode dispatch: "weekly" runs the summary and exits, anything else polls.
MODE="${1:-poll}"
if [[ "$MODE" == "weekly" ]]; then
  weekly_summary
  exit 0
fi

declare -a NEW_ALERTS=()

# Read one STORAGE row per mailbox. doveadm's columns are:
#   $1=address  $2=User  $3=quota  $4=STORAGE|MESSAGE  $5=Value  $6=Limit  $NF=percent
while read -r user pct limit; do
  [[ "$pct" =~ ^[0-9]+$ ]] || continue     # skip rows with no numeric percent (unlimited boxes)
  in_scope "$user" || continue

  cur_band="$(band_for "$pct")"
  sf="$(state_file_for "$user")"
  last_band="$(cat "$sf" 2>/dev/null || echo 0)"

  if (( cur_band > last_band )); then
    # Risen into a new, higher band: alert.
    NEW_ALERTS+=("$user|$pct|$limit")
    echo "$cur_band" > "$sf"
    log "ALERT $user at ${pct}% (band ${cur_band}, was ${last_band})"

    if (( NOTIFY_USER == 1 )); then
      localpart="${user%@*}"
      if ! [[ "${localpart,,}" =~ $SKIP_LOCALPARTS_REGEX ]]; then
        if (( pct >= 100 )); then
          # Over quota: mail is being rejected, say so plainly.
          subject="Your mailbox is full (${pct}%) - action needed"
          body="Your mailbox ($user) is now ${pct}% full, which is at or over its limit.

Because it is full, you are NOT receiving new email. Messages sent to you
will be delayed and may be returned to the sender. New mail will only
start arriving again once the mailbox is back below its limit.

Please delete older or larger messages, and empty your Trash and Junk
folders, to bring it below the limit as soon as possible.

To arrange more space, please contact your administrator."
        else
          # Approaching quota: a friendly heads-up.
          subject="Your mailbox is ${pct}% full"
          body="Your mailbox ($user) is currently ${pct}% full.

Once it reaches 100% you will stop receiving new email. Please delete
older or larger messages, and empty your Trash and Junk folders.

To arrange more space, please contact your administrator."
        fi
        # noenforcing lets the warning be delivered even past 100%.
        "$LDA" -d "$user" -o "plugin/quota=maildir:User quota:noenforcing" <<EOF || log "lda delivery failed for $user"
From: $MAIL_FROM
To: $user
Subject: $subject

$body
EOF
      fi
    fi

  elif (( cur_band < last_band )); then
    # Usage fell back below a band. Lower the watermark so a later rise
    # alerts again rather than being silently suppressed.
    echo "$cur_band" > "$sf"
  fi
done < <("$DOVEADM" quota get -A | awk '$4=="STORAGE" { print $1, $NF, $6 }')

# Send one admin digest, only if something newly crossed a band.
if (( ${#NEW_ALERTS[@]} > 0 )); then
  {
    echo "From: $MAIL_FROM"
    echo "To: $ADMIN_EMAIL"
    echo "Subject: [mail quota] ${#NEW_ALERTS[@]} mailbox(es) crossed a usage threshold"
    echo
    echo "These mailboxes on $(hostname -f) have risen into a new usage band:"
    echo
    printf '%-45s %6s\n' "MAILBOX" "USED"
    printf '%-45s %6s\n' "-------" "----"
    for row in "${NEW_ALERTS[@]}"; do
      IFS='|' read -r u p l <<< "$row"
      printf '%-45s %5s%%\n' "$u" "$p"
    done
    echo
    echo "Any box at or over 100% is no longer receiving mail."
  } | "$SENDMAIL" -t -f "$MAIL_FROM"
  log "digest sent to $ADMIN_EMAIL for ${#NEW_ALERTS[@]} mailbox(es)"
fi

Installing it

Drop the script into a sensible location, make it root-owned and executable, and create its state directory:

sudo install -o root -g root -m 750 mailquota-notify.sh /usr/local/sbin/mailquota-notify.sh
sudo mkdir -p /var/lib/mailquota-notify

The state directory is where the script remembers which band each mailbox was last in. That memory is what stops it re-sending the same warning on every run.

Configuring it

Open the CONFIG block and set these for your server.

ADMIN_EMAIL is where your digest and weekly summary go. Use an address hosted somewhere other than this server. The one time you most need the alert is when this server's own mail is unwell, and an on-server address is exactly the one that will not reach you at that moment. A mailbox on a different provider is ideal.

MAIL_FROM is the address the script sends as, used both in the From header and, importantly, as the envelope sender. More on why that matters below.

NOTIFY_USER decides whether end users get warned in their own inbox. Set it to 1 to notify users, which is the behaviour most people want, or 0 to keep it admin-only while you get comfortable with it.

BANDS are the thresholds. The defaults of 80, 90, 95 and 100 work well. Keeping 100 as its own band matters, because it guarantees that crossing into "full" always produces a fresh notification even for a mailbox that was already sitting in the 95% band.

SUMMARY_MIN_PCT sets the floor for the weekly planning summary. At 50 you get a weekly list of everything half full or more, which is handy for spotting mailboxes that will need attention soon.

SKIP_LOCALPARTS_REGEX lists local-parts that should never receive a user-facing warning, such as noreply and donotreply boxes. They still appear in your admin digest, they just do not get mailed directly, since nobody reads them.

DOVEADM, LDA and SENDMAIL are the binary paths you confirmed earlier. Adjust if yours differ.

Scheduling it with cron

The tidiest way is a drop-in file in /etc/cron.d, which is idempotent and easy to inspect later:

sudo tee /etc/cron.d/mailquota-notify >/dev/null <<'EOF'
# Mailbox quota notifications
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""

# Every 15 minutes: poll quotas, alert on band crossings
*/15 * * * * root /usr/local/sbin/mailquota-notify.sh >/dev/null 2>&1

# Monday 08:00: weekly planning summary
0 8 * * 1 root /usr/local/sbin/mailquota-notify.sh weekly >/dev/null 2>&1
EOF
sudo chmod 644 /etc/cron.d/mailquota-notify

Two details specific to /etc/cron.d files: each line needs the user (root) after the time fields, which a normal user crontab does not, and MAILTO="" stops cron from emailing you its own output, since the script handles all of its own mail. Cron picks the file up on its own within a minute, no restart needed. Confirm it is in place with:

cat /etc/cron.d/mailquota-notify

Testing it safely

Two habits make testing painless.

First, scope it to a single domain you control. Set DOMAIN_FILTER="yourdomain.com" in the config, and the script ignores every other domain on the server while you experiment. Clear it back to "" when you are happy.

Second, force a crossing. If nothing in your test domain happens to be near a band, temporarily add a low band so a real mailbox crosses it, for example BANDS=(50 80 90 95 100), run the script by hand, watch what happens, then put the bands back. To repeat a test against the same mailbox, clear its remembered state first. State files live in /var/lib/mailquota-notify/ with the @ replaced by an underscore, so [email protected] becomes a file called user_yourdomain.com.

Run either mode by hand while testing:

sudo /usr/local/sbin/mailquota-notify.sh          # poll now
sudo /usr/local/sbin/mailquota-notify.sh weekly    # send the weekly summary now

Reading the logs

The script logs to syslog under its own tag, so you can see exactly what it has been doing:

journalctl -t mailquota-notify --since today

You will see an ALERT line each time a mailbox crosses a band, plus lines confirming the digest and weekly summary were sent. A poll run that finds nothing to report writes nothing, which is normal and not a sign of trouble.

Wrapping up

That is the whole thing: a single self-contained script, a couple of cron lines, and no changes to anything Enhance manages. Users get a warning before their mailbox fills, a clear message when it does, and you get a quiet digest instead of a surprise support ticket.

If you adapt it, the obvious extensions are a daily re-nudge for mailboxes that stay over quota, and per-domain routing so each client's alerts go to their own address rather than all landing on you. Both are small additions to the same structure.

Disclaimer

Provided as-is with no warranty. Use at your own risk. The script runs as root and sends email, so test it on a spare domain first. I accept no liability for any loss or disruption resulting from its use. Always review the code and confirm it suits your own setup before running it in production.

crossmenu
linkedin facebook pinterest youtube rss twitter instagram facebook-blank rss-blank linkedin-blank pinterest youtube twitter instagram