#!/bin/bash -e # # Installer for ISPmail servers # Copyright © 2025 Christoph Haas # # License: Creative Commons BY-NC-SA license # # Version 0.1 / 2025-12-14 # First release. # # Version 0.2 / 2025-12-28 # Enable plugins (managesieve, password) in Roundcube # usage() { echo "Usage: $0 -f fqdn" exit 1 } FQDN="" while getopts "f:h" opt; do case "$opt" in f) FQDN="$OPTARG" ;; h) usage ;; *) usage ;; esac done ############ pre flight check ############ pre_flight() { # Check for root if [[ $EUIA -ne 0 ]]; then echo "❌ You must run this script as root." exit 1 fi # Check for Trixie DEBIAN_VERSION=$(cut -d'.' -f1 /etc/debian_version) if [[ "$DEBIAN_VERSION" -ne 13 ]]; then echo "❌ Sorry, this installer only works on Debian 13/Trixie." exit 10 fi # FQDN needs to be given as an option if [[ $FQDN = "" ]]; then echo "❌ You must specify your FQDN using the '-f' option." exit 1 fi } ############## Intro ############## intro() { cat < /etc/letsencrypt/cli.ini << 'EOF' # Restart services after renewing a certificate post-hook = systemctl reload postfix dovecot apache2 # Because we are using logrotate for greater flexibility, disable the # internal certbot logrotation. max-log-backups = 0 # Adjust interactive output regarding automated renewal preconfigured-renewal = True EOF } ############# Postfix ############### postfix_config() { cat > /etc/postfix/mariadb-virtual-mailbox-domains.cf << EOF user = mailserver password = MAILSERVER-PASSWORD-HERE hosts = 127.0.0.1 dbname = mailserver query = SELECT 1 FROM virtual_domains WHERE name='%s' EOF cat > /etc/postfix/mariadb-virtual-alias-maps.cf << EOF user = mailserver password = MAILSERVER-PASSWORD-HERE hosts = 127.0.0.1 dbname = mailserver query = SELECT destination FROM virtual_aliases WHERE source='%s' EOF cat > /etc/postfix/mariadb-virtual-mailbox-maps.cf << EOF user = mailserver password = MAILSERVER-PASSWORD-HERE hosts = 127.0.0.1 dbname = mailserver query = SELECT 1 FROM virtual_users WHERE email='%s' EOF postconf virtual_mailbox_domains=mysql:/etc/postfix/mariadb-virtual-mailbox-domains.cf postconf virtual_mailbox_maps=mysql:/etc/postfix/mariadb-virtual-mailbox-maps.cf postconf virtual_alias_maps=mysql:/etc/postfix/mariadb-virtual-alias-maps.cf chown root:postfix /etc/postfix/mariadb-*.cf chmod o= /etc/postfix/mariadb-*.cf # Postfix check RES=$(postmap -q example.org mysql:/etc/postfix/mariadb-virtual-mailbox-domains.cf) if [[ $RES -ne 1 ]]; then echo "❌ The mariadb-virtual-mailbox-domains.cf mapping failed." exit 10 fi RES=$(postmap -q jack@example.org mysql:/etc/postfix/mariadb-virtual-alias-maps.cf) if [[ $RES != 'john@example.org' ]]; then echo "❌ The mariadb-virtual-alias-maps.cf mapping failed." exit 10 fi RES=$(postmap -q john@example.org mysql:/etc/postfix/mariadb-virtual-mailbox-maps.cf) if [[ $RES -ne 1 ]]; then echo "❌ The mariadb-virtual-mailbox-maps.cf mapping failed." exit 10 fi echo "✅ Mappings work." } ############# Dovecot ############### dovecot_config() { groupadd -f --system vmail id -a vmail 2>/dev/null >/dev/null || useradd --system --gid vmail vmail mkdir -p /var/vmail chown -R vmail:vmail /var/vmail chmod u=rwx,g=rx,o= /var/vmail sed -i '/!include/s/^/#/' /etc/dovecot/conf.d/10-auth.conf cat > /etc/dovecot/conf.d/99-ispmail-mail.conf << EOF mail_driver = maildir mail_home = /var/vmail/%{user | domain}/%{user | username} mail_path = ~/Maildir mail_uid = vmail mail_gid = vmail mail_inbox_path = ~/Maildir/ EOF cat > /etc/dovecot/conf.d/99-ispmail-master.conf << EOF service auth { unix_listener /var/spool/postfix/private/dovecot-auth { mode = 0660 user = postfix group = postfix } } EOF cat > /etc/dovecot/conf.d/99-ispmail-ssl.conf << EOF ssl = required ssl_server_cert_file = /etc/letsencrypt/live/$FQDN/fullchain.pem ssl_server_key_file = /etc/letsencrypt/live/$FQDN/privkey.pem EOF cat > /etc/dovecot/conf.d/99-ispmail-lmtp-username-format.conf << EOF protocol lmtp { auth_username_format = } EOF cat > /etc/dovecot/conf.d/99-ispmail-sql.conf << EOF sql_driver = mysql mysql /var/run/mysqld/mysqld.sock { user = mailserver password = 'MAILSERVER-PASSWORD-HERE' dbname = mailserver host = 127.0.0.1 } userdb sql { query = SELECT email as user FROM virtual_users WHERE email='%{user}' iterate_query = SELECT email as user FROM virtual_users } passdb sql { query = SELECT password FROM virtual_users where email='%{user}' } EOF # fix permissions chown root:root /etc/dovecot/conf.d/99-ispmail-sql.conf chmod go= /etc/dovecot/conf.d/99-ispmail-sql.conf cat > /etc/dovecot/conf.d/99-ispmail-managesieve.conf << EOF service managesieve-login { # Listen only on localhost inet_listener sieve { listen= 127.0.0.1 port = 4190 } # Disable the deprecated listener inet_listener sieve_deprecated { port = 0 } } EOF systemctl restart dovecot # Check if running systemctl is-active --quiet dovecot.service if [[ $? -ne 0 ]]; then echo "❌ Dovecot failed to start properly." exit 10 fi echo "✅ Dovecot is running fine." # Check query JOHNS_MAILDIR=$(doveadm user john@example.org | grep ^mail_path | cut -f2) if [[ $JOHNS_MAILDIR != '/var/vmail/example.org/john/Maildir' ]]; then echo "❌ Could not find John's mail_path." exit 10 fi echo "✅ Lookup works." } ############# LMTP ############### lmtp_config() { # Add an LMTP listening socket in Dovecot cat > /etc/dovecot/conf.d/99-ispmail-lmtp-listener.conf << EOF service lmtp { # Used internally by Dovecot unix_listener lmtp { } # Listen to LMTP connections from Postfix unix_listener /var/spool/postfix/private/dovecot-lmtp { mode = 0600 user = postfix group = postfix } } EOF # Restart Dovecot systemctl restart dovecot postconf virtual_transport=lmtp:unix:private/dovecot-lmtp # Testing delivery echo Testing email delivery internally. if [[ -d /var/vmail/example.org/john/Maildir/new/ ]]; then rm -f /var/vmail/example.org/john/Maildir/new/* fi swaks --server localhost --to john@example.org --tls --silent DELIVERY=0 for i in $(seq 1 50); do NUM_FILES=$(ls -1 /var/vmail/example.org/john/Maildir/new/ | wc -l) if [[ $NUM_FILES -eq 1 ]]; then DELIVERY=1 break fi sleep 0.2 done if [[ $DELIVERY -eq 0 ]]; then echo "❌ Mail delivery to john@example.org failed." exit 10 fi echo "✅ Test successful." } ############ APACHE ############# apache_config() { cat > /etc/apache2/sites-available/000-default-le-ssl.conf << EOF ServerAdmin postmaster@$FQDN ServerName $FQDN DocumentRoot /var/lib/roundcube/public_html ErrorLog \${APACHE_LOG_DIR}/error.log CustomLog \${APACHE_LOG_DIR}/access.log combined SSLCertificateFile /etc/letsencrypt/live/$FQDN/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/$FQDN/privkey.pem Include /etc/letsencrypt/options-ssl-apache.conf Include /etc/roundcube/apache.conf Require all granted RewriteEngine On RewriteRule ^/rspamd$ /rspamd/ [R,L] RewriteRule ^/rspamd/(.*) http://localhost:11334/\$1 [P,L] EOF # Enable the required Apache modules a2enmod proxy_http -q a2enmod rewrite -q # Restart Apache systemctl restart apache2 # Check if running systemctl is-active --quiet apache2.service if [[ $? -ne 0 ]]; then echo "❌ Apache has not started properly." exit 10 fi echo "✅ Apache is running fine." # Set imap_host and smtp_host in /etc/roundcube/config.inc.php sed -i "s|^\s*\$config\['imap_host'\]\s*=.*|\$config['imap_host'] = 'tls://$FQDN:143';|" /etc/roundcube/config.inc.php sed -i "s|^\s*\$config\['smtp_host'\]\s*=.*|\$config['smtp_host'] = 'tls://$FQDN:587';|" /etc/roundcube/config.inc.php # Enable plugins sed -i "/\$config\['plugins'\] = \[/,/\];/c\\ \$config['plugins'] = [\\ 'managesieve',\\ 'password',\\ ];" /etc/roundcube/config.inc.php # Check if web interface is loading curl -s https://$FQDN | grep title | grep -q "Roundcube Webmail" if [[ $? -ne 0 ]]; then echo "❌ Webmail interface is not reachable at https://$FQDN" exit 10 fi } ############ Relaying ############# relaying_setup() { postconf -M submission/inet="submission inet n - y - - smtpd" postconf -P "submission/inet/syslog_name=postfix/submission" postconf -P "submission/inet/smtpd_tls_security_level=encrypt" postconf -P "submission/inet/smtpd_sasl_auth_enable=yes" postconf -P "submission/inet/smtpd_sasl_type=dovecot" postconf -P "submission/inet/smtpd_sasl_path=private/dovecot-auth" postconf -P "submission/inet/smtpd_recipient_restrictions=permit_sasl_authenticated,reject" postconf -P "submission/inet/smtpd_sender_restrictions=reject_sender_login_mismatch,permit_sasl_authenticated,reject" postconf -M submissions/inet="submissions inet n - y - - smtpd" postconf -P "submissions/inet/syslog_name=postfix/submissions" postconf -P "submissions/inet/smtpd_tls_wrappermode=yes" postconf -P "submissions/inet/smtpd_sasl_auth_enable=yes" postconf -P "submissions/inet/smtpd_sasl_type=dovecot" postconf -P "submissions/inet/smtpd_sasl_path=private/dovecot-auth" postconf -P "submissions/inet/smtpd_recipient_restrictions=permit_sasl_authenticated,reject" postconf -P "submissions/inet/smtpd_sender_restrictions=reject_sender_login_mismatch,permit_sasl_authenticated,reject" postfix reload 2>/dev/null postconf smtp_tls_security_level=encrypt postconf smtpd_tls_security_level=encrypt postconf smtp_tls_mandatory_protocols=">=TLSv1.2" postconf smtpd_tls_mandatory_protocols=">=TLSv1.2" postconf smtp_tls_mandatory_ciphers=high postconf smtpd_tls_mandatory_ciphers=high postconf smtpd_tls_cert_file=/etc/letsencrypt/live/$FQDN/fullchain.pem postconf smtpd_tls_key_file=/etc/letsencrypt/live/$FQDN/privkey.pem # Create a mapping configuration from the user to themself cat > /etc/postfix/mariadb-email2email.cf << EOF user = mailserver password = MAILSERVER-PASSWORD-HERE hosts = 127.0.0.1 dbname = mailserver query = SELECT email FROM virtual_users WHERE email='%s' EOF # Fix permissions chown root:postfix /etc/postfix/mariadb-email2email.cf chmod u=rw,g=r,o= /etc/postfix/mariadb-email2email.cf # Tell Postfix to use this mapping postconf smtpd_sender_login_maps=mysql:/etc/postfix/mariadb-email2email.cf # Test spoofing set +e swaks --server localhost:587 \ --from brunhilde@example.org \ --to list@example.com \ --tls \ --auth-user john@example.org \ --auth-password summersun \ --silent 3 if [[ $? -ne 24 ]]; then echo "❌ Forged sender spoofing test failed." exit 10 fi set -e echo "✅ Forged sender spoofing successfully rejected." # Test with proper email set +e swaks --server localhost:587 \ --from john@example.org \ --to list@example.com \ --tls \ --auth-user john@example.org \ --auth-password summersun \ --silent 3 if [[ $? -ne 0 ]]; then echo "❌ Relaying of proper email failed." exit 10 fi set -e echo "✅ Relay test okay." # Submission plaintext test set +e swaks --server localhost:587 \ --from john@example.org \ --to lisa@example.com \ --silent 3 if [[ $? -ne 23 ]]; then echo "❌ Relaying over submission without STARTTLS was accepted." exit 10 fi set -e echo "✅ Plaintext auth refused via submission port." # Submission encryption but no auth test set +e swaks --server localhost:587 \ --from john@example.org \ --to lisa@example.com \ -tls \ --silent 3 if [[ $? -ne 24 ]]; then echo "❌ Relaying over submission with STARTTLS but without auth was accepted." exit 10 fi set -e echo "✅ Encrypted non-auth refused via submission port." # Test encryption and auth over submission set +e swaks --server localhost:587 \ --from john@example.org \ --to lisa@example.com \ -tls \ --auth-user john@example.org \ --auth-password summersun \ --silent 3 if [[ $? -ne 0 ]]; then echo "❌ Relaying over submission with STARTTLS and auth failed." exit 10 fi set -e echo "✅ Encrypted and authenticated relaying over submission works." # Test encryption and auth over submissions set +e swaks --server localhost:465 \ --from john@example.org \ --to lisa@example.com \ --tls-on-connect \ --auth-user john@example.org \ --auth-password summersun \ --silent 3 if [[ $? -ne 0 ]]; then echo "❌ Relaying over submissions with STARTTLS and auth failed." exit 10 fi set -e echo "✅ Encrypted and authenticated relaying over submissions works." } ############ IMAP ############## imap_test() { # Test IMAP access apt-get -qq install fetchmail cat > ~/.fetchmailrc </dev/null rm ~/.fetchmailrc if [[ $? -ne 0 ]]; then echo "❌ IMAP connection failed" exit 10 fi set -e echo "✅ IMAP connection test successful." } ############ rspamd ############# rspamd_config() { postconf smtpd_milters=inet:127.0.0.1:11332 postconf non_smtpd_milters=inet:127.0.0.1:11332 postconf inet_protocols=all systemctl restart postfix cat > /tmp/gtube.txt < /etc/dovecot/conf.d/99-ispmail-sieve-movetojunk.conf << 'EOF' sieve_script spam-to-junk-folder { driver = file type = after path = /etc/dovecot/sieve/spam-to-junk-folder.sieve } # Enable the execution of Sieve rules when Postfix sends an email to Dovecot over LMTP protocol lmtp { mail_plugins { sieve = yes } } # Make sure that every user has a Junk folder and is subscribed to it namespace inbox { mailbox Junk { special_use = \Junk auto = subscribe } } EOF # Restart Dovecot systemctl reload dovecot # Create the directory for Sieve files mkdir -p /etc/dovecot/sieve # Create the Sieve script to move Spam mails to the user's Junk folder cat > /etc/dovecot/sieve/spam-to-junk-folder.sieve << 'EOF' require ["fileinto"]; if header :contains "X-Spam" "Yes" { fileinto "Junk"; stop; } EOF # Make the sieve script machine-readable sievec /etc/dovecot/sieve/spam-to-junk-folder.sieve 2>/dev/null # Create a config file to enable automatic spam training cat > /etc/rspamd/local.d/classifier-bayes.conf << 'EOF' # Store training data in the Redis database servers = "127.0.0.1:6379"; backend = "redis"; # Enable automatic training autolearn = true; # if rspamd is sure that an email is spam, it will be learned min_learns = 200; # do not trust the data before at least 200 mails have been learned EOF # Restart rspamd #systemctl restart rspamd cat > /etc/dovecot/conf.d/99-ispmail-imapsieve.conf << 'EOF' # Enable the imap_sieve plugin protocol imap { mail_plugins { imap_sieve = yes } } # Allow the use of the pipe plugin to send mails to shell scripts sieve_plugins { sieve_extprograms = yes sieve_imapsieve = yes } sieve_global_extensions { vnd.dovecot.pipe = yes } # Where to look for Sieve scripts that use the Pipe functionality sieve_pipe_bin_dir = /etc/dovecot/sieve # Moved into Junk? -> Learn as spam. mailbox Junk { sieve_script spam { type = before cause = copy path = /etc/dovecot/sieve/learn-spam.sieve } } # Moved out of Junk? -> Learn as ham. imapsieve_from Junk { sieve_script ham { type = before cause = copy path = /etc/dovecot/sieve/learn-ham.sieve } } EOF # Create spam learning script cat > /etc/dovecot/sieve/learn-spam.sieve << 'EOF' require ["vnd.dovecot.pipe", "copy", "imapsieve"]; pipe :copy "rspamd-learn-spam.sh"; EOF # Create ham learning script cat > /etc/dovecot/sieve/learn-ham.sieve << 'EOF' require ["vnd.dovecot.pipe", "copy", "imapsieve", "variables"]; pipe :copy "rspamd-learn-ham.sh"; EOF # Compile the Sieve scripts systemctl reload dovecot sievec /etc/dovecot/sieve/learn-spam.sieve 2>/dev/null sievec /etc/dovecot/sieve/learn-ham.sieve 2>/dev/null # Fix permissions chmod u=rw,go= /etc/dovecot/sieve/learn-{spam,ham}.{sieve,svbin} chown vmail:vmail /etc/dovecot/sieve/learn-{spam,ham}.{sieve,svbin} # Create the shell script for learning spam cat > /etc/dovecot/sieve/rspamd-learn-spam.sh << 'EOF' #!/bin/sh # Receives an email from Dovecot's Sieve script and pipe it into rspamc exec /usr/bin/rspamc learn_spam EOF # Create the shell script for learning ham cat > /etc/dovecot/sieve/rspamd-learn-ham.sh << 'EOF' #!/bin/sh # Receives an email from Dovecot's Sieve script and pipe it into rspamc exec /usr/bin/rspamc learn_ham EOF # Fix permissions of the shell scripts chmod u=rwx,go= /etc/dovecot/sieve/rspamd-learn-{spam,ham}.sh chown vmail:vmail /etc/dovecot/sieve/rspamd-learn-{spam,ham}.sh cat > /etc/dovecot/conf.d/99-ispmail-sieve-debug.conf << 'EOF' # Enable detailed logging of Sieve scripts log_debug=category=sieve EOF cat > /etc/dovecot/conf.d/99-ispmail-autoexpunge.conf << 'EOF' # Remove mails from the Junk and Trash folders after 30 days mailbox Junk { special_use = \Junk auto = subscribe mailbox_autoexpunge = 30d } mailbox Trash { special_use = \Trash auto = subscribe mailbox_autoexpunge = 30d } # Make expunging more efficient mailbox_list_index = yes mail_always_cache_fields = date.save EOF # Restart Dovecot systemctl restart dovecot # Generate a random password that is 15 characters long RSPAMD_PW=$(pwgen 15 1) # Create a hash of that password HASH=$(rspamadm pw -p $RSPAMD_PW) # Add the hashed password to rspamd's configuration echo "password = \"$HASH\"" > /etc/rspamd/local.d/worker-controller.inc # Restart rspamd systemctl reload rspamd echo "✅ rspamd set up" } ############ Show summary at the end ############# dkim_config() { postconf smtpd_milters=inet:127.0.0.1:11332 postconf non_smtpd_milters=inet:127.0.0.1:11332 # Create the directory mkdir -p /var/lib/rspamd/dkim # Fix permissions chown _rspamd:_rspamd /var/lib/rspamd/dkim # Tell rspamd to look up selectors in our mapping file cat > /etc/rspamd/local.d/dkim_signing.conf << 'EOF' path = "/var/lib/rspamd/dkim/$domain.$selector.key"; selector_map = "/etc/rspamd/dkim_selectors.map"; EOF # TODO: create a key for $FQDN and test it # Restart rspamd systemctl restart rspamd echo "✅ DKIM signing ready to use" } ############ Catch-all aliases ############# catchall_config() { # Create the john-to-himself mapping cat > /etc/postfix/mariadb-email2email.cf << EOF user = mailserver password = MAILSERVER-PASSWORD-HERE hosts = 127.0.0.1 dbname = mailserver query = SELECT email FROM virtual_users WHERE email='%s' EOF # Fix the permissions of that file chgrp postfix /etc/postfix/mariadb-*.cf chmod u=rw,g=r,o= /etc/postfix/mariadb-*.cf # Add the new mapping to the virtual_alias_maps # (The order is not important. Postfix will check all mapping files.) postconf virtual_alias_maps=mysql:/etc/postfix/mariadb-virtual-alias-maps.cf,mysql:/etc/postfix/mariadb-email2email.cf result=$(postmap -q john@example.org mysql:/etc/postfix/mariadb-email2email.cf) if [[ $result != "john@example.org" ]]; then echo "❌ email2email mapping for catch-all aliases do not work properly" exit 10 fi set -e echo "✅ Catch-all aliases ready to use." } ############ Going-live ############# going_live() { # Create two random passwords PW_MAILADMIN=$(pwgen -s 32 1) PW_MAILSERVER=$(pwgen -s 32 1) # Replace the dummy passwords sed -i "s|MAILADMIN-PASSWORD-HERE|$PW_MAILADMIN|g" /etc/roundcube/plugins/password/config.inc.php sed -i "s|MAILSERVER-PASSWORD-HERE|$PW_MAILSERVER|g" \ /etc/dovecot/conf.d/99-ispmail-sql.conf \ /etc/postfix/mariadb-virtual-mailbox-maps.cf \ /etc/postfix/mariadb-virtual-mailbox-domains.cf \ /etc/postfix/mariadb-virtual-alias-maps.cf \ /etc/postfix/mariadb-email2email.cf # Restart the services systemctl restart postfix dovecot # Update MariaDB user passwords mariadb <