install_update_dovecot-2.4.sh: enhance quota_clone plugin support for MySQL and update documentation

This commit is contained in:
2026-07-02 02:11:55 +02:00
parent d917dc69bb
commit 17e858d51b
2 changed files with 570 additions and 141 deletions
+327 -137
View File
@@ -3,13 +3,15 @@
## install_quota_clone.sh ## install_quota_clone.sh
## ##
## Configures Dovecot 2.4 quota_clone plugin and enables PostfixAdmin ## 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. ## Must be run as root on the target mail server.
## ##
## What this script does: ## What this script does:
## - Backs up the Dovecot and PostfixAdmin directories ## - 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 a new SET-semantics upsertquota2 trigger (validation only)
## - Creates /etc/dovecot/conf.d/91-quota-clone.conf (dict_server) ## - Creates /etc/dovecot/conf.d/91-quota-clone.conf (dict_server)
## - Appends the quota_clone plugin block to 90-quota.conf ## - 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" info "Dovecot : $DOVECOT_REAL"
# ── PostfixAdmin path (interactive) ────────────────────────────────────────── # ── PostfixAdmin path (interactive) ──────────────────────────────────────────
# Auto-detect: look for a 'postfixadmin' symlink under /var/www/*/htdocs/
_pfa_default="" _pfa_default=""
mapfile -t _pfa_candidates < <(find /var/www -maxdepth 3 -name "postfixadmin" -type l 2>/dev/null | sort) mapfile -t _pfa_candidates < <(find /var/www -maxdepth 3 -name "postfixadmin" -type l 2>/dev/null | sort)
if [[ ${#_pfa_candidates[@]} -eq 1 ]]; then if [[ ${#_pfa_candidates[@]} -eq 1 ]]; then
@@ -90,15 +91,50 @@ else
fi fi
info "Config : $PFA_CONFIG" 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" [[ -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:]') 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:]') 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:]') 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 "$sql_driver" ]] || die "Could not parse 'sql_driver' from $SQL_CONNECT"
[[ -n "$dbname" ]] || die "Could not parse 'dbname' from $SQL_CONNECT" [[ -n "$dbuser" ]] || die "Could not parse 'user' from $SQL_CONNECT"
info "DB : $dbuser@$dbname" [[ -n "$dbpass" ]] || die "Could not parse 'password' from $SQL_CONNECT"
[[ -n "$dbname" ]] || die "Could not parse 'dbname' from $SQL_CONNECT"
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 ──────────────────────────────────────────────────────────────────── # ── backup ────────────────────────────────────────────────────────────────────
heading "Backup" heading "Backup"
@@ -107,30 +143,70 @@ BACKUP_DIR="${BACKUP_BASE}/quota_clone_$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
step "$(basename "$DOVECOT_REAL") ..." step "$(basename "$DOVECOT_REAL") ..."
tar -czf "${BACKUP_DIR}/dovecot.tar.gz" \ if tar -czf "${BACKUP_DIR}/dovecot.tar.gz" \
-C "$(dirname "$DOVECOT_REAL")" "$(basename "$DOVECOT_REAL")" \ -C "$(dirname "$DOVECOT_REAL")" "$(basename "$DOVECOT_REAL")"; then
&& ok || { failed; die "Backup of $DOVECOT_REAL failed."; } ok
else
failed; die "Backup of $DOVECOT_REAL failed."
fi
step "$(basename "$PFA_REAL") ..." _pfa_backup_src=$(dirname "$PFA_REAL")
tar -czf "${BACKUP_DIR}/postfixadmin.tar.gz" \ _pfa_backup_name=$(basename "$PFA_REAL")
-C "$(dirname "$PFA_REAL")" "$(basename "$PFA_REAL")" \ step "${_pfa_backup_name} ..."
&& ok || { failed; die "Backup of $PFA_REAL failed."; } 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" info "Saved to: $BACKUP_DIR"
# ── PostgreSQL ──────────────────────────────────────────────────────────────── # ── Database ──────────────────────────────────────────────────────────────────
heading "PostgreSQL" if [[ "$sql_driver" == "pgsql" ]]; then
heading "PostgreSQL"
else
heading "MariaDB / MySQL"
fi
step "Drop old mergequota2 trigger + function ..." if [[ "$sql_driver" == "mysql" ]]; then
psql -U"$dbuser" "$dbname" -c " step "Create quota2 table if not exists ..."
DROP TRIGGER IF EXISTS mergequota2 ON quota2; if db_exec -e "
DROP FUNCTION IF EXISTS merge_quota2(); CREATE TABLE IF NOT EXISTS quota2 (
" > /dev/null 2>&1 \ username varchar(100) NOT NULL,
&& ok || { failed; die "Could not drop old mergequota2 trigger."; } 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
# Single-quoted heredoc: $$ is passed literally to psql (no shell expansion). step "Drop old mergequota2 trigger ..."
step "Create upsertquota2 trigger + function ..." if [[ "$sql_driver" == "pgsql" ]]; then
psql -U"$dbuser" "$dbname" <<'SQL' > /dev/null 2>&1 if db_exec -c "
DROP TRIGGER IF EXISTS mergequota2 ON quota2;
DROP FUNCTION IF EXISTS merge_quota2();
" > /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
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 CREATE OR REPLACE FUNCTION upsert_quota2() RETURNS trigger
LANGUAGE plpgsql AS $$ LANGUAGE plpgsql AS $$
BEGIN BEGIN
@@ -152,20 +228,42 @@ BEGIN
END; END;
$$; $$;
SQL 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 ───────────────────────────────────────────────────── # ── Dovecot configuration ─────────────────────────────────────────────────────
heading "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" 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+) ## Quota Clone Configuration (Dovecot 2.4+ / PostgreSQL)
##
## Writes quota usage to the quota2 table in real time so PostfixAdmin
## can display mailbox fill levels via \$CONF['used_quotas'] = 'YES'.
## ##
dict_server { dict_server {
dict quota_clone_pgsql { dict quota_clone_pgsql {
@@ -193,15 +291,54 @@ dict_server {
} }
} }
EOF 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" QUOTA_CONF="${DOVECOT_CONF_D}/90-quota.conf"
step "Append quota_clone block to 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 skip
else else
cat >> "$QUOTA_CONF" <<'EOF' if cat >> "$QUOTA_CONF" <<EOF
## ##
## Quota Clone - write quota usage to quota2 table for PostfixAdmin ## Quota Clone - write quota usage to quota2 table for PostfixAdmin
@@ -212,20 +349,20 @@ mail_plugins {
quota_clone { quota_clone {
dict proxy { dict proxy {
name = quota_clone_pgsql name = $_dict_name
} }
} }
EOF 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 fi
# ── PostfixAdmin ────────────────────────────────────────────────────────────── # ── PostfixAdmin ──────────────────────────────────────────────────────────────
heading "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() { pfa_set() {
local key="$1" val="$2" local key="$1" val="$2"
step "\$CONF['${key}'] = '${val}' ..." step "\$CONF['${key}'] = '${val}' ..."
@@ -234,142 +371,195 @@ pfa_set() {
return return
fi fi
if grep -qF "\$CONF['${key}']" "$PFA_CONFIG"; then if grep -qF "\$CONF['${key}']" "$PFA_CONFIG"; then
# present with wrong value — update active (non-commented) line if perl -i -p -e "s|^(\s*\\\$CONF\['${key}'\]\s*=\s*)'[^']*'|\${1}'${val}'|" "$PFA_CONFIG"; then
perl -i -p -e "s|^(\s*\\\$CONF\['${key}'\]\s*=\s*)'[^']*'|\${1}'${val}'|" "$PFA_CONFIG" \ ok
&& ok || { failed; die "Could not update \$CONF['${key}'] in $PFA_CONFIG"; } else
failed; die "Could not update \$CONF['${key}'] in $PFA_CONFIG"
fi
else else
# not present — append if { echo ""; echo "\$CONF['${key}'] = '${val}';"; } >> "$PFA_CONFIG"; then
{ echo ""; echo "\$CONF['${key}'] = '${val}';"; } >> "$PFA_CONFIG" \ ok
&& ok || { failed; die "Could not append \$CONF['${key}'] to $PFA_CONFIG"; } else
failed; die "Could not append \$CONF['${key}'] to $PFA_CONFIG"
fi
fi fi
} }
pfa_set used_quotas YES pfa_set used_quotas YES
pfa_set new_quota_table YES pfa_set new_quota_table YES
# ── /usr/local/bin/update_quota2.sh ────────────────────────────────────────── # ── /usr/local/bin/update_quota2.sh ──────────────────────────────────────────
heading "Batch update script" heading "Batch update script"
# Single-quoted PYEOF: no shell expansion inside the Python source.
step "Install /usr/local/bin/update_quota2.sh ..." step "Install /usr/local/bin/update_quota2.sh ..."
if [[ "$sql_driver" == "pgsql" ]]; then
cat > /usr/local/bin/update_quota2.sh <<'PYEOF' cat > /usr/local/bin/update_quota2.sh <<'PYEOF'
#!/usr/bin/env python3 #!/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 Uses 'doveadm quota get' (must run as root) and writes to quota2 via
writes it to quota2 via INSERT ... ON CONFLICT DO UPDATE. INSERT ... ON CONFLICT DO UPDATE.
Usage: Usage: update_quota2.sh [--dry-run] [--limit N]
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, sys
import subprocess
import sys
DOVEADM = '/usr/local/dovecot/bin/doveadm' DOVEADM = '/usr/local/dovecot/bin/doveadm'
PSQL = 'psql' PSQL = 'psql'
DB_USER = 'postfix' DB_USER = 'postfix'
DB_NAME = 'postfix' DB_NAME = 'postfix'
def get_users(limit=None): 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: if limit:
query = ('SELECT username FROM mailbox WHERE active = true ' q = 'SELECT username FROM mailbox WHERE active = true ORDER BY username LIMIT %d;' % limit
'ORDER BY username LIMIT %d;' % limit) r = subprocess.run([PSQL, '-U', DB_USER, DB_NAME, '-tAF', '|', '-c', q],
result = subprocess.run( capture_output=True, text=True)
[PSQL, '-U', DB_USER, DB_NAME, '-tAF', '|', '-c', query], return [u.strip() for u in r.stdout.strip().split('\n') if u.strip()]
capture_output=True, text=True
)
return [u.strip() for u in result.stdout.strip().split('\n') if u.strip()]
def get_quota(user): def get_quota(user):
"""Returns (bytes, messages) or (None, None) if not available.""" r = subprocess.run([DOVEADM, 'quota', 'get', '-u', user],
result = subprocess.run( capture_output=True, text=True)
[DOVEADM, 'quota', 'get', '-u', user], bv = mv = None
capture_output=True, text=True for line in r.stdout.splitlines():
) p = line.split()
bytes_val = messages_val = None if len(p) < 4: continue
for line in result.stdout.splitlines(): if p[2] == 'STORAGE' and p[1] != 'Type':
parts = line.split() try: bv = int(p[3]) * 1024
if len(parts) < 4: except ValueError: bv = 0
continue elif p[2] == 'MESSAGE' and p[1] != 'Type':
if parts[2] == 'STORAGE' and parts[1] != 'Type': try: mv = int(p[3])
try: except ValueError: mv = 0
bytes_val = int(parts[3]) * 1024 # KiB → bytes return bv, mv
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 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)
def main(): def main():
dry_run = '--dry-run' in sys.argv dry = '--dry-run' in sys.argv
limit = None limit = None
if '--limit' in sys.argv: if '--limit' in sys.argv:
idx = sys.argv.index('--limit') try: limit = int(sys.argv[sys.argv.index('--limit') + 1])
try:
limit = int(sys.argv[idx + 1])
except (IndexError, ValueError): except (IndexError, ValueError):
print('Usage: update_quota2.sh [--dry-run] [--limit N]') print('Usage: update_quota2.sh [--dry-run] [--limit N]'); sys.exit(1)
sys.exit(1)
users = get_users(limit) users = get_users(limit)
total = len(users) total = len(users)
print('Processing %d mailboxes...' % total) print('Processing %d mailboxes...' % total)
ok = skip = 0
updated = skipped = 0
for i, user in enumerate(users, 1): for i, user in enumerate(users, 1):
bytes_val, messages_val = get_quota(user) bv, mv = get_quota(user)
if bytes_val is None or messages_val is None: if bv is None or mv is None:
print(' [%d/%d] SKIP %s (no quota data)' % (i, total, user)) print(' [%d/%d] SKIP %s' % (i, total, user)); skip += 1; continue
skipped += 1 if dry:
continue print(' [%d/%d] DRY %s: %d bytes, %d msgs' % (i, total, user, bv, mv))
if dry_run:
print(' [%d/%d] DRY %s: %d bytes, %d messages'
% (i, total, user, bytes_val, messages_val))
else: else:
upsert_quota2(user, bytes_val, messages_val) upsert(user, bv, mv)
if i % 50 == 0 or i == total: if i % 50 == 0 or i == total: print(' [%d/%d] done' % (i, total))
print(' [%d/%d] done' % (i, total)) ok += 1
updated += 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 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 ──────────────────────────────────────────────────────────── # ── Dovecot reload ────────────────────────────────────────────────────────────
heading "Dovecot reload" heading "Dovecot reload"
step "Reloading Dovecot ..." step "Reloading Dovecot ..."
"${DOVECOT_REAL}/sbin/dovecot" reload \ if "${DOVECOT_REAL}/sbin/dovecot" reload; then
&& ok || { failed; die "Dovecot reload failed."; } ok
else
failed; die "Dovecot reload failed."
fi
# ── summary ─────────────────────────────────────────────────────────────────── # ── summary ───────────────────────────────────────────────────────────────────
echo 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 ## --- Writes quota usage to quota2 table in real time so PostfixAdmin
## --- can display mailbox fill levels ($CONF['used_quotas'] = 'YES'). ## --- can display mailbox fill levels ($CONF['used_quotas'] = 'YES').
## --- ## ---
## --- Replaces the old ADD-semantics mergequota2 trigger with a ## --- Replaces the old ADD-semantics mergequota2 trigger with a
## --- SET-semantics upsertquota2 trigger (validation only); the dict ## --- SET-semantics upsertquota2 trigger (validation only); Dovecot's
## --- proxy service generates INSERT … ON CONFLICT DO UPDATE natively. ## --- dict service generates native UPSERT queries (pgsql: ON CONFLICT
## --- DO UPDATE; mysql: ON DUPLICATE KEY UPDATE).
if [[ $dovecot_major_version -gt 2 ]] \ if [[ $dovecot_major_version -gt 2 ]] \
|| ( [[ $dovecot_major_version -eq 2 ]] && [[ $dovecot_minor_version -gt 3 ]] ); then || ( [[ $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!" error "Installing /usr/local/bin/update_quota2.sh failed!"
fi 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 fi # Dovecot 2.4+ quota_clone