#!/bin/bash
# =============================================================================
# Roundcube Webmail Installer
# Targets: Ubuntu 22.04/24.04/26.04, Trisquel 11/12/13
# Requires: Postfix, Dovecot, PowerDNS, PostfixAdmin running
# =============================================================================
set -euo pipefail
# --- Configuration ---
roundcube_version="1.6.9"
ROOT_MYSQL_PASSWORD="penguin"
ROUNDCUBE_MYSQL_PASSWORD="penguin"
domain_name="tuxmail.io"
POSTFIX_ADMIN_PASSWORD="penguin"
# --- Debugging helpers ---
LOG_FILE="/var/log/roundcube-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: Download + Extract
# =============================================================================
section_download() {
log "=== SECTION: Download ==="
apt-get update
apt-get install -y wget
if [ -d /var/www/roundcube ]; then
log "Roundcube already present at /var/www/roundcube, skipping download"
else
log "Downloading Roundcube v${roundcube_version}"
local url="https://github.com/roundcube/roundcubemail/releases/download/${roundcube_version}/roundcubemail-${roundcube_version}-complete.tar.gz"
local tmp_tar="/tmp/roundcube.tar.gz"
if ! wget -qO "$tmp_tar" "$url"; then
rm -f "$tmp_tar"
die "Failed to download Roundcube from $url"
fi
tar -xzf "$tmp_tar" -C /var/www/
rm -f "$tmp_tar"
mv "/var/www/roundcubemail-${roundcube_version}" "/var/www/roundcube"
log "Roundcube extracted to /var/www/roundcube"
fi
mkdir -p /var/www/roundcube/temp /var/www/roundcube/logs
chown www-data:www-data /var/www/roundcube/temp/ /var/www/roundcube/logs/ -R
verify
check "Roundcube directory exists" "test -d /var/www/roundcube"
check "index.php exists" "test -f /var/www/roundcube/index.php"
check "temp/ is writable by www-data" "test -w /var/www/roundcube/temp"
check "logs/ is writable by www-data" "test -w /var/www/roundcube/logs"
}
# =============================================================================
# Section 2: PHP Dependencies
# =============================================================================
section_php() {
log "=== SECTION: PHP Dependencies ==="
# Fallback if detect_os wasn't run
if [ -z "${php_ver:-}" ]; then
php_ver=$(dpkg-query -f '${Version}' -W php-common 2>/dev/null | cut -d'.' -f1,2 || echo "8.3")
fi
apt-get install -y \
"php${php_ver}-common" \
"php${php_ver}-gd" \
"php${php_ver}-imap" \
"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}-redis" \
php-net-ldap2 \
php-imagick \
libapache2-mod-php
verify
check "PHP IMAP extension loaded" "php -m | grep -q '^imap$'"
check "PHP MySQL extension loaded" "php -m | grep -qi 'mysqli\|pdo_mysql'"
check "PHP GD extension loaded" "php -m | grep -q '^gd$'"
}
# =============================================================================
# Section 3: Database
# =============================================================================
section_database() {
log "=== SECTION: Database ==="
mysql --user="root" --password="$ROOT_MYSQL_PASSWORD" </dev/null || echo "0")
if [ "$table_count" -eq 0 ]; then
mysql -u roundcube -p"$ROUNDCUBE_MYSQL_PASSWORD" roundcubemail < /var/www/roundcube/SQL/mysql.initial.sql
log "Schema imported"
else
log "Schema already present ($table_count tables), skipping import"
fi
verify
check "roundcubemail DB exists" "mysql -u root -p'$ROOT_MYSQL_PASSWORD' -e 'USE roundcubemail'"
check "roundcube user can connect" "mysql -u roundcube -p'$ROUNDCUBE_MYSQL_PASSWORD' -e 'SELECT 1'"
check "Tables exist" "mysql -u roundcube -p'$ROUNDCUBE_MYSQL_PASSWORD' roundcubemail -N -e 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=\"roundcubemail\";' | grep -q '[1-9]'"
}
# =============================================================================
# Section 4: Apache VirtualHost + TLS (combined)
# =============================================================================
section_apache_tls() {
log "=== SECTION: Apache + TLS ==="
apt-get install -y certbot python3-certbot-apache
# Disable any certbot-created SSL vhost that might conflict
a2dissite "mail.${domain_name}-le-ssl.conf" 2>/dev/null || true
# HTTP vhost with redirect to HTTPS
cat > "/etc/apache2/sites-available/mail.${domain_name}.conf" << EOF
ServerName mail.${domain_name}
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)\$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
EOF
# Get the cert (use --standalone so certbot doesn't modify our vhosts)
if [ ! -f "/etc/letsencrypt/live/mail.${domain_name}/fullchain.pem" ]; then
a2ensite "mail.${domain_name}.conf"
systemctl reload apache2
sleep 2
certbot certonly --standalone --agree-tos --non-interactive \
--email "user1@${domain_name}" \
-d "mail.${domain_name}"
log "TLS certificate obtained"
else
log "TLS certificate already exists"
fi
# HTTPS vhost (we write it ourselves with explicit cert paths)
cat > "/etc/apache2/sites-available/mail.${domain_name}-ssl.conf" << EOF
ServerName mail.${domain_name}
DocumentRoot /var/www/roundcube/
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/mail.${domain_name}/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/mail.${domain_name}/privkey.pem
ErrorLog \${APACHE_LOG_DIR}/roundcube_error.log
CustomLog \${APACHE_LOG_DIR}/roundcube_access.log combined
Options FollowSymLinks MultiViews
AllowOverride All
Require all granted
EOF
# Enable both vhosts
a2ensite "mail.${domain_name}.conf"
a2ensite "mail.${domain_name}-ssl.conf"
a2dissite 000-default.conf 2>/dev/null || true
# Add to /etc/hosts (idempotent)
if ! grep -q "mail.${domain_name}" /etc/hosts; then
sed -i "s/^127.0.0.1[[:space:]]*localhost/127.0.0.1\tlocalhost mail.${domain_name}/" /etc/hosts
log "mail.${domain_name} added to /etc/hosts"
fi
systemctl restart apache2
sleep 2
setfacl -R -m u:www-data:rx /etc/letsencrypt/live/ /etc/letsencrypt/archive/ 2>/dev/null || true
verify
check "HTTP vhost enabled" "test -f /etc/apache2/sites-enabled/mail.${domain_name}.conf"
check "HTTPS vhost enabled" "test -f /etc/apache2/sites-enabled/mail.${domain_name}-ssl.conf"
check "Certificate exists" "test -f /etc/letsencrypt/live/mail.${domain_name}/fullchain.pem"
check "Cert is for correct domain" "openssl x509 -noout -subject -in /etc/letsencrypt/live/mail.${domain_name}/fullchain.pem | grep -q 'mail.${domain_name}'"
check "Cert is valid (not expired)" "openssl x509 -checkend 86400 -noout -in /etc/letsencrypt/live/mail.${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 'mail.${domain_name}:443:127.0.0.1' https://mail.${domain_name}/ 2>/dev/null | grep -qE '^(200|301|302)\$' && break; sleep 1; done"
check "HTTP redirects to HTTPS" "curl -s -o /dev/null -w '%{http_code}' --resolve 'mail.${domain_name}:80:127.0.0.1' http://mail.${domain_name}/ | grep -q '301'"
}
# =============================================================================
# Section 5: Roundcube Configuration
# =============================================================================
section_config() {
log "=== SECTION: Roundcube Configuration ==="
if [ -f /var/www/roundcube/config/config.inc.php ]; then
log "config.inc.php already exists, skipping creation"
else
cp /var/www/roundcube/config/config.inc.php.sample /var/www/roundcube/config/config.inc.php
log "config.inc.php created from sample"
fi
local config="/var/www/roundcube/config/config.inc.php"
# Set database DSN (idempotent)
sed -i "s|\$config\['db_dsnw'\] = .*|\$config['db_dsnw'] = 'mysql://roundcube:${ROUNDCUBE_MYSQL_PASSWORD}@localhost/roundcubemail';|" "$config"
# Replace localhost:143 with mail domain
sed -i "s|localhost:143|tls://mail.${domain_name}:143|g" "$config"
# Replace localhost:587 with mail domain
sed -i "s|localhost:587|tls://mail.${domain_name}:587|g" "$config"
# Set random DES key (only if still the default)
if grep -q 'rcmail-!24ByteDESkey\*Str' "$config"; then
local random_string
random_string=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 32 || true)
sed -i "s/rcmail-\\!24ByteDESkey\\*Str/${random_string}/g" "$config"
log "DES key set"
else
log "DES key already set"
fi
# Replace active plugins section (idempotent)
sed -i '/^\/\/ List of active plugins/,/^\];/d' "$config"
cat >> "$config" << 'PLUGINS'
// List of active plugins (in plugins/ directory)
$config['plugins'] = ['acl', 'additional_message_headers', 'archive', 'attachment_reminder', 'autologon', 'debug_logger', 'emoticons', 'enigma', 'help', 'hide_blockquote', 'http_authentication', 'identicon', 'identity_select', 'jqueryui', 'krb_authentication', 'managesieve', 'markasjunk', 'new_user_dialog', 'new_user_identity', 'newmail_notifier', 'password', 'reconnect', 'redundant_attachments', 'show_additional_headers', 'squirrelmail_usercopy', 'subscriptions_option', 'userinfo', 'vcard_attachments', 'virtuser_file', 'virtuser_query', 'zipdownload'];
PLUGINS
# Enable spellchecker (idempotent)
if ! grep -q "enable_spellcheck" "$config"; then
echo "\$config['enable_spellcheck'] = true;" >> "$config"
fi
# Set product name
sed -i "s/'Roundcube Webmail'/'${domain_name} Webmail'/g" "$config"
# Set default mail domain (idempotent)
if ! grep -q "mail_domain" "$config"; then
echo "\$config['mail_domain'] = '${domain_name}';" >> "$config"
fi
# Set enigma PGP home directory (idempotent)
if ! grep -q "enigma_pgp_homedir" "$config"; then
echo "\$config['enigma_pgp_homedir'] = '/var/vmail/pgp-keys';" >> "$config"
fi
chown www-data:www-data "$config"
log "Roundcube configured"
# Remove installer for security
rm -rf /var/www/roundcube/installer/
log "Installer removed"
# Create PGP keys directory
mkdir -p /var/vmail/pgp-keys
chown www-data:www-data /var/vmail/pgp-keys
verify
check "config.inc.php exists" "test -f $config"
check "config.inc.php is valid PHP" "php -l $config"
check "DB DSN is set" "grep -q 'db_dsnw.*roundcube' $config"
check "IMAP host set to mail domain" "grep -q 'tls://mail.${domain_name}:143' $config"
check "Installer removed" "! test -d /var/www/roundcube/installer"
check "PGP keys directory exists" "test -d /var/vmail/pgp-keys"
}
# =============================================================================
# Section 6: Redirect VHosts
# =============================================================================
section_redirects() {
log "=== SECTION: Redirect VHosts ==="
cat > "/etc/apache2/sites-available/${domain_name}.conf" << EOF
ServerName ${domain_name}
Redirect permanent / https://mail.${domain_name}/
EOF
cat > "/etc/apache2/sites-available/www.${domain_name}.conf" << EOF
ServerName www.${domain_name}
Redirect permanent / https://mail.${domain_name}/
EOF
a2ensite "${domain_name}.conf"
a2ensite "www.${domain_name}.conf"
systemctl restart apache2
log "Redirect vhosts configured"
verify
check "Domain redirect vhost enabled" "test -f /etc/apache2/sites-enabled/${domain_name}.conf"
check "WWW redirect vhost enabled" "test -f /etc/apache2/sites-enabled/www.${domain_name}.conf"
}
# =============================================================================
# Section 7: Dovecot Sieve
# =============================================================================
section_sieve() {
log "=== SECTION: Dovecot Sieve ==="
apt-get install -y dovecot-sieve dovecot-managesieved dovecot-lmtpd
# Ensure protocols line is clean (sieve is NOT a main protocol)
sed -i 's/^protocols = .*/protocols = imap pop3 lmtp/' /etc/dovecot/dovecot.conf
# Try enabling sieve in mail_plugins
if ! grep -q "^mail_plugins.*sieve" /etc/dovecot/conf.d/15-lda.conf; then
sed -i "s/^#mail_plugins = \$mail_plugins/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/conf.d/15-lda.conf
if ! grep -q "^mail_plugins.*sieve" /etc/dovecot/conf.d/15-lda.conf; then
echo "mail_plugins = \$mail_plugins sieve" >> /etc/dovecot/conf.d/15-lda.conf
fi
log "sieve enabled in 15-lda.conf"
else
log "sieve already enabled in 15-lda.conf"
fi
if ! grep -q "^mail_plugins.*sieve" /etc/dovecot/conf.d/20-lmtp.conf; then
sed -i "s/^#mail_plugins = \$mail_plugins/mail_plugins = \$mail_plugins quota sieve/" /etc/dovecot/conf.d/20-lmtp.conf
if ! grep -q "^mail_plugins.*sieve" /etc/dovecot/conf.d/20-lmtp.conf; then
echo "mail_plugins = \$mail_plugins quota sieve" >> /etc/dovecot/conf.d/20-lmtp.conf
fi
log "sieve enabled in 20-lmtp.conf"
else
log "sieve already enabled in 20-lmtp.conf"
fi
systemctl restart dovecot
sleep 2
# If Dovecot failed, the sieve plugin is broken — remove it gracefully
if ! systemctl is-active dovecot &>/dev/null; then
log "WARNING: Dovecot failed with sieve plugin (packaging bug). Disabling sieve."
sed -i 's/mail_plugins = \$mail_plugins sieve/mail_plugins = \$mail_plugins/' /etc/dovecot/conf.d/15-lda.conf
sed -i 's/mail_plugins = \$mail_plugins quota sieve/mail_plugins = \$mail_plugins quota/' /etc/dovecot/conf.d/20-lmtp.conf
systemctl restart dovecot
sleep 2
fi
log "Dovecot sieve configured"
verify
check "Dovecot is running" "systemctl is-active dovecot"
check "Dovecot can authenticate" "doveadm auth test user1@${domain_name} penguin12"
}
# =============================================================================
# Section 8: Postfix Header Checks
# =============================================================================
section_header_checks() {
log "=== SECTION: Postfix Header Checks ==="
if [ -f /etc/postfix/smtp_header_checks ]; then
log "smtp_header_checks already exists, skipping"
else
cat > /etc/postfix/smtp_header_checks << 'EOF'
/^User-Agent.*Roundcube Webmail/ IGNORE
EOF
log "smtp_header_checks created"
fi
postconf -e "smtp_header_checks = regexp:/etc/postfix/smtp_header_checks"
postmap /etc/postfix/smtp_header_checks
systemctl reload postfix
verify
check "smtp_header_checks file exists" "test -f /etc/postfix/smtp_header_checks"
check "smtp_header_checks configured" "postconf -h smtp_header_checks | grep -q 'smtp_header_checks'"
check "Postfix is running" "systemctl is-active postfix"
}
# =============================================================================
# Section 9: Password Plugin
# =============================================================================
section_password_plugin() {
log "=== SECTION: Password Plugin ==="
local pwconfig="/var/www/roundcube/plugins/password/config.inc.php"
if [ -f "$pwconfig" ]; then
log "Password plugin config already exists, skipping"
else
cp /var/www/roundcube/plugins/password/config.inc.php.dist "$pwconfig"
log "Password plugin config created"
fi
sed -i "s|\$config\['password_db_dsn'\] = ''|\$config['password_db_dsn'] = 'mysql://postfixadmin:${POSTFIX_ADMIN_PASSWORD}@127.0.0.1/postfixadmin'|" "$pwconfig"
sed -i "s|\$config\['password_query'\] = 'SELECT update_passwd(%P, %u)'|\$config['password_query'] = 'UPDATE mailbox SET password=%P,modified=NOW() WHERE username=%u'|" "$pwconfig"
sed -i "s|\$config\['password_strength_driver'\] = null|\$config['password_strength_driver'] = 'zxcvbn'|" "$pwconfig"
if ! grep -q "password_zxcvbn_min_score" "$pwconfig"; then
echo "\$config['password_zxcvbn_min_score'] = 5;" >> "$pwconfig"
fi
sed -i "s|\$config\['password_algorithm'\] = 'clear'|\$config['password_algorithm'] = 'dovecot'|" "$pwconfig"
sed -i "s|\$config\['password_dovecotpw'\] = '.*'|\$config['password_dovecotpw'] = '/usr/bin/doveadm pw -r 5'|" "$pwconfig"
sed -i "s|\$config\['password_dovecotpw_method'\] = '.*'|\$config['password_dovecotpw_method'] = 'ARGON2I'|" "$pwconfig"
sed -i "s|\$config\['password_dovecotpw_with_method'\] = false|\$config['password_dovecotpw_with_method'] = true|" "$pwconfig"
chown www-data:www-data "$pwconfig"
chmod 600 "$pwconfig"
log "Password plugin configured"
verify
check "Password plugin config exists" "test -f $pwconfig"
check "Password plugin config is valid PHP" "php -l $pwconfig"
check "DB DSN set" "grep -q 'postfixadmin' $pwconfig"
check "Algorithm is dovecot" "grep -q 'password_algorithm.*dovecot' $pwconfig"
check "Hash method is ARGON2I" "grep -q 'ARGON2I' $pwconfig"
check "File permissions are 600" "stat -c '%a' $pwconfig | grep -q '^600$'"
}
# =============================================================================
# Section 10: PHP Upload Limits
# =============================================================================
section_php_limits() {
log "=== SECTION: PHP Upload Limits ==="
# Fallback if detect_os wasn't run
if [ -z "${php_ver:-}" ]; then
php_ver=$(dpkg-query -f '${Version}' -W php-common 2>/dev/null | cut -d'.' -f1,2 || echo "8.3")
fi
local php_ini="/etc/php/${php_ver}/apache2/php.ini"
if [ ! -f "$php_ini" ]; then
php_ini="/etc/php/${php_ver}/fpm/php.ini"
fi
if [ -f "$php_ini" ]; then
sed -i "s/^upload_max_filesize = .*/upload_max_filesize = 50M/" "$php_ini"
sed -i "s/^post_max_size = .*/post_max_size = 50M/" "$php_ini"
log "PHP upload limits set to 50M in $php_ini"
else
log "WARNING: Could not find php.ini, skipping upload limit changes"
fi
systemctl restart apache2
verify
check "upload_max_filesize is 50M" "grep -q '^upload_max_filesize = 50M' $php_ini"
check "post_max_size is 50M" "grep -q '^post_max_size = 50M' $php_ini"
}
# =============================================================================
# Section 11: Final Verification
# =============================================================================
section_final_test() {
log "=== SECTION: Final Verification ==="
systemctl restart postfix dovecot
sleep 2
verify
check "Roundcube reachable over HTTPS" "for i in 1 2 3 4 5; do curl -sk -o /dev/null -w '%{http_code}' --resolve 'mail.${domain_name}:443:127.0.0.1' https://mail.${domain_name}/ 2>/dev/null | grep -qE '^(200|301|302)\$' && break; sleep 1; done"
check "HTTP redirects to HTTPS" "curl -s -o /dev/null -w '%{http_code}' --resolve 'mail.${domain_name}:80:127.0.0.1' http://mail.${domain_name}/ | grep -q '301'"
check "Cert is for correct domain" "echo | openssl s_client -connect 127.0.0.1:443 -servername mail.${domain_name} 2>/dev/null | openssl x509 -noout -subject | grep -q 'mail.${domain_name}'"
check "Dovecot running" "systemctl is-active dovecot"
check "Postfix running" "systemctl is-active postfix"
check "PowerDNS still answering" "dig @127.0.0.1 $domain_name NS +short | grep -q 'ns1'"
check "Dovecot can authenticate user" "doveadm auth test user1@${domain_name} penguin12"
check "Mail queue is empty" "mailq | grep -q 'Mail queue is empty'"
}
# =============================================================================
# Main
# =============================================================================
ALL_SECTIONS=(
detect_os
section_download
section_php
section_database
section_apache_tls
section_config
section_redirects
section_sieve
section_header_checks
section_password_plugin
section_php_limits
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 "=== Roundcube installation complete ==="
