diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..46f9dce --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,63 @@ +{ + "permissions": { + "allow": [ + "Bash(ssh a.mx.oopen.de \"cat /usr/local/dovecot-2.4.2/etc/dovecot/conf.d/90-quota.conf 2>/dev/null || echo 'FILE NOT FOUND'\")", + "Bash(ssh a.mx.oopen.de \"grep -n -i 'quota\\\\|imap' /var/www/adm.oopen.de/postfixadmin-3.3.13/config.local.php 2>/dev/null | head -40\")", + "Bash(ssh a.mx.oopen.de \"grep -n -i 'quota\\\\|getquota\\\\|imap' /var/www/adm.oopen.de/postfixadmin-3.3.13/config.inc.php 2>/dev/null | head -60\")", + "Bash(ssh a.mx.oopen.de \"grep -n -i 'quota\\\\|getquota\\\\|imap\\\\|dovecot_doveadm\\\\|doveadm' /var/www/adm.oopen.de/postfixadmin-3.3.13/config.inc.php 2>/dev/null | grep -v '^.*:\\\\/\\\\/' | head -60\")", + "Bash(ssh a.mx.oopen.de \"sed -n '680,780p' /var/www/adm.oopen.de/postfixadmin-3.3.13/config.inc.php\")", + "Bash(ssh a.mx.oopen.de \"sudo sed -n '680,780p' /var/www/adm.oopen.de/postfixadmin-3.3.13/config.inc.php\")", + "Bash(ssh a.mx.oopen.de \"ls -la /var/www/adm.oopen.de/postfixadmin-3.3.13/ && cat /var/www/adm.oopen.de/postfixadmin-3.3.13/config.local.php 2>/dev/null | grep -n -i 'quota\\\\|imap\\\\|doveadm' | head -40\")", + "Bash(ssh a.mx.oopen.de \"grep -n 'used_quotas\\\\|new_quota_table\\\\|getquota\\\\|imap_quota\\\\|doveadm\\\\|dovecot' /var/www/adm.oopen.de/postfixadmin-3.3.13/config.local.php 2>/dev/null\")", + "Bash(ssh a.mx.oopen.de \"grep -n 'getquota\\\\|quota_get\\\\|imap.*quota\\\\|GETQUOTA\\\\|quota_used\\\\|used_quota\\\\|dovecot_quota' /var/www/adm.oopen.de/postfixadmin-3.3.13/functions.inc.php 2>/dev/null | head -30\")", + "Bash(ssh a.mx.oopen.de \"/usr/local/dovecot-2.4.2/bin/doveadm quota get -u 'ckubu@oopen.de' 2>/dev/null || /usr/local/dovecot/bin/doveadm quota get -u 'ckubu@oopen.de' 2>&1 | head -20\")", + "Bash(ssh a.mx.oopen.de \"/usr/local/dovecot/bin/doveadm quota get -u 'chris@oopen.de' 2>&1 | head -10\")", + "Bash(ssh a.mx.oopen.de \"/usr/local/dovecot/bin/doveadm user '*@oopen.de' 2>&1 | head -5\")", + "Bash(ssh a.mx.oopen.de \"psql -U postfix postfix -c \\\\\"SELECT username, quota FROM mailbox LIMIT 5;\\\\\" 2>/dev/null\")", + "Bash(ssh a.mx.oopen.de \"/usr/local/dovecot/bin/doveadm quota get -u 'bastisch@kottbusserdamm.net' 2>&1\")", + "Bash(ssh a.mx.oopen.de \"cat /usr/local/dovecot-2.4.2/etc/dovecot/conf.d/10-auth.conf 2>/dev/null | grep -v '^#\\\\|^$' | head -30\")", + "Bash(ssh a.mx.oopen.de \"ls /usr/local/dovecot-2.4.2/var/run/dovecot/ 2>/dev/null || ls /var/run/dovecot/ 2>/dev/null | head -20\")", + "Bash(ssh a.mx.oopen.de \"/usr/local/dovecot/bin/doveadm -D quota get -u 'bastisch@kottbusserdamm.net' 2>&1 | tail -20\")", + "Bash(ssh a.mx.oopen.de \"ls /usr/local/dovecot-2.4.2/lib/dovecot/ 2>/dev/null | grep -i quota\")", + "Bash(ssh a.mx.oopen.de \"cat /usr/local/dovecot-2.4.2/etc/dovecot/conf.d/90-quota.conf | grep -v '^#\\\\|^$'\")", + "Bash(ssh a.mx.oopen.de \"cat /usr/local/dovecot-2.4.2/etc/dovecot/dovecot.conf | grep -v '^#\\\\|^$'\")", + "Bash(ssh a.mx.oopen.de \"ls /usr/local/dovecot-2.4.2/etc/dovecot/conf.d/ && cat /usr/local/dovecot-2.4.2/etc/dovecot/conf.d/20-imap.conf 2>/dev/null | grep -v '^#\\\\|^$'\")", + "Bash(ssh a.mx.oopen.de \"cat /usr/local/dovecot-2.4.2/etc/dovecot/conf.d/10-mail.conf | grep -v '^#\\\\|^$'\")", + "Bash(ssh a.mx.oopen.de \"cat /usr/local/dovecot-2.4.2/share/doc/dovecot/quota_clone* 2>/dev/null || find /usr/local/dovecot-2.4.2/share/doc/dovecot/ -name '*quota*' 2>/dev/null | head -10\")", + "Bash(ssh a.mx.oopen.de \"ls /usr/local/dovecot-2.4.2/etc/dovecot/*.conf* 2>/dev/null && find /usr/local/dovecot-2.4.2/etc/dovecot/ -name '*.ext' 2>/dev/null\")", + "Bash(ssh a.mx.oopen.de \"ls /usr/local/dovecot-2.4.2/lib/dovecot/ | grep -i dict\")", + "Bash(ssh a.mx.oopen.de \"cat /usr/local/dovecot-2.4.2/etc/dovecot/sql-connect.conf.ext\")", + "Bash(ssh a.mx.oopen.de \"ls /usr/local/dovecot-2.4.2/lib/dovecot/ | grep -i 'pgsql\\\\|mysql\\\\|sqlite\\\\|db'\")", + "Bash(ssh a.mx.oopen.de \"ls /usr/local/dovecot-2.4.2/lib/ | grep -i 'sql\\\\|dict\\\\|pgsql\\\\|mysql'\")", + "Bash(ssh a.mx.oopen.de \"find /usr/local/dovecot-2.4.2/ -name '*dict*' -o -name '*pgsql*' -o -name '*sql*' 2>/dev/null | grep '\\\\.so'\")", + "Bash(ssh a.mx.oopen.de \"psql -U postfix postfix -c \\\\\"SELECT * FROM quota2 LIMIT 5;\\\\\" 2>/dev/null\")", + "Bash(ssh a.mx.oopen.de \"psql -U postfix postfix -c \\\\\"\\\\dt\\\\\" 2>/dev/null | grep -i quota\")", + "Bash(ssh a.mx.oopen.de \"find /usr/local/dovecot-2.4.2/share/ -name '*.conf' -o -name '*.conf.ext' -o -name '*example*' 2>/dev/null | head -20\")", + "Bash(ssh a.mx.oopen.de \"/usr/local/dovecot-2.4.2/sbin/dovecot -a 2>/dev/null | grep -i 'quota_clone\\\\|clone' | head -20\")", + "Bash(ssh a.mx.oopen.de \"/usr/local/dovecot-2.4.2/sbin/dovecot -a 2>/dev/null | grep -i 'quota_clone' | head -20\")", + "Bash(ssh a.mx.oopen.de \"/usr/local/dovecot-2.4.2/sbin/dovecot -a 2>/dev/null | grep -i 'dict\\\\|clone' | head -40\")", + "Bash(ssh a.mx.oopen.de \"/usr/local/dovecot-2.4.2/sbin/dovecot -a 2>/dev/null | grep -A5 'service dict'\")", + "Bash(ssh a.mx.oopen.de \"cat /usr/local/dovecot-2.4.2/etc/dovecot/conf.d/10-master.conf | grep -v '^#\\\\|^$'\")", + "Bash(ssh a.mx.oopen.de \"grep -n 'imap\\\\|getquota\\\\|quota' /var/www/adm.oopen.de/postfixadmin-3.3.13/functions.inc.php 2>/dev/null | grep -i 'imap.*quota\\\\|quota.*imap\\\\|getquota\\\\|imap_open\\\\|imap_getquota\\\\|used_quota' | head -20\")", + "Bash(ssh a.mx.oopen.de \"grep -n 'getquota\\\\|imap_open\\\\|imap_close\\\\|imap_quota' /var/www/adm.oopen.de/postfixadmin-3.3.13/functions.inc.php 2>/dev/null | head -20\")", + "Bash(ssh a.mx.oopen.de \"cat /usr/local/dovecot-2.4.2/etc/dovecot/conf.d/auth-sql.conf.ext\")", + "Bash(ssh a.mx.oopen.de \"/usr/local/dovecot-2.4.2/bin/doveadm -D quota get -u 'bastisch@kottbusserdamm.net' 2>&1 | grep -E 'Error|Debug.*auth|FAIL|passdb|userdb' | head -15\")", + "Bash(ssh a.mx.oopen.de \"ls /var/www/adm.oopen.de/postfixadmin-3.3.13/ADDITIONS/ && ls /var/www/adm.oopen.de/postfixadmin-3.3.13/scripts/\")", + "Bash(ssh a.mx.oopen.de \"journalctl -u dovecot --since '5 minutes ago' 2>/dev/null | grep -i 'quota\\\\|bastisch\\\\|userdb\\\\|error' | head -20\")", + "Bash(ssh a.mx.oopen.de \"cat /var/www/adm.oopen.de/postfixadmin-3.3.13/ADDITIONS/quota_usage.pl\")", + "Bash(ssh a.mx.oopen.de \"ls -la /var/www/adm.oopen.de/postfixadmin-3.3.13/ADDITIONS/\")", + "Bash(ssh a.mx.oopen.de \"sudo cat /var/www/adm.oopen.de/postfixadmin-3.3.13/ADDITIONS/quota_usage.pl 2>/dev/null || sg www-data -c 'cat /var/www/adm.oopen.de/postfixadmin-3.3.13/ADDITIONS/quota_usage.pl' 2>/dev/null\")", + "Bash(ssh a.mx.oopen.de \"id && groups\")", + "Bash(ssh a.mx.oopen.de \"grep -E 'quota|bastisch|userdb.*FAIL' /var/log/mail.log 2>/dev/null | tail -20\")", + "Bash(ssh a.mx.oopen.de \"/usr/local/dovecot/bin/doveadm -c /usr/local/dovecot-2.4.2/etc/dovecot/dovecot.conf user 'bastisch@kottbusserdamm.net' 2>&1\")", + "Bash(ssh a.mx.oopen.de \"psql -U postfix postfix -c \\\\\"SELECT username, maildir, quota, active FROM mailbox WHERE username = 'bastisch@kottbusserdamm.net';\\\\\" 2>/dev/null\")", + "Bash(ssh a.mx.oopen.de \"grep -i 'auth.*worker\\\\|sql.*error\\\\|error.*sql\\\\|userdb.*error' /var/log/syslog 2>/dev/null | tail -10\")", + "Bash(ssh a.mx.oopen.de \"journalctl -u dovecot --since '10 minutes ago' 2>/dev/null | grep -i 'userdb\\\\|auth.*worker\\\\|sql\\\\|error' | head -20\")", + "Bash(ssh a.mx.oopen.de \"journalctl -t dovecot --since '10 minutes ago' 2>/dev/null | grep -i 'bastisch\\\\|userdb\\\\|doveadm' | head -20\")", + "Bash(ssh a.mx.oopen.de \"cat /usr/local/dovecot-2.4.2/etc/dovecot/conf.d/10-logging.conf | grep -v '^#\\\\|^$'\")", + "Bash(ssh a.mx.oopen.de \"grep -i 'bastisch\\\\|auth.*worker\\\\|userdb.*fail\\\\|doveadm' /var/log/local1.log 2>/dev/null | tail -20 || grep -i 'bastisch\\\\|auth.*worker\\\\|userdb.*fail\\\\|doveadm' /var/log/syslog 2>/dev/null | tail -10\")", + "Bash(ssh a.mx.oopen.de \"find /var/log/ -name '*.log' -newer /var/log/auth.log 2>/dev/null | head -10 && ls /var/log/*.log 2>/dev/null | head -10\")", + "Bash(ssh a.mx.oopen.de \"grep -i 'bastisch\\\\|doveadm.*quota\\\\|auth.*fail\\\\|userdb.*error' /var/log/dovecot/dovecot.log 2>/dev/null | tail -20\")" + ] + } +} diff --git a/install_postfixadmin.sh b/install_postfixadmin.sh index a7ae94b..52acfc3 100755 --- a/install_postfixadmin.sh +++ b/install_postfixadmin.sh @@ -2610,7 +2610,7 @@ fi ## - $CONF['show_undeliverable']='NO'; ## - $CONF['show_popimap']='NO'; ## - -## - $CONF['used_quotas'] = 'NO'; +## - $CONF['used_quotas'] = 'YES'; ## - $CONF['new_quota_table'] = 'YES'; ## - echononl "\tAdjust Postfix Admin's Configuration - Part 5" @@ -2642,7 +2642,7 @@ perl -i -n -p -e "s#^(\s*\\\$CONF\['show_undeliverable'\]\s*=.*)#//!\1\n\\\$CONF $pfa_conf_file >> $log_file 2>&1 || _failed=true perl -i -n -p -e "s#^(\s*\\\$CONF\['show_popimap'\]\s*=.*)#//!\1\n\\\$CONF['show_popimap'] = 'NO';#" \ $pfa_conf_file >> $log_file 2>&1 || _failed=true -perl -i -n -p -e "s#^(\s*\\\$CONF\['used_quotas'\]\s*=.*)#//!\1\n\\\$CONF['used_quotas'] = 'NO';#" \ +perl -i -n -p -e "s#^(\s*\\\$CONF\['used_quotas'\]\s*=.*)#//!\1\n\\\$CONF['used_quotas'] = 'YES';#" \ $pfa_conf_file >> $log_file 2>&1 || _failed=true perl -i -n -p -e "s#^(\s*\\\$CONF\['new_quota_table'\]\s*=.*)#//!\1\n\\\$CONF['new_quota_table'] = 'YES';#" \ $pfa_conf_file >> $log_file 2>&1 || _failed=true diff --git a/install_quota_clone.sh b/install_quota_clone.sh new file mode 100755 index 0000000..3aaed9f --- /dev/null +++ b/install_quota_clone.sh @@ -0,0 +1,350 @@ +#!/bin/bash +## +## install_quota_clone.sh +## +## Configures Dovecot 2.4 quota_clone plugin and enables PostfixAdmin +## quota display by writing real-time usage to the quota2 PostgreSQL table. +## +## Must be run as root on the target mail server. +## +## What this script does: +## - Backs up the Dovecot and PostfixAdmin directories +## - Drops the old ADD-semantics mergequota2 PostgreSQL trigger +## - Creates a new SET-semantics upsertquota2 trigger (validation only) +## - Creates /etc/dovecot/conf.d/91-quota-clone.conf (dict_server) +## - Appends the quota_clone plugin block to 90-quota.conf +## - Enables $CONF['used_quotas'] and $CONF['new_quota_table'] in PostfixAdmin +## - Installs /usr/local/bin/update_quota2.sh for initial batch fill +## - Reloads Dovecot +## +## Safe to run multiple times (idempotent). +## + +set -uo pipefail + +# ── paths ───────────────────────────────────────────────────────────────────── +DOVECOT_LINK="/usr/local/dovecot" +PFA_LINK="/var/www/adm.oopen.de/postfixadmin" +BACKUP_BASE="/root/backup" + +# ── colour helpers ──────────────────────────────────────────────────────────── +_bold='\033[1m'; _green='\033[0;32m'; _red='\033[0;31m'; _yellow='\033[1;33m'; _nc='\033[0m' + +heading() { echo; echo -e "${_bold}${*}${_nc}"; } +step() { printf ' %-60s' "$*"; } +ok() { echo -e " [${_green}OK${_nc}]"; } +skip() { echo -e " [${_yellow}SKIP${_nc}]"; } +failed() { echo -e " [${_red}FAILED${_nc}]"; } +info() { echo -e " ${*}"; } +die() { echo -e "\n${_red}FATAL: ${*}${_nc}" >&2; exit 1; } + +# ── root check ──────────────────────────────────────────────────────────────── +[[ $EUID -eq 0 ]] || die "This script must be run as root." + +# ── resolve symlinks ────────────────────────────────────────────────────────── +[[ -L "$DOVECOT_LINK" ]] || die "$DOVECOT_LINK is not a symlink." +[[ -L "$PFA_LINK" ]] || die "$PFA_LINK is not a symlink." + +DOVECOT_REAL=$(realpath "$DOVECOT_LINK") +PFA_REAL=$(realpath "$PFA_LINK") +DOVECOT_CONF_D="${DOVECOT_REAL}/etc/dovecot/conf.d" +SQL_CONNECT="${DOVECOT_REAL}/etc/dovecot/sql-connect.conf.ext" +PFA_CONFIG="${PFA_REAL}/config.local.php" + +echo +echo -e "${_bold}Dovecot quota_clone + PostfixAdmin quota display${_nc}" +echo +info "Dovecot : $DOVECOT_REAL" +info "PFA : $PFA_REAL" + +# ── read DB credentials from Dovecot's sql-connect.conf.ext ────────────────── +[[ -f "$SQL_CONNECT" ]] || die "Not found: $SQL_CONNECT" +dbuser=$(grep -E '^\s*user\s*=' "$SQL_CONNECT" | head -1 | sed 's/.*=\s*//' | tr -d '[:space:]') +dbpass=$(grep -E '^\s*password\s*=' "$SQL_CONNECT" | head -1 | sed 's/.*=\s*//' | tr -d '[:space:]') +dbname=$(grep -E '^\s*dbname\s*=' "$SQL_CONNECT" | head -1 | sed 's/.*=\s*//' | tr -d '[:space:]') +[[ -n "$dbuser" ]] || die "Could not parse 'user' from $SQL_CONNECT" +[[ -n "$dbpass" ]] || die "Could not parse 'password' from $SQL_CONNECT" +[[ -n "$dbname" ]] || die "Could not parse 'dbname' from $SQL_CONNECT" +info "DB : $dbuser@$dbname" + +# ── backup ──────────────────────────────────────────────────────────────────── +heading "Backup" +mkdir -p "$BACKUP_BASE" +BACKUP_DIR="${BACKUP_BASE}/quota_clone_$(date +%Y%m%d_%H%M%S)" +mkdir -p "$BACKUP_DIR" + +step "$(basename "$DOVECOT_REAL") ..." +tar -czf "${BACKUP_DIR}/dovecot.tar.gz" \ + -C "$(dirname "$DOVECOT_REAL")" "$(basename "$DOVECOT_REAL")" \ + && ok || { failed; die "Backup of $DOVECOT_REAL failed."; } + +step "$(basename "$PFA_REAL") ..." +tar -czf "${BACKUP_DIR}/postfixadmin.tar.gz" \ + -C "$(dirname "$PFA_REAL")" "$(basename "$PFA_REAL")" \ + && ok || { failed; die "Backup of $PFA_REAL failed."; } + +info "Saved to: $BACKUP_DIR" + +# ── PostgreSQL ──────────────────────────────────────────────────────────────── +heading "PostgreSQL" + +step "Drop old mergequota2 trigger + function ..." +psql -U"$dbuser" "$dbname" -c " + DROP TRIGGER IF EXISTS mergequota2 ON quota2; + DROP FUNCTION IF EXISTS merge_quota2(); +" > /dev/null 2>&1 \ + && ok || { failed; die "Could not drop old mergequota2 trigger."; } + +# Single-quoted heredoc: $$ is passed literally to psql (no shell expansion). +step "Create upsertquota2 trigger + function ..." +psql -U"$dbuser" "$dbname" <<'SQL' > /dev/null 2>&1 +CREATE OR REPLACE FUNCTION upsert_quota2() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.bytes IS NULL OR NEW.bytes < 0 THEN NEW.bytes := 0; END IF; + IF NEW.messages IS NULL OR NEW.messages < 0 THEN NEW.messages := 0; END IF; + RETURN NEW; + END; + $$; + +ALTER FUNCTION public.upsert_quota2() OWNER TO postfix; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'upsertquota2') THEN + CREATE TRIGGER upsertquota2 + BEFORE INSERT ON quota2 + FOR EACH ROW EXECUTE PROCEDURE upsert_quota2(); + END IF; +END; +$$; +SQL +[[ $? -eq 0 ]] && ok || { failed; die "Could not create upsertquota2 trigger."; } + +# ── Dovecot configuration ───────────────────────────────────────────────────── +heading "Dovecot configuration" + +# 91-quota-clone.conf — always (re)written, content is idempotent +QUOTA_CLONE_CONF="${DOVECOT_CONF_D}/91-quota-clone.conf" +step "Write 91-quota-clone.conf ..." +cat > "$QUOTA_CLONE_CONF" </dev/null; then + skip +else + cat >> "$QUOTA_CONF" <<'EOF' + +## +## Quota Clone - write quota usage to quota2 table for PostfixAdmin +## +mail_plugins { + quota_clone = yes +} + +quota_clone { + dict proxy { + name = quota_clone_pgsql + } +} +EOF + [[ $? -eq 0 ]] && ok || { failed; die "Could not append to $QUOTA_CONF"; } +fi + +# ── PostfixAdmin ────────────────────────────────────────────────────────────── +heading "PostfixAdmin" + +[[ -f "$PFA_CONFIG" ]] || die "Not found: $PFA_CONFIG" + +# Sets $CONF['key'] = 'val' in config.local.php. +# - Already correct value → SKIP +# - Present with different value → update in place (perl, only non-commented lines) +# - Not present → append +pfa_set() { + local key="$1" val="$2" + step "\$CONF['${key}'] = '${val}' ..." + if grep -qF "\$CONF['${key}'] = '${val}';" "$PFA_CONFIG"; then + skip + return + fi + if grep -qF "\$CONF['${key}']" "$PFA_CONFIG"; then + # present with wrong value — update active (non-commented) line + perl -i -p -e "s|^(\s*\\\$CONF\['${key}'\]\s*=\s*)'[^']*'|\${1}'${val}'|" "$PFA_CONFIG" \ + && ok || { failed; die "Could not update \$CONF['${key}'] in $PFA_CONFIG"; } + else + # not present — append + { echo ""; echo "\$CONF['${key}'] = '${val}';"; } >> "$PFA_CONFIG" \ + && ok || { failed; die "Could not append \$CONF['${key}'] to $PFA_CONFIG"; } + fi +} + +pfa_set used_quotas YES +pfa_set new_quota_table YES + +# ── /usr/local/bin/update_quota2.sh ────────────────────────────────────────── +heading "Batch update script" + +# Single-quoted PYEOF: no shell expansion inside the Python source. +step "Install /usr/local/bin/update_quota2.sh ..." +cat > /usr/local/bin/update_quota2.sh <<'PYEOF' +#!/usr/bin/env python3 +""" +Batch-update the quota2 table for all active mailboxes. + +Uses 'doveadm quota get' (must run as root) to read current usage and +writes it to quota2 via INSERT ... ON CONFLICT DO UPDATE. + +Usage: + update_quota2.sh [--dry-run] [--limit N] + +Options: + --dry-run Print what would be done without touching the database. + --limit N Process only the first N mailboxes (useful for testing). +""" + +import subprocess +import sys + +DOVEADM = '/usr/local/dovecot/bin/doveadm' +PSQL = 'psql' +DB_USER = 'postfix' +DB_NAME = 'postfix' + + +def get_users(limit=None): + query = 'SELECT username FROM mailbox WHERE active = true ORDER BY username;' + if limit: + query = ('SELECT username FROM mailbox WHERE active = true ' + 'ORDER BY username LIMIT %d;' % limit) + result = subprocess.run( + [PSQL, '-U', DB_USER, DB_NAME, '-tAF', '|', '-c', query], + capture_output=True, text=True + ) + return [u.strip() for u in result.stdout.strip().split('\n') if u.strip()] + + +def get_quota(user): + """Returns (bytes, messages) or (None, None) if not available.""" + result = subprocess.run( + [DOVEADM, 'quota', 'get', '-u', user], + capture_output=True, text=True + ) + bytes_val = messages_val = None + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) < 4: + continue + if parts[2] == 'STORAGE' and parts[1] != 'Type': + try: + bytes_val = int(parts[3]) * 1024 # KiB → bytes + except ValueError: + bytes_val = 0 + elif parts[2] == 'MESSAGE' and parts[1] != 'Type': + try: + messages_val = int(parts[3]) + except ValueError: + messages_val = 0 + return bytes_val, messages_val + + +def upsert_quota2(user, bytes_val, messages_val): + sql = ( + "INSERT INTO quota2 (username, bytes, messages) VALUES ('" + + user.replace("'", "''") + + "', " + str(bytes_val) + ", " + str(messages_val) + ") " + "ON CONFLICT (username) DO UPDATE " + "SET bytes = EXCLUDED.bytes, messages = EXCLUDED.messages;" + ) + subprocess.run([PSQL, '-U', DB_USER, DB_NAME, '-c', sql], + capture_output=True) + + +def main(): + dry_run = '--dry-run' in sys.argv + limit = None + if '--limit' in sys.argv: + idx = sys.argv.index('--limit') + try: + limit = int(sys.argv[idx + 1]) + except (IndexError, ValueError): + print('Usage: update_quota2.sh [--dry-run] [--limit N]') + sys.exit(1) + + users = get_users(limit) + total = len(users) + print('Processing %d mailboxes...' % total) + + updated = skipped = 0 + for i, user in enumerate(users, 1): + bytes_val, messages_val = get_quota(user) + if bytes_val is None or messages_val is None: + print(' [%d/%d] SKIP %s (no quota data)' % (i, total, user)) + skipped += 1 + continue + if dry_run: + print(' [%d/%d] DRY %s: %d bytes, %d messages' + % (i, total, user, bytes_val, messages_val)) + else: + upsert_quota2(user, bytes_val, messages_val) + if i % 50 == 0 or i == total: + print(' [%d/%d] done' % (i, total)) + updated += 1 + + print('Done: %d updated, %d skipped.' % (updated, skipped)) + + +if __name__ == '__main__': + main() +PYEOF +chmod +x /usr/local/bin/update_quota2.sh +[[ $? -eq 0 ]] && ok || { failed; die "Could not install update_quota2.sh"; } + +# ── Dovecot reload ──────────────────────────────────────────────────────────── +heading "Dovecot reload" +step "Reloading Dovecot ..." +"${DOVECOT_REAL}/sbin/dovecot" reload \ + && ok || { failed; die "Dovecot reload failed."; } + +# ── summary ─────────────────────────────────────────────────────────────────── +echo +echo -e "${_bold}Done.${_nc}" +echo +info "Backup : $BACKUP_DIR" +info "Next : run the initial batch fill of quota2 for all mailboxes:" +info " /usr/local/bin/update_quota2.sh [--dry-run] [--limit 5]" +echo diff --git a/install_update_dovecot-2.4.sh b/install_update_dovecot-2.4.sh index 6c992fd..fe6b16e 100755 --- a/install_update_dovecot-2.4.sh +++ b/install_update_dovecot-2.4.sh @@ -9226,6 +9226,259 @@ EOF fi # Renew file /usr/local/dovecot-${_version}/etc/dovecot/sql-dict.conf.ext +## ----------------------------------------------------------------- +## --- quota_clone plugin (Dovecot 2.4+ / pgsql only) +## --- +## --- Writes quota usage to quota2 table in real time so PostfixAdmin +## --- can display mailbox fill levels ($CONF['used_quotas'] = 'YES'). +## --- +## --- Replaces the old ADD-semantics mergequota2 trigger with a +## --- SET-semantics upsertquota2 trigger (validation only); the dict +## --- proxy service generates INSERT … ON CONFLICT DO UPDATE natively. + +if [[ $dovecot_major_version -gt 2 ]] \ + || ( [[ $dovecot_major_version -eq 2 ]] && [[ $dovecot_minor_version -gt 3 ]] ); then + + if [ "$db_driver" = "pgsql" ]; then + + ## - Drop old ADD-semantics trigger/function if present + + echononl " Drop old mergequota2 trigger/function (if present).." + cat << EOF | psql -U$dbuser $dbname > /dev/null 2>&1 +DROP TRIGGER IF EXISTS mergequota2 ON quota2; +DROP FUNCTION IF EXISTS merge_quota2(); +EOF + if [ "$?" = 0 ]; then + echo -e "$rc_done" + else + echo -e "$rc_failed" + error "Dropping mergequota2 trigger/function failed" + fi + + ## - Create upsertquota2 trigger (validation / sanitise only) + + echononl " Create upsertquota2 trigger/function.." + cat << 'EOF' | psql -U$dbuser $dbname > /dev/null 2>&1 +CREATE OR REPLACE FUNCTION upsert_quota2() RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF NEW.bytes IS NULL OR NEW.bytes < 0 THEN + NEW.bytes := 0; + END IF; + IF NEW.messages IS NULL OR NEW.messages < 0 THEN + NEW.messages := 0; + END IF; + RETURN NEW; + END; + $$; + +ALTER FUNCTION public.upsert_quota2() OWNER TO postfix; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'upsertquota2' + ) THEN + CREATE TRIGGER upsertquota2 + BEFORE INSERT ON quota2 + FOR EACH ROW + EXECUTE PROCEDURE upsert_quota2(); + END IF; +END; +$$; +EOF + if [ "$?" = 0 ]; then + echo -e "$rc_done" + else + echo -e "$rc_failed" + error "Creating upsertquota2 trigger/function failed" + fi + + ## - Create 91-quota-clone.conf + + _conf_file="/usr/local/dovecot-${_version}/etc/dovecot/conf.d/91-quota-clone.conf" + echononl " Create '$(basename "${_conf_file}")' (dict_server for quota_clone).." + cat < "${_conf_file}" +## +## Quota Clone Configuration (Dovecot 2.4+) +## +## Writes quota usage to the quota2 PostgreSQL table in real time so +## PostfixAdmin can display mailbox fill levels. +## +dict_server { + dict quota_clone_pgsql { + driver = sql + sql_driver = pgsql + pgsql localhost { + parameters { + user = $dbuser + password = $dbpassword + dbname = $dbname + } + } + dict_map priv/quota/storage { + sql_table = quota2 + username_field = username + value_field bytes { + } + } + dict_map priv/quota/messages { + sql_table = quota2 + username_field = username + value_field messages { + } + } + } +} +EOF + if [ "$?" = 0 ]; then + echo -e "$rc_done" + else + echo -e "$rc_failed" + error "Creating file '${_conf_file}' failed!" + fi + + ## - Append quota_clone plugin block to 90-quota.conf + + _conf_file="/usr/local/dovecot-${_version}/etc/dovecot/conf.d/90-quota.conf" + echononl " Append quota_clone block to '$(basename "${_conf_file}")'.." + if ! grep -q "quota_clone_pgsql" "${_conf_file}" 2>/dev/null; then + cat <<'EOF' >> "${_conf_file}" + +## +## Quota Clone - write quota usage to quota2 table for PostfixAdmin +## +mail_plugins { + quota_clone = yes +} + +quota_clone { + dict proxy { + name = quota_clone_pgsql + } +} +EOF + if [ "$?" = 0 ]; then + echo -e "$rc_done" + else + echo -e "$rc_failed" + error "Appending quota_clone block to '${_conf_file}' failed!" + fi + else + echo -e "${rc_already_done}" + fi + + ## - Install /usr/local/bin/update_quota2.sh (initial batch fill) + + echononl " Install /usr/local/bin/update_quota2.sh.." + cat <<'PYEOF' > /usr/local/bin/update_quota2.sh +#!/usr/bin/env python3 +"""Batch-update quota2 table for all active mailboxes from doveadm quota get.""" + +import subprocess +import sys + +DOVEADM = '/usr/local/dovecot/bin/doveadm' +PSQL = 'psql' +DB_USER = 'postfix' +DB_NAME = 'postfix' + + +def get_users(limit=None): + query = 'SELECT username FROM mailbox WHERE active = true ORDER BY username;' + if limit: + query = 'SELECT username FROM mailbox WHERE active = true ORDER BY username LIMIT %d;' % limit + result = subprocess.run( + [PSQL, '-U', DB_USER, DB_NAME, '-tAF', '|', '-c', query], + capture_output=True, text=True + ) + return [u.strip() for u in result.stdout.strip().split('\n') if u.strip()] + + +def get_quota(user): + result = subprocess.run( + [DOVEADM, 'quota', 'get', '-u', user], + capture_output=True, text=True + ) + bytes_val = None + messages_val = None + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) < 4: + continue + if parts[2] == 'STORAGE' and parts[1] != 'Type': + try: + bytes_val = int(parts[3]) * 1024 + except ValueError: + bytes_val = 0 + elif parts[2] == 'MESSAGE' and parts[1] != 'Type': + try: + messages_val = int(parts[3]) + except ValueError: + messages_val = 0 + return bytes_val, messages_val + + +def upsert_quota2(user, bytes_val, messages_val): + cmd = [PSQL, '-U', DB_USER, DB_NAME, '-c', + "INSERT INTO quota2 (username, bytes, messages) VALUES ('" + + user.replace("'", "''") + "', " + str(bytes_val) + ", " + + str(messages_val) + + ") ON CONFLICT (username) DO UPDATE SET bytes = EXCLUDED.bytes, messages = EXCLUDED.messages;"] + subprocess.run(cmd, capture_output=True) + + +def main(): + dry_run = '--dry-run' in sys.argv + limit = None + if '--limit' in sys.argv: + idx = sys.argv.index('--limit') + try: + limit = int(sys.argv[idx + 1]) + except (IndexError, ValueError): + print('Usage: update_quota2.sh [--dry-run] [--limit N]') + sys.exit(1) + + users = get_users(limit) + total = len(users) + print('Processing %d mailboxes...' % total) + + ok = 0 + skip = 0 + for i, user in enumerate(users, 1): + bytes_val, messages_val = get_quota(user) + if bytes_val is None or messages_val is None: + print(' [%d/%d] SKIP %s (no quota data)' % (i, total, user)) + skip += 1 + continue + if dry_run: + print(' [%d/%d] DRY %s: %d bytes, %d messages' % (i, total, user, bytes_val, messages_val)) + else: + upsert_quota2(user, bytes_val, messages_val) + if i % 50 == 0 or i == total: + print(' [%d/%d] done' % (i, total)) + ok += 1 + + print('Done: %d updated, %d skipped.' % (ok, skip)) + + +if __name__ == '__main__': + main() +PYEOF + chmod +x /usr/local/bin/update_quota2.sh + if [ "$?" = 0 ]; then + echo -e "$rc_done" + else + echo -e "$rc_failed" + error "Installing /usr/local/bin/update_quota2.sh failed!" + fi + + fi # db_driver = pgsql + +fi # Dovecot 2.4+ quota_clone + + # In order to support extra variable "quota_rule", also userdb's and # passdb's SQL query have to update #