diff --git a/public/ispmail.sh b/public/ispmail.sh new file mode 100755 index 0000000..814fe47 --- /dev/null +++ b/public/ispmail.sh @@ -0,0 +1,991 @@ +#!/bin/bash -e +# +# Installer for ISPmail servers +# Copyright © 2025 Christoph Haas +# +# License: Creative Commons BY-NC-SA license +# +# Version 0.1 +# 2025-12-14 +# + +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 + + # 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 < - This page is currently rewritten. It will be online in a couple of days. + -![Under construction](images/under-construction.jpg) +Quotas are size limits for users. You can make sure that users do not waste arbitrary amounts of disk space but are +forced to clean up old emails every now and then. + +The magic happens in two places: + +1. Postfix needs to reject new emails if the user’s mailbox is over quota. +2. Dovecot needs to keep track of the quota and how much the user has already used up of it. + +--- + +https://doc.dovecot.org/2.4.2/core/plugins/quota.html#quota-service + +https://sys4.de/en/blog/postfix-dovecot-mailbox-quota/ + +--- + +https://www.postfix.org/SMTPD_POLICY_README.html + +telnet 127.0.0.1 13373 Trying 127.0.0.1... Connected to 127.0.0.1. Escape character is '^]'. +recipient=chris@auenland.workaround.org + +action=554 5.2.2 Quota exceeded (mailbox for user is full) + +--- + +```swaks --from nonexistens@example.org --to chris@auenland.workaround.org +=== Trying auenland.workaround.org:25... +=== Connected to auenland.workaround.org. +<- 220 auenland ESMTP Postfix (Debian) + -> EHLO minty +<- 250-auenland +<- 250-PIPELINING +<- 250-SIZE 10240000 +<- 250-VRFY +<- 250-ETRN +<- 250-STARTTLS +<- 250-ENHANCEDSTATUSCODES +<- 250-8BITMIME +<- 250-DSN +<- 250-SMTPUTF8 +<- 250 CHUNKING + -> MAIL FROM: +<- 250 2.1.0 Ok + -> RCPT TO: +<** 554 5.2.2 : Recipient address rejected: Quota exceeded (mailbox for user is full) + -> QUIT +<- 221 2.0.0 Bye +=== Connection closed with remote host. +``` + +--- + +``` +cat 99-ispmail-quota.conf +mail_plugins { + quota = yes +} + +quota "User quota" { + storage_size = 100K + storage_grace = 0 + + warning warn-95 { + quota_storage_percentage = 95 + execute quota-warning { + args = 95 %{user} + } + } + + warning warn-80 { + quota_storage_percentage = 80 + execute quota-warning { + args = 80 %{user} + } + } +} + +service quota-status { + executable = quota-status -p postfix + inet_listener quota-status { + port = 13373 + } + client_limit = 1 +} + +# Example quota-warning service. The unix listener's permissions should be +# set in a way that mail processes can connect to it. Below example assumes +# that mail processes run as vmail user. If you use mode=0666, all system users +# can generate quota warnings to anyone. +service quota-warning { + executable = script /usr/local/bin/ispmail-quota-warning.sh + user = dovecot + unix_listener quota-warning { + user = vmail + } +} + +## +## Quota backends +## + +# Multiple backends are supported: +# count: Default and recommended, quota driver tracks the quota internally within Dovecot's index files. +# maildir: Maildir++ quota +# fs: Read-only support for filesystem quota +#quota "User quota" { +# driver = count +#} +``` + +--- + +``` +cat /usr/local/bin/ispmail-quota-warning.sh +#!/bin/sh +PERCENT=$1 +USER=$2 +cat << EOF | /usr/lib/dovecot/dovecot-lda -d $USER -o quota_enforce=no +From: postmaster@domain.com +Subject: quota warning + +Your mailbox is now $PERCENT% full. +EOF +``` + +--- + +### Dovecot quota policy service + +Let’s start with Dovecot. Find the file `/etc/dovecot/conf.d/90-quota.conf` and edit it. There are several `plugin {}` +sections. Take one and make it look like: + +``` +plugin { + quota = count:User quota + quota_vsizes = yes + + quota_status_success = DUNNO + quota_status_nouser = DUNNO + quota_status_overquota = "452 4.2.2 Mailbox is full and cannot receive any more emails" +} +``` + +The first line defines that you want to calculate the used space in a user’s _maildir_. There are several +[backends](https://doc.dovecot.org/configuration_manual/quota_plugin/) like that but the +_[count](https://doc.dovecot.org/configuration_manual/quota/quota_count/#quota-backend-count)_ is the best choice in +this context. (Previous guides used _maildir_ here.) The string “User quota” is just an arbitrary string that may be +queried from a mail user agent. + +The lines starting with “`quota_status_…`” set return values for the service that you will set up in a minute. It will +tell Postfix that it will not interfere (_DUNNO_ – colloquial way to say “I don’t know”). And it will return a string +with a return code 452 if the user is over quota. Codes starting with “4” mean temporary errors. It will tell the +sending party that it is worth retrying at a later time. However if the user does not resolve the issue it will lead to +a _bounce_ error email after three days. + +In the same file (_90-quota.conf_) add another section: + +``` +service quota-status { + executable = /usr/lib/dovecot/quota-status -p postfix + unix_listener /var/spool/postfix/private/quota-status { + user = postfix + } +} +``` + +This creates a new [Dovecot service](https://doc.dovecot.org/configuration_manual/service_configuration/) responding to +requests from other processes. You surely recognize that we put it into the jail that Postfix runs in +(_/var/spool/postfix_), so that Postfix can access it. + +Time to restart Dovecot: + +``` +systemctl restart dovecot +``` + +Take a look at the /var/spool/postfix/private directory. If all went as intended you will find a socket file called +`quota-status` there. Otherwise please check the `/var/log/mail.log` file for errors. + +### Postfix recipient restrictions + +If we stopped here, then Dovecot would reject emails for users who have no space left. However Postfix would still +happily receive new emails and attempt to forward them to Dovecot via LMTP. Dovecot however will deny that. It will then +keep the email in its queue and retry for a while. In the end it will send a _bounce_ back to the sender telling them +about the problem. So why is this bad? + +1. The sender will assume that the email was delivered while it is stuck in the queue for up to three days. +2. Spam emails use forged senders. So at the time that Postfix generates the _bounce email_ it will likely send it to an + innocent person. This is called _backscatter_ and considered a mail server misconfiguration. Such a problem may get + your mail server blacklisted. You don’t want that. + +So the next logical step is to make Postfix check whether a mailbox is over quota whenever a new email arrives. Let’s +hook up into the “RCPT TO” phase of the SMTP dialog when a new email comes in. Postfix checks its +_smtpd_recipient_restrictions_ configuration at this stage. Run this command in the shell: + +``` +postconf smtpd_recipient_restrictions=reject_unauth_destination, \ + "check_policy_service unix:private/quota-status" +``` + +This adds two checks: + +1. `reject_unauth_destination` checks whether the mail server is the final destination for the recipient’s email + address. This is pretty much the default behavior if you do not define any restrictions. +2. `check_policy_service` connects to the socket file at `/var/spool/postfix/private/quota-status` that was put there by + Dovecot. It will use it to ask Dovecot whether the user is over quota in which case the email would get rejected. + +### Test it + +If you are curious to see this working, then set John’s mailbox quota to 5 KB: + +```sql +# mariadb mailserver +mysql> update virtual_users set quota=4000 where email='john@example.org'; +``` + +Send him a few emails using the ‘swaks’ tool: + +``` +swaks --server localhost --to john@example.org +``` + +After a few emails you will see the rejection message: + +``` +-> RCPT TO:john@example.org + <** 452 4.2.2 john@example.org: Recipient address rejected: Mailbox is full and cannot receive any more emails +``` + +### Troubleshooting + +These are things you should consider if quotas do not seem to work properly: + +- Check if you have enabled “quota” in the “mail_plugins” in the 10-mail.conf file. +- Your users may complain that they have deleted many emails but are still over quota. Let them check if they actually + emptied the _Trash_ folder. Of course emails in that folder also contribute to the disk space usage. Once the Trash + folder is expunged the problem should be gone. You may also allow your users more space in the Trash folder. That’s + explained in the [Dovecot documentation](https://doc.dovecot.org/configuration_manual/quota/#quota-rules). +- If you directly remove files from a user’s Maildir instead of properly accessing the mailbox using IMAP then you will + screw up the quota calculation. In that case let Dovecot recalculate the quota: + `doveadm quota recalc -u john@example.org` + +### Automatic warning emails + +The last step is to inform the poor users if they accidentally went over quota. After all they do not necessarily +recognize that on their own. Let’s do that by sending them an email with a warning. Yes, we will make sure that the +email gets through even if the quota is reached. + +Edit the `90-quota.conf` file again. Add this section to the file (derived from the +[Dovecot documentation](https://doc.dovecot.org/configuration_manual/quota/#quota-warning-scripts)): + +``` +plugin { + quota_warning = storage=95%% quota-warning 95 %u + quota_warning2 = storage=80%% quota-warning 80 %u +} +service quota-warning { + executable = script /usr/local/bin/quota-warning.sh + unix_listener quota-warning { + user = vmail + group = vmail + mode = 0660 + } +} +``` + +This section defines two automatic quota warnings. The first (quota_warning) is triggered if the user reaches 95% of the +quota. The second (quota_warning2) at 80%. These lines follow this schema: + +- **Trigger** (e.g. “storage=95%”). The “%” sign needs to be used twice if you want to emit a literal percent sign. So + this is not a typo. +- The **socket** you want to call in that case. Our socket is the “service quota-warning” that calls a shell script. +- Additional **parameters** that are passed to the shell script in our case. They tell the script the percentage that + has been reached (e.g. 95) and the address of the user who should get the warning. + +Apparently we need the script to run. So please create a new file at `/usr/local/bin/quota-warning.sh` and put these +lines into it: + +``` +#!/bin/sh +PERCENT=$1 +USER=$2 +cat << EOF | /usr/lib/dovecot/dovecot-lda -d $USER -o "plugin/quota=maildir:User quota:noenforcing" +From: postmaster@webmail.example.org +Subject: Quota warning - $PERCENT% reached + +Your mailbox can only store a limited amount of emails. +Currently it is $PERCENT% full. If you reach 100% then +new emails cannot be stored. Thanks for your understanding. +EOF +``` + +Make this file executable: + +``` +chmod +x /usr/local/bin/quota-warning.sh +``` + +Time to restart Dovecot again: + +``` +systemctl restart dovecot +``` + +Dovecot’s quota limits can be configured in many ways. If you have special needs then give +[their documentation](https://doc.dovecot.org/configuration_manual/quota/) a look. diff --git a/src/content/docs/ispmail-trixie/400-going-live.mdx b/src/content/docs/ispmail-trixie/400-going-live.mdx index 093bab0..871c43a 100644 --- a/src/content/docs/ispmail-trixie/400-going-live.mdx +++ b/src/content/docs/ispmail-trixie/400-going-live.mdx @@ -36,6 +36,12 @@ ALTER USER 'mailserver'@'127.0.0.1' IDENTIFIED BY '${PW_MAILSERVER}'; FLUSH PRIVILEGES; EOF +# Delete example data +mariadb < + You may be tempted to skip the entire guide and just download and run the installation script. I trust that you are + not doing that. Only use this script after you have set up at least one mail server while following this guide from + start to end. It is easy to use but it is meant for experienced users who want to install multiple mail servers. + + +To use the automated installer: + +```sh +wget https://workaround.org/ispmail.sh +chmod +x ispmail.sh +./ispmail.sh -f example.org +``` + +Use your main FQDN instead of **example.org**. It will become the main host name for Roundcube, rspamd, SMTP, IMAP and +will be taken as the common name for the Let's Encrypt certificate. + +This script is something new. So please report your experience while using it down in the comments.