#!/bin/bash
# =============================================================================
# PostfixAdmin + Postfix + Dovecot Installer
# Targets: Ubuntu 22.04/24.04/26.04, Trisquel 11/12/13
# Fully automated - no user interaction required
# =============================================================================
set -euo pipefail
# --- Configuration ---
ROOT_MYSQL_PASSWORD="penguin"
POSTFIX_ADMIN_PASSWORD="penguin"
POSTFIX_ADMIN_SETUP_PASSWORD="penguin"
domain_name="tuxmail.io"
user1_email_address="user1"
user1_password="penguin12"
postfix_super_admin_password="penguin12"
postfix_admin_version="4.0.5"
postfix_admin_download_url="https://github.com/postfixadmin/postfixadmin/releases/download/v${postfix_admin_version}/postfixadmin-v${postfix_admin_version}-php84.tar.gz"
# --- Debugging helpers ---
LOG_FILE="/var/log/postfixadmin-install.log"
PASS_COUNT=0
FAIL_COUNT=0
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
die() {
log "FATAL: $*" >&2
exit 1
}
check() {
local desc="$1"
local cmd="$2"
if eval "$cmd" &>/dev/null; then
log " [PASS] $desc"
((PASS_COUNT++)) || true
else
log " [FAIL] $desc"
((FAIL_COUNT++)) || true
fi
}
verify() {
log "--- VERIFICATION ---"
}
# =============================================================================
# Section 0: OS Detection
# =============================================================================
detect_os() {
log "=== SECTION: OS Detection ==="
if [ -f /etc/os-release ]; then
. /etc/os-release
OS_ID="$ID"
OS_VERSION="$VERSION_ID"
else
die "Cannot detect OS"
fi
log "Detected OS: $OS_ID $OS_VERSION"
if [ "$OS_VERSION" = "22.04" ] || [ "$OS_VERSION" = "11.0" ]; then
php_ver="8.1"
elif [ "$OS_VERSION" = "24.04" ] || [ "$OS_VERSION" = "12.0" ]; then
php_ver="8.3"
elif [ "$OS_VERSION" = "26.04" ] || [ "$OS_VERSION" = "13.0" ]; then
php_ver="8.5"
else
php_ver=$(dpkg-query -f '${Version}' -W php-common 2>/dev/null | cut -d'.' -f1,2 || echo "8.3")
fi
log "Using PHP version: $php_ver"
verify
check "OS detected ($OS_ID $OS_VERSION)" "test -n '$OS_ID'"
check "PHP version resolved ($php_ver)" "test -n '$php_ver'"
}
# =============================================================================
# Section 1: MariaDB
# =============================================================================
section_mariadb() {
log "=== SECTION: MariaDB ==="
apt-get update
apt-get install -y mariadb-server mariadb-client
systemctl enable mariadb
systemctl start mariadb
if mysql -u root -e "SELECT 1" 2>/dev/null; then
mysql -u root -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '$ROOT_MYSQL_PASSWORD'; FLUSH PRIVILEGES;"
log "MariaDB root password set"
else
log "MariaDB root password already set"
fi
mysql --user="root" --password="$ROOT_MYSQL_PASSWORD" < "/var/www/postfixadmin/config.local.php" << EOF
<?php
\$CONF['configured'] = true;
\$CONF['database_type'] = 'mysqli';
\$CONF['database_host'] = 'localhost';
\$CONF['database_port'] = '3306';
\$CONF['database_user'] = 'postfixadmin';
\$CONF['database_password'] = '$POSTFIX_ADMIN_PASSWORD';
\$CONF['database_name'] = 'postfixadmin';
\$CONF['encrypt'] = 'dovecot:ARGON2I';
if(@file_exists('/usr/bin/doveadm')) {
\$CONF['dovecotpw'] = "/usr/bin/doveadm pw -r 5";
}
\$CONF['setup_password'] = password_hash("$POSTFIX_ADMIN_SETUP_PASSWORD", PASSWORD_DEFAULT);
EOF
log "config.local.php written"
apt-get install -y \
"php${php_ver}-common" \
"php${php_ver}-gd" \
"php${php_ver}-mysql" \
"php${php_ver}-curl" \
"php${php_ver}-zip" \
"php${php_ver}-xml" \
"php${php_ver}-mbstring" \
"php${php_ver}-bz2" \
"php${php_ver}-intl" \
"php${php_ver}-gmp" \
"php${php_ver}-imap" \
libapache2-mod-php
cat > "/etc/apache2/sites-available/postfixadmin.conf" << EOF
ServerName admin.${domain_name}
DocumentRoot /var/www/postfixadmin/public
ErrorLog \${APACHE_LOG_DIR}/postfixadmin_error.log
CustomLog \${APACHE_LOG_DIR}/postfixadmin_access.log combined
Options FollowSymLinks MultiViews
AllowOverride All
Require all granted
EOF
a2ensite postfixadmin.conf
systemctl restart apache2
sleep 2
log "Apache configured for PostfixAdmin"
verify
check "config.local.php is valid PHP" "php -l /var/www/postfixadmin/config.local.php"
check "Apache is running" "systemctl is-active apache2"
check "postfixadmin vhost is enabled" "test -f /etc/apache2/sites-enabled/postfixadmin.conf"
check "PostfixAdmin responds locally" "for i in 1 2 3 4 5; do curl -s -o /dev/null -w '%{http_code}' --resolve 'admin.${domain_name}:80:127.0.0.1' http://admin.${domain_name}/ 2>/dev/null | grep -qE '^(200|301)$' && break; sleep 1; done"
}
# =============================================================================
# Section 4: TLS Certificate
# =============================================================================
section_tls() {
log "=== SECTION: TLS Certificate ==="
apt-get install -y certbot python3-certbot-apache
if [ -f "/etc/letsencrypt/live/admin.${domain_name}/fullchain.pem" ]; then
log "TLS certificate already exists, skipping certbot"
else
certbot --apache --agree-tos --redirect --hsts --staple-ocsp \
--email "${user1_email_address}@${domain_name}" \
-d "admin.${domain_name}" --non-interactive
log "TLS certificate obtained"
fi
setfacl -R -m u:www-data:rx /etc/letsencrypt/live/ /etc/letsencrypt/archive/ 2>/dev/null || true
verify
check "Certificate file exists" "test -f /etc/letsencrypt/live/admin.${domain_name}/fullchain.pem"
check "HTTPS responds locally" "for i in 1 2 3 4 5; do curl -sk -o /dev/null -w '%{http_code}' --resolve 'admin.${domain_name}:443:127.0.0.1' https://admin.${domain_name}/ 2>/dev/null | grep -qE '^(200|301)$' && break; sleep 1; done"
check "Cert is valid (not expired)" "openssl x509 -checkend 86400 -noout -in /etc/letsencrypt/live/admin.${domain_name}/fullchain.pem"
}
# =============================================================================
# Section 5: PostfixAdmin Database Setup (fully automated)
# =============================================================================
section_setup() {
log "=== SECTION: PostfixAdmin Database Setup ==="
log "Running upgrade.php to create database tables..."
php /var/www/postfixadmin/public/upgrade.php > /dev/null 2>&1 || true
log "Database tables created (or already present)"
local table_count
table_count=$(mysql -u postfixadmin -p"$POSTFIX_ADMIN_PASSWORD" postfixadmin -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='postfixadmin';" 2>/dev/null || echo "0")
if [ "$table_count" -eq 0 ]; then
die "No tables found in postfixadmin DB. upgrade.php failed."
fi
log "Found $table_count tables in postfixadmin DB"
if mysql -u postfixadmin -p"$POSTFIX_ADMIN_PASSWORD" postfixadmin -N -e "SELECT COUNT(*) FROM admin WHERE username='${user1_email_address}@${domain_name}';" 2>/dev/null | grep -q '^0$'; then
/var/www/postfixadmin/scripts/postfixadmin-cli admin add \
"${user1_email_address}@${domain_name}" \
--superadmin 1 --active 1 \
--password "$postfix_super_admin_password" \
--password2 "$postfix_super_admin_password"
log "SuperAdmin user created"
else
log "SuperAdmin user already exists"
fi
if mysql -u postfixadmin -p"$POSTFIX_ADMIN_PASSWORD" postfixadmin -N -e "SELECT COUNT(*) FROM domain WHERE domain='$domain_name';" 2>/dev/null | grep -q '^0$'; then
/var/www/postfixadmin/scripts/postfixadmin-cli domain add \
"$domain_name" \
--mailboxes 100 --aliases 100 --active 1 --password-expiry 0
log "Domain added"
else
log "Domain already exists"
fi
if mysql -u postfixadmin -p"$POSTFIX_ADMIN_PASSWORD" postfixadmin -N -e "SELECT COUNT(*) FROM mailbox WHERE username='${user1_email_address}@${domain_name}';" 2>/dev/null | grep -q '^0$'; then
/var/www/postfixadmin/scripts/postfixadmin-cli mailbox add \
"${user1_email_address}@${domain_name}" \
--password "$user1_password" --password2 "$user1_password"
log "Mailbox created for ${user1_email_address}@${domain_name}"
else
log "Mailbox already exists"
fi
verify
check "config table exists" "mysql -u postfixadmin -p'$POSTFIX_ADMIN_PASSWORD' postfixadmin -e 'SELECT 1 FROM config LIMIT 1'"
check "Domain table has records" "mysql -u postfixadmin -p'$POSTFIX_ADMIN_PASSWORD' postfixadmin -e 'SELECT COUNT(*) FROM domain' | grep -q '[1-9]'"
check "Admin user exists" "mysql -u postfixadmin -p'$POSTFIX_ADMIN_PASSWORD' postfixadmin -e \"SELECT COUNT(*) FROM admin WHERE username='${user1_email_address}@${domain_name}'\" | grep -q '[1-9]'"
check "Mailbox exists" "mysql -u postfixadmin -p'$POSTFIX_ADMIN_PASSWORD' postfixadmin -e \"SELECT COUNT(*) FROM mailbox WHERE username='${user1_email_address}@${domain_name}'\" | grep -q '[1-9]'"
check "PostfixAdmin web responds locally" "for i in 1 2 3 4 5; do curl -sk -o /dev/null -w '%{http_code}' --resolve 'admin.${domain_name}:443:127.0.0.1' https://admin.${domain_name}/ 2>/dev/null | grep -qE '^(200|301|302)$' && break; sleep 1; done"
}
# =============================================================================
# Section 6: Postfix Configuration
# =============================================================================
section_postfix() {
log "=== SECTION: Postfix ==="
apt-get install -y postfix postfix-mysql
if ! id vmail &>/dev/null; then
adduser vmail --system --group --uid 2000 --disabled-login --no-create-home
fi
mkdir -p /var/vmail
chown vmail:vmail /var/vmail
mkdir -p /etc/postfix/sql/
cat > "/etc/postfix/sql/mysql_virtual_domains_maps.cf" << EOF
user = postfixadmin
password = $POSTFIX_ADMIN_PASSWORD
hosts = localhost
dbname = postfixadmin
query = SELECT domain FROM domain WHERE domain='%s' AND active = '1'
EOF
cat > "/etc/postfix/sql/mysql_virtual_mailbox_maps.cf" << EOF
user = postfixadmin
password = $POSTFIX_ADMIN_PASSWORD
hosts = localhost
dbname = postfixadmin
query = SELECT maildir FROM mailbox WHERE username='%s' AND active = '1'
EOF
cat > "/etc/postfix/sql/mysql_virtual_alias_domain_mailbox_maps.cf" << EOF
user = postfixadmin
password = $POSTFIX_ADMIN_PASSWORD
hosts = localhost
dbname = postfixadmin
query = SELECT maildir FROM mailbox,alias_domain WHERE alias_domain.alias_domain = '%d' and mailbox.username = CONCAT('%u', '@', alias_domain.target_domain) AND mailbox.active = 1 AND alias_domain.active='1'
EOF
cat > "/etc/postfix/sql/mysql_virtual_alias_maps.cf" << EOF
user = postfixadmin
password = $POSTFIX_ADMIN_PASSWORD
hosts = localhost
dbname = postfixadmin
query = SELECT goto FROM alias WHERE address='%s' AND active = '1'
EOF
cat > "/etc/postfix/sql/mysql_virtual_alias_domain_maps.cf" << EOF
user = postfixadmin
password = $POSTFIX_ADMIN_PASSWORD
hosts = localhost
dbname = postfixadmin
query = SELECT goto FROM alias,alias_domain WHERE alias_domain.alias_domain = '%d' and alias.address = CONCAT('%u', '@', alias_domain.target_domain) AND alias.active = 1 AND alias_domain.active='1'
EOF
cat > "/etc/postfix/sql/mysql_virtual_alias_domain_catchall_maps.cf" << EOF
user = postfixadmin
password = $POSTFIX_ADMIN_PASSWORD
hosts = localhost
dbname = postfixadmin
query = SELECT goto FROM alias,alias_domain WHERE alias_domain.alias_domain = '%d' and alias.address = CONCAT('@', alias_domain.target_domain) AND alias.active = 1 AND alias_domain.active='1'
EOF
chmod 0640 /etc/postfix/sql/*
setfacl -R -m u:postfix:rx /etc/postfix/sql/
postconf -e "virtual_mailbox_domains = proxy:mysql:/etc/postfix/sql/mysql_virtual_domains_maps.cf"
postconf -e "virtual_mailbox_maps = proxy:mysql:/etc/postfix/sql/mysql_virtual_mailbox_maps.cf, proxy:mysql:/etc/postfix/sql/mysql_virtual_alias_domain_mailbox_maps.cf"
postconf -e "virtual_alias_maps = proxy:mysql:/etc/postfix/sql/mysql_virtual_alias_maps.cf, proxy:mysql:/etc/postfix/sql/mysql_virtual_alias_domain_maps.cf, proxy:mysql:/etc/postfix/sql/mysql_virtual_alias_domain_catchall_maps.cf"
postconf -e "virtual_transport = lmtp:unix:private/dovecot-lmtp"
postconf -e "virtual_mailbox_base = /var/vmail"
postconf -e "virtual_minimum_uid = 2000"
postconf -e "virtual_uid_maps = static:2000"
postconf -e "virtual_gid_maps = static:2000"
postconf -e "authorized_submit_users = root, www-data, vmail"
postconf -e "mydestination = \$myhostname, localhost.\$mydomain, localhost"
systemctl restart postfix
log "Postfix configured"
verify
check "Postfix is running" "systemctl is-active postfix"
check "vmail user exists (uid 2000)" "id -u vmail | grep -q '^2000$'"
check "/var/vmail is owned by vmail" "stat -c '%U' /var/vmail | grep -q '^vmail$'"
check "SQL map files exist" "test -f /etc/postfix/sql/mysql_virtual_domains_maps.cf"
check "virtual_mailbox_domains is set" "postconf -h virtual_mailbox_domains | grep -q 'mysql_virtual_domains'"
check "Postfix can resolve domain via SQL" "postmap -q '$domain_name' mysql:/etc/postfix/sql/mysql_virtual_domains_maps.cf | grep -q '$domain_name'"
}
# =============================================================================
# Section 7: Dovecot Configuration
# =============================================================================
section_dovecot() {
log "=== SECTION: Dovecot ==="
apt-get install -y dovecot-core dovecot-mysql dovecot-lmtpd
if ! grep -q "^mail_home" /etc/dovecot/conf.d/10-mail.conf; then
sed -i '/^mail_location = maildir:~\/Maildir/a mail_home = /var/vmail/%d/%n/' /etc/dovecot/conf.d/10-mail.conf
fi
sed -i "s/^auth_username_format = %n/auth_username_format = %u/" /etc/dovecot/conf.d/10-auth.conf
if grep -q "^#auth_default_realm" /etc/dovecot/conf.d/10-auth.conf; then
sed -i "s/^#auth_default_realm =/auth_default_realm = ${domain_name}/" /etc/dovecot/conf.d/10-auth.conf
elif ! grep -q "^auth_default_realm" /etc/dovecot/conf.d/10-auth.conf; then
echo "auth_default_realm = ${domain_name}" >> /etc/dovecot/conf.d/10-auth.conf
fi
sed -i 's/^#!include auth-sql.conf.ext/!include auth-sql.conf.ext/' /etc/dovecot/conf.d/10-auth.conf
sed -i 's/^!include auth-system.conf.ext/#!include auth-system.conf.ext/' /etc/dovecot/conf.d/10-auth.conf
cat > /etc/dovecot/dovecot-sql.conf.ext << EOF
driver = mysql
connect = host=localhost dbname=postfixadmin user=postfixadmin password=${POSTFIX_ADMIN_PASSWORD}
default_pass_scheme = ARGON2I
password_query = SELECT username AS user,password FROM mailbox WHERE username = '%u' AND active='1'
user_query = SELECT maildir, 2000 AS uid, 2000 AS gid FROM mailbox WHERE username = '%u' AND active='1'
iterate_query = SELECT username AS user FROM mailbox
EOF
if ! grep -q "service stats" /etc/dovecot/conf.d/10-master.conf; then
cat >> /etc/dovecot/conf.d/10-master.conf << 'EOF'
service stats {
unix_listener stats-reader {
user = www-data
group = www-data
mode = 0660
}
unix_listener stats-writer {
user = www-data
group = www-data
mode = 0660
}
}
EOF
fi
gpasswd -a www-data dovecot
mkdir -p /var/run/dovecot
setfacl -R -m u:www-data:rwx /var/run/dovecot/stats-reader /var/run/dovecot/stats-writer 2>/dev/null || true
systemctl restart dovecot
log "Dovecot configured"
verify
check "Dovecot is running" "systemctl is-active dovecot"
check "auth-sql is enabled" "grep -q '^!include auth-sql.conf.ext' /etc/dovecot/conf.d/10-auth.conf"
check "auth-system is disabled" "grep -q '^#!include auth-system.conf.ext' /etc/dovecot/conf.d/10-auth.conf"
check "mail_home is set" "grep -q '^mail_home' /etc/dovecot/conf.d/10-mail.conf"
check "dovecot-sql.conf.ext exists" "test -f /etc/dovecot/dovecot-sql.conf.ext"
check "Dovecot can authenticate user" "doveadm auth test '${user1_email_address}@${domain_name}' '$user1_password'"
check "LMTP socket exists" "test -S /var/spool/postfix/private/dovecot-lmtp"
}
# =============================================================================
# Section 8: Cron
# =============================================================================
section_cron() {
log "=== SECTION: Cron ==="
crontab -l 2>/dev/null | { cat; echo "@monthly doveadm expunge -A mailbox Junk savedbefore 2w"; } | crontab -
log "Cron job added"
verify
check "Cron entry exists" "crontab -l | grep -q 'doveadm expunge'"
}
# =============================================================================
# Section 9: Final End-to-End Test
# =============================================================================
section_final_test() {
log "=== SECTION: Final End-to-End Test ==="
verify
check "PowerDNS still answering" "dig @127.0.0.1 $domain_name NS +short | grep -q 'ns1'"
check "Postfix running" "systemctl is-active postfix"
check "Dovecot running" "systemctl is-active dovecot"
check "Apache running" "systemctl is-active apache2"
check "MariaDB running" "systemctl is-active mariadb"
check "WireGuard tunnel up" "wg show | grep -q 'wg-client0'"
check "PostfixAdmin reachable locally over HTTPS" "for i in 1 2 3 4 5; do curl -sk -o /dev/null -w '%{http_code}' --resolve 'admin.${domain_name}:443:127.0.0.1' https://admin.${domain_name}/ 2>/dev/null | grep -qE '^(200|301|302)$' && break; sleep 1; done"
}
# =============================================================================
# Main
# =============================================================================
ALL_SECTIONS=(
detect_os
section_mariadb
section_download
section_postfixadmin_config
section_tls
section_setup
section_postfix
section_dovecot
section_cron
section_final_test
)
if [ $# -gt 0 ]; then
for section in "$@"; do
case "$section" in
detect_os|section_*)
log "Running: $section"
"$section"
;;
all)
for s in "${ALL_SECTIONS[@]}"; do
log "Running: $s"
"$s"
done
;;
*)
die "Unknown section: $section"
;;
esac
done
else
for s in "${ALL_SECTIONS[@]}"; do
log "Running: $s"
"$s"
done
fi
# --- Summary ---
log "=============================================="
log " PASSED: $PASS_COUNT"
log " FAILED: $FAIL_COUNT"
log "=============================================="
if [ "$FAIL_COUNT" -gt 0 ]; then
log "WARNING: Some checks failed. Review [FAIL] entries above."
log "Full log: $LOG_FILE"
exit 1
else
log "All checks passed."
fi
log "=== Installation complete ==="
