This commit is contained in:
2026-07-02 02:23:59 +02:00
2 changed files with 570 additions and 141 deletions
+320 -130
View File
@@ -3,13 +3,15 @@
## 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.
## quota display by writing real-time usage to the quota2 table.
##
## Supports PostgreSQL and MySQL/MariaDB backends.
## 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 the quota2 table if it does not exist (MySQL only)
## - Drops the old ADD-semantics mergequota2 trigger (if present)
## - 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
@@ -52,7 +54,6 @@ SQL_CONNECT="${DOVECOT_REAL}/etc/dovecot/sql-connect.conf.ext"
info "Dovecot : $DOVECOT_REAL"
# ── PostfixAdmin path (interactive) ──────────────────────────────────────────
# Auto-detect: look for a 'postfixadmin' symlink under /var/www/*/htdocs/
_pfa_default=""
mapfile -t _pfa_candidates < <(find /var/www -maxdepth 3 -name "postfixadmin" -type l 2>/dev/null | sort)
if [[ ${#_pfa_candidates[@]} -eq 1 ]]; then
@@ -90,15 +91,50 @@ else
fi
info "Config : $PFA_CONFIG"
# ── read DB credentials from Dovecot's sql-connect.conf.ext ──────────────────
# ── DB credentials from sql-connect.conf.ext ─────────────────────────────────
[[ -f "$SQL_CONNECT" ]] || die "Not found: $SQL_CONNECT"
sql_driver=$(grep -E '^\s*sql_driver\s*=' "$SQL_CONNECT" | head -1 | sed 's/.*=\s*//' | tr -d '[:space:]')
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 "$sql_driver" ]] || die "Could not parse 'sql_driver' from $SQL_CONNECT"
[[ -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"
case "$sql_driver" in
pgsql)
info "DB : PostgreSQL $dbuser@$dbname"
dbsocket=""
;;
mysql)
# socket path: line looks like mysql /run/mysqld/mysqld.sock {
dbsocket=$(grep -E '^\s*mysql\s+/' "$SQL_CONNECT" | head -1 | awk '{print $2}')
[[ -n "$dbsocket" ]] || die "Could not parse MySQL socket path from $SQL_CONNECT"
info "DB : MariaDB/MySQL $dbuser@$dbname (socket: $dbsocket)"
;;
*)
die "Unsupported sql_driver '$sql_driver' in $SQL_CONNECT (expected: pgsql or mysql)"
;;
esac
# ── DB helper: run SQL (DDL: CREATE TABLE, triggers) ─────────────────────────
# pgsql: postfix user via psql.
# mysql: OS root → MariaDB root via unix_socket auth (Debian default);
# falls back to postfix user credentials if that fails.
db_exec() {
if [[ "$sql_driver" == "pgsql" ]]; then
psql -U"$dbuser" "$dbname" "$@"
else
if mysql --socket="$dbsocket" "$dbname" "$@" 2>/dev/null; then
return 0
else
mysql -u"$dbuser" -p"$dbpass" --socket="$dbsocket" "$dbname" "$@"
fi
fi
}
# ── backup ────────────────────────────────────────────────────────────────────
heading "Backup"
@@ -107,30 +143,70 @@ 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."; }
if tar -czf "${BACKUP_DIR}/dovecot.tar.gz" \
-C "$(dirname "$DOVECOT_REAL")" "$(basename "$DOVECOT_REAL")"; then
ok
else
failed; die "Backup of $DOVECOT_REAL failed."
fi
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."; }
_pfa_backup_src=$(dirname "$PFA_REAL")
_pfa_backup_name=$(basename "$PFA_REAL")
step "${_pfa_backup_name} ..."
if tar -czf "${BACKUP_DIR}/postfixadmin.tar.gz" \
-C "$_pfa_backup_src" "$_pfa_backup_name"; then
ok
else
failed; die "Backup of $PFA_REAL failed."
fi
info "Saved to: $BACKUP_DIR"
# ── PostgreSQL ────────────────────────────────────────────────────────────────
heading "PostgreSQL"
# ── Database ──────────────────────────────────────────────────────────────────
if [[ "$sql_driver" == "pgsql" ]]; then
heading "PostgreSQL"
else
heading "MariaDB / MySQL"
fi
step "Drop old mergequota2 trigger + function ..."
psql -U"$dbuser" "$dbname" -c "
if [[ "$sql_driver" == "mysql" ]]; then
step "Create quota2 table if not exists ..."
if db_exec -e "
CREATE TABLE IF NOT EXISTS quota2 (
username varchar(100) NOT NULL,
bytes bigint NOT NULL DEFAULT 0,
messages int NOT NULL DEFAULT 0,
PRIMARY KEY (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
" > /dev/null 2>&1; then
ok
else
failed; die "Could not create quota2 table."
fi
fi
step "Drop old mergequota2 trigger ..."
if [[ "$sql_driver" == "pgsql" ]]; then
if db_exec -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."; }
" > /dev/null 2>&1; then
ok
else
failed; die "Could not drop old mergequota2 trigger."
fi
else
if db_exec -e "DROP TRIGGER IF EXISTS mergequota2;" > /dev/null 2>&1; then
ok
else
failed; die "Could not drop old mergequota2 trigger."
fi
fi
# 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
step "Create upsertquota2 trigger ..."
if [[ "$sql_driver" == "pgsql" ]]; then
# Single-quoted heredoc: $$ reaches psql literally (no shell expansion).
if db_exec <<'SQL' > /dev/null 2>&1
CREATE OR REPLACE FUNCTION upsert_quota2() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
@@ -152,20 +228,42 @@ BEGIN
END;
$$;
SQL
[[ $? -eq 0 ]] && ok || { failed; die "Could not create upsertquota2 trigger."; }
then
ok
else
failed; die "Could not create upsertquota2 trigger."
fi
else
# DELIMITER lets the MySQL CLI handle ; inside BEGIN...END in batch mode.
if db_exec <<'SQL' > /dev/null 2>&1
DROP TRIGGER IF EXISTS upsertquota2;
DELIMITER //
CREATE TRIGGER upsertquota2
BEFORE INSERT ON quota2
FOR EACH ROW
BEGIN
IF NEW.bytes IS NULL OR NEW.bytes < 0 THEN SET NEW.bytes = 0; END IF;
IF NEW.messages IS NULL OR NEW.messages < 0 THEN SET NEW.messages = 0; END IF;
END//
DELIMITER ;
SQL
then
ok
else
failed; die "Could not create upsertquota2 trigger."
fi
fi
# ── 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" <<EOF
_write_quota_clone_conf() {
if [[ "$sql_driver" == "pgsql" ]]; then
cat > "$QUOTA_CLONE_CONF" <<EOF
##
## Quota Clone Configuration (Dovecot 2.4+)
##
## Writes quota usage to the quota2 table in real time so PostfixAdmin
## can display mailbox fill levels via \$CONF['used_quotas'] = 'YES'.
## Quota Clone Configuration (Dovecot 2.4+ / PostgreSQL)
##
dict_server {
dict quota_clone_pgsql {
@@ -193,15 +291,54 @@ dict_server {
}
}
EOF
[[ $? -eq 0 ]] && ok || { failed; die "Could not write $QUOTA_CLONE_CONF"; }
else
cat > "$QUOTA_CLONE_CONF" <<EOF
##
## Quota Clone Configuration (Dovecot 2.4+ / MariaDB / MySQL)
##
dict_server {
dict quota_clone_mysql {
driver = sql
sql_driver = mysql
mysql $dbsocket {
user = $dbuser
password = $dbpass
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
fi
}
step "Write 91-quota-clone.conf ..."
if _write_quota_clone_conf; then ok; else failed; die "Could not write $QUOTA_CLONE_CONF"; fi
# dict name differs by driver — used both as grep marker and in quota_clone block
if [[ "$sql_driver" == "pgsql" ]]; then
_dict_name="quota_clone_pgsql"
else
_dict_name="quota_clone_mysql"
fi
# 90-quota.conf — append only if not already present
QUOTA_CONF="${DOVECOT_CONF_D}/90-quota.conf"
step "Append quota_clone block to 90-quota.conf ..."
if grep -q "quota_clone_pgsql" "$QUOTA_CONF" 2>/dev/null; then
if grep -q "$_dict_name" "$QUOTA_CONF" 2>/dev/null; then
skip
else
cat >> "$QUOTA_CONF" <<'EOF'
if cat >> "$QUOTA_CONF" <<EOF
##
## Quota Clone - write quota usage to quota2 table for PostfixAdmin
@@ -212,20 +349,20 @@ mail_plugins {
quota_clone {
dict proxy {
name = quota_clone_pgsql
name = $_dict_name
}
}
EOF
[[ $? -eq 0 ]] && ok || { failed; die "Could not append to $QUOTA_CONF"; }
then
ok
else
failed; die "Could not append to $QUOTA_CONF"
fi
fi
# ── PostfixAdmin ──────────────────────────────────────────────────────────────
heading "PostfixAdmin"
# 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}' ..."
@@ -234,13 +371,17 @@ pfa_set() {
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"; }
if perl -i -p -e "s|^(\s*\\\$CONF\['${key}'\]\s*=\s*)'[^']*'|\${1}'${val}'|" "$PFA_CONFIG"; then
ok
else
# not present — append
{ echo ""; echo "\$CONF['${key}'] = '${val}';"; } >> "$PFA_CONFIG" \
&& ok || { failed; die "Could not append \$CONF['${key}'] to $PFA_CONFIG"; }
failed; die "Could not update \$CONF['${key}'] in $PFA_CONFIG"
fi
else
if { echo ""; echo "\$CONF['${key}'] = '${val}';"; } >> "$PFA_CONFIG"; then
ok
else
failed; die "Could not append \$CONF['${key}'] to $PFA_CONFIG"
fi
fi
}
@@ -250,126 +391,175 @@ 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 ..."
if [[ "$sql_driver" == "pgsql" ]]; then
cat > /usr/local/bin/update_quota2.sh <<'PYEOF'
#!/usr/bin/env python3
"""
Batch-update the quota2 table for all active mailboxes.
Batch-update the quota2 table for all active mailboxes (PostgreSQL).
Uses 'doveadm quota get' (must run as root) to read current usage and
writes it to quota2 via INSERT ... ON CONFLICT DO UPDATE.
Uses 'doveadm quota get' (must run as root) and writes 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).
Usage: update_quota2.sh [--dry-run] [--limit N]
"""
import subprocess
import sys
import subprocess, 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;'
q = '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()]
q = 'SELECT username FROM mailbox WHERE active = true ORDER BY username LIMIT %d;' % limit
r = subprocess.run([PSQL, '-U', DB_USER, DB_NAME, '-tAF', '|', '-c', q],
capture_output=True, text=True)
return [u.strip() for u in r.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
r = subprocess.run([DOVEADM, 'quota', 'get', '-u', user],
capture_output=True, text=True)
bv = mv = None
for line in r.stdout.splitlines():
p = line.split()
if len(p) < 4: continue
if p[2] == 'STORAGE' and p[1] != 'Type':
try: bv = int(p[3]) * 1024
except ValueError: bv = 0
elif p[2] == 'MESSAGE' and p[1] != 'Type':
try: mv = int(p[3])
except ValueError: mv = 0
return bv, mv
def upsert_quota2(user, bytes_val, messages_val):
sql = (
"INSERT INTO quota2 (username, bytes, messages) VALUES ('"
+ user.replace("'", "''")
+ "', " + str(bytes_val) + ", " + str(messages_val) + ") "
def upsert(user, bv, mv):
sql = ("INSERT INTO quota2 (username,bytes,messages) VALUES ('"
+ user.replace("'","''") + "'," + str(bv) + "," + str(mv) + ") "
"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)
"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
dry = '--dry-run' in sys.argv
limit = None
if '--limit' in sys.argv:
idx = sys.argv.index('--limit')
try:
limit = int(sys.argv[idx + 1])
try: limit = int(sys.argv[sys.argv.index('--limit') + 1])
except (IndexError, ValueError):
print('Usage: update_quota2.sh [--dry-run] [--limit N]')
sys.exit(1)
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
ok = 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))
skipped += 1
continue
if dry_run:
print(' [%d/%d] DRY %s: %d bytes, %d messages'
% (i, total, user, bytes_val, messages_val))
bv, mv = get_quota(user)
if bv is None or mv is None:
print(' [%d/%d] SKIP %s' % (i, total, user)); skip += 1; continue
if dry:
print(' [%d/%d] DRY %s: %d bytes, %d msgs' % (i, total, user, bv, mv))
else:
upsert_quota2(user, bytes_val, messages_val)
if i % 50 == 0 or i == total:
print(' [%d/%d] done' % (i, total))
updated += 1
upsert(user, bv, mv)
if i % 50 == 0 or i == total: print(' [%d/%d] done' % (i, total))
ok += 1
print('Done: %d updated, %d skipped.' % (ok, skip))
print('Done: %d updated, %d skipped.' % (updated, skipped))
if __name__ == '__main__':
main()
if __name__ == '__main__': main()
PYEOF
chmod +x /usr/local/bin/update_quota2.sh
[[ $? -eq 0 ]] && ok || { failed; die "Could not install update_quota2.sh"; }
else # mysql — shell expands DB_USER, DB_PASS, DB_SOCKET, DB_NAME into the script
cat > /usr/local/bin/update_quota2.sh <<PYEOF
#!/usr/bin/env python3
"""
Batch-update the quota2 table for all active mailboxes (MariaDB/MySQL).
Uses 'doveadm quota get' (must run as root) and writes to quota2 via
REPLACE INTO.
Usage: update_quota2.sh [--dry-run] [--limit N]
"""
import subprocess, sys
DOVEADM = '/usr/local/dovecot/bin/doveadm'
MYSQL = 'mysql'
DB_USER = '$dbuser'
DB_PASS = '$dbpass'
DB_SOCKET = '$dbsocket'
DB_NAME = '$dbname'
def mysql_query(sql):
return subprocess.run(
[MYSQL, '-u', DB_USER, '-p' + DB_PASS, '--socket', DB_SOCKET,
DB_NAME, '-sNe', sql],
capture_output=True, text=True)
def get_users(limit=None):
q = 'SELECT username FROM mailbox WHERE active = 1 ORDER BY username;'
if limit:
q = 'SELECT username FROM mailbox WHERE active = 1 ORDER BY username LIMIT %d;' % limit
r = mysql_query(q)
return [u.strip() for u in r.stdout.strip().split('\n') if u.strip()]
def get_quota(user):
r = subprocess.run([DOVEADM, 'quota', 'get', '-u', user],
capture_output=True, text=True)
bv = mv = None
for line in r.stdout.splitlines():
p = line.split()
if len(p) < 4: continue
if p[2] == 'STORAGE' and p[1] != 'Type':
try: bv = int(p[3]) * 1024
except ValueError: bv = 0
elif p[2] == 'MESSAGE' and p[1] != 'Type':
try: mv = int(p[3])
except ValueError: mv = 0
return bv, mv
def upsert(user, bv, mv):
sql = ("REPLACE INTO quota2 (username, bytes, messages) VALUES ('"
+ user.replace("'", "\\'") + "', " + str(bv) + ", " + str(mv) + ");")
mysql_query(sql)
def main():
dry = '--dry-run' in sys.argv
limit = None
if '--limit' in sys.argv:
try: limit = int(sys.argv[sys.argv.index('--limit') + 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 = skip = 0
for i, user in enumerate(users, 1):
bv, mv = get_quota(user)
if bv is None or mv is None:
print(' [%d/%d] SKIP %s' % (i, total, user)); skip += 1; continue
if dry:
print(' [%d/%d] DRY %s: %d bytes, %d msgs' % (i, total, user, bv, mv))
else:
upsert(user, bv, mv)
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
fi # sql_driver
if chmod +x /usr/local/bin/update_quota2.sh; then ok; else failed; die "Could not install update_quota2.sh"; fi
# ── Dovecot reload ────────────────────────────────────────────────────────────
heading "Dovecot reload"
step "Reloading Dovecot ..."
"${DOVECOT_REAL}/sbin/dovecot" reload \
&& ok || { failed; die "Dovecot reload failed."; }
if "${DOVECOT_REAL}/sbin/dovecot" reload; then
ok
else
failed; die "Dovecot reload failed."
fi
# ── summary ───────────────────────────────────────────────────────────────────
echo
+243 -4
View File
@@ -9227,14 +9227,15 @@ fi # Renew file /usr/local/dovecot-${_version}/etc/dovecot/sql-dict.conf.ext
## -----------------------------------------------------------------
## --- quota_clone plugin (Dovecot 2.4+ / pgsql only)
## --- quota_clone plugin (Dovecot 2.4+ / pgsql + mysql)
## ---
## --- 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.
## --- SET-semantics upsertquota2 trigger (validation only); Dovecot's
## --- dict service generates native UPSERT queries (pgsql: ON CONFLICT
## --- DO UPDATE; mysql: ON DUPLICATE KEY UPDATE).
if [[ $dovecot_major_version -gt 2 ]] \
|| ( [[ $dovecot_major_version -eq 2 ]] && [[ $dovecot_minor_version -gt 3 ]] ); then
@@ -9474,7 +9475,245 @@ PYEOF
error "Installing /usr/local/bin/update_quota2.sh failed!"
fi
fi # db_driver = pgsql
elif [ "$db_driver" = "mysql" ]; then
## - Create quota2 table if it does not yet exist
echononl " Create quota2 table if not exists.."
cat << EOF | mysql -u$dbuser -p$dbpassword $dbname > /dev/null 2>&1
CREATE TABLE IF NOT EXISTS quota2 (
username varchar(100) NOT NULL,
bytes bigint NOT NULL DEFAULT 0,
messages integer NOT NULL DEFAULT 0,
PRIMARY KEY (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
EOF
if [ "$?" = 0 ]; then
echo -e "$rc_done"
else
echo -e "$rc_failed"
error "Creating quota2 table failed"
fi
## - Drop old ADD-semantics trigger if present
echononl " Drop old mergequota2 trigger (if present).."
cat << EOF | mysql -u$dbuser -p$dbpassword $dbname > /dev/null 2>&1
DROP TRIGGER IF EXISTS mergequota2;
EOF
if [ "$?" = 0 ]; then
echo -e "$rc_done"
else
echo -e "$rc_failed"
error "Dropping mergequota2 trigger failed"
fi
## - Create upsertquota2 trigger (DELIMITER needed in MySQL batch mode)
echononl " Create upsertquota2 trigger.."
cat << 'EOF' | mysql -u$dbuser -p$dbpassword $dbname > /dev/null 2>&1
DROP TRIGGER IF EXISTS upsertquota2;
DELIMITER //
CREATE TRIGGER upsertquota2
BEFORE INSERT ON quota2
FOR EACH ROW
BEGIN
IF NEW.bytes IS NULL OR NEW.bytes < 0 THEN SET NEW.bytes = 0; END IF;
IF NEW.messages IS NULL OR NEW.messages < 0 THEN SET NEW.messages = 0; END IF;
END//
DELIMITER ;
EOF
if [ "$?" = 0 ]; then
echo -e "$rc_done"
else
echo -e "$rc_failed"
error "Creating upsertquota2 trigger 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 <<EOF > "${_conf_file}"
##
## Quota Clone Configuration (Dovecot 2.4+ / MariaDB / MySQL)
##
## Writes quota usage to the quota2 table in real time so
## PostfixAdmin can display mailbox fill levels.
##
dict_server {
dict quota_clone_mysql {
driver = sql
sql_driver = mysql
mysql $dbhost {
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_mysql" "${_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_mysql
}
}
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)
## - Variables $dbuser, $dbpassword, $dbhost, $dbname are expanded
## - into the Python script by the unquoted heredoc.
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'
MYSQL = 'mysql'
DB_USER = '$dbuser'
DB_PASS = '$dbpassword'
DB_SOCKET = '$dbhost'
DB_NAME = '$dbname'
def mysql_query(sql):
return subprocess.run(
[MYSQL, '-u', DB_USER, '-p' + DB_PASS, '--socket', DB_SOCKET,
DB_NAME, '-sNe', sql],
capture_output=True, text=True)
def get_users(limit=None):
query = 'SELECT username FROM mailbox WHERE active = 1 ORDER BY username;'
if limit:
query = 'SELECT username FROM mailbox WHERE active = 1 ORDER BY username LIMIT %d;' % limit
r = mysql_query(query)
return [u.strip() for u in r.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):
sql = ("REPLACE INTO quota2 (username, bytes, messages) VALUES ('"
+ user.replace("'", "\\'") + "', "
+ str(bytes_val) + ", " + str(messages_val) + ");")
mysql_query(sql)
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
fi # Dovecot 2.4+ quota_clone