Skip to content

Virtual Mailboxes

Postfixadmin

Install Postfixadmin

Postfixadmin is a web module that allows you to easily manipulate virtual domains and users in a database.

Futher Reading: http://postfixadmin.sourceforge.net/

First, we'll install the required packages:

apt update && apt install php7.4-fpm php7.4-imap php7.4-mbstring php7.4-mysql php7.4-json php7.4-curl php7.4-zip php7.4-xml php7.4-bz2 php7.4-intl php7.4-gmp

Next, we'll download the actual latest version 3.2.4 from GitHub and save it in the /opt folder:

wget -P /opt https://github.com/postfixadmin/postfixadmin/archive/postfixadmin-3.2.4.tar.gz

Wget is a free software package for retrieving files using HTTP, HTTPS, FTP and FTPS. More information here: https://www.lifewire.com/uses-of-command-wget-2201085 and here: https://www.gnu.org/software/wget/manual/html_node/index.html

Now go to that folder and uncompress it:

cd /opt && tar xvf postfixadmin-3.2.4.tar.gz

And then move and rename it:

mv postfixadmin-postfixadmin-3.2.4/ /usr/share/postfixadmin

Next, we connect to MariaDB database...

mysql -u root -p

And create a database and a user:

CREATE DATABASE postfix;
CREATE USER 'postfix'@'localhost' IDENTIFIED BY 'postfix-db-password';
GRANT ALL PRIVILEGES ON `postfix` . * TO 'postfix'@'localhost';
FLUSH PRIVILEGES;
exit

Now we need to amend the Postfixadmin config file so that it knows about the database. Replace the examples with your own values:

nano /usr/share/postfixadmin/config.local.php
<?php
$CONF['database_type'] = 'mysqli';
$CONF['database_user'] = 'postfix';
$CONF['database_password'] = 'postfix-db-password';
$CONF['database_name'] = 'postfix';

$CONF['default_aliases'] = array (
  'abuse'      => 'abuse@example.com',
  'hostmaster' => 'hostmaster@example.com',
  'postmaster' => 'postmaster@example.com',
  'webmaster'  => 'webmaster@example.com'
);

$CONF['configured'] = true;

$CONF['fetchmail'] = 'NO';
$CONF['show_footer_text'] = 'NO';

$CONF['quota'] = 'YES';
$CONF['domain_quota'] = 'YES';
$CONF['quota_multiplier'] = '1024000';
$CONF['used_quotas'] = 'YES';
$CONF['new_quota_table'] = 'YES';

$CONF['aliases'] = '0';
$CONF['mailboxes'] = '0';
$CONF['maxquota'] = '0';
$CONF['domain_quota_default'] = '0';
?>

Postfixadmin requires permission to a sub-folder named templates_c that doesn't exist. So to avoid some errors during the installation, we need to create it manually and give the www-data user permission:

mkdir /usr/share/postfixadmin/templates_c
setfacl -R -m u:www-data:rwx /usr/share/postfixadmin/templates_c/

Now we'll change the default password scheme to something stronger:

nano /usr/share/postfixadmin/config.local.php
<?php
$CONF['encrypt'] = 'dovecot:ARGON2I';

$CONF['dovecotpw'] = "/usr/bin/doveadm pw -r 5";
if(@file_exists('/usr/bin/doveadm')) { // @ to silence openbase_dir stuff; see https://github.com/postfixadmin/postfixadmin/issues/171
    $CONF['dovecotpw'] = "/usr/bin/doveadm pw -r 5"; # debian
}

Issuing SSL Certificates

Now we need to create the site on Nginx reverse proxy but before we do that, we need to create the SSL certificates using Acme.sh (which I assume is already installed and configured). Make sure you've entered the A and AAAA records for the new postfixadmin domain in your DNS management service (eg pfa2.example.com)

Change to root user:

su -

First issue an RSA certificate

~/.acme.sh/acme.sh --issue --dns dns_cloudns -d pfa2.example.com --keylength 4096 --key-file /etc/letsencrypt/rsa-certs/pfa2.example.com/privkey.pem --ca-file /etc/letsencrypt/rsa-certs/pfa2.example.com/chain.pem --cert-file /etc/letsencrypt/rsa-certs/pfa2.example.com/cert.pem --fullchain-file /etc/letsencrypt/rsa-certs/pfa2.example.com/fullchain.pem --pre-hook "mkdir -p /etc/letsencrypt/rsa-certs/pfa2.example.com" --post-hook "find /etc/letsencrypt/rsa-certs/pfa2.example.com/ -name '*.pem' -type f -exec chmod 600 {} \;" --renew-hook "find /etc/letsencrypt/rsa-certs/pfa2.example.com/ -name '*.pem' -type f -exec chmod 600 {} \; -exec service nginx reload \;"
~/.acme.sh/acme.sh --issue --dns dns_cloudns -d pfa2.example.com --keylength ec-384 --key-file /etc/letsencrypt/ecc-certs/pfa2.example.com/privkey.pem --ca-file /etc/letsencrypt/ecc-certs/pfa2.example.com/chain.pem --cert-file /etc/letsencrypt/ecc-certs/pfa2.example.com/cert.pem --fullchain-file /etc/letsencrypt/ecc-certs/pfa2.example.com/fullchain.pem --pre-hook "mkdir -p /etc/letsencrypt/ecc-certs/pfa2.example.com" --post-hook "find /etc/letsencrypt/ecc-certs/pfa2.example.com/ -name '*.pem' -type f -exec chmod 600 {} \;" --renew-hook "find /etc/letsencrypt/ecc-certs/pfa2.example.com/ -name '*.pem' -type f -exec chmod 600 {} \; -exec service nginx reload \;"

Exit back to the sudo shell:

exit

Configuring Nginx

Now we'll create the site:

nano /etc/nginx/sites-available/pfa2.example.com
server {

    listen 99 ssl http2;
    listen [::]:99 ssl http2;

    server_name pfa2.example.com;

    ssl_certificate /etc/letsencrypt/rsa-certs/pfa2.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/rsa-certs/pfa2.example.com/privkey.pem;
    ssl_certificate /etc/letsencrypt/ecc-certs/pfa2.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/ecc-certs/pfa2.example.com/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/ecc-certs/pfa2.example.com/chain.pem;

    include /etc/nginx/custom-config/ssl.conf;
    include /etc/nginx/custom-config/header.conf;

    location / {
        include /etc/nginx/custom-config/proxy.conf;
        proxy_pass http://127.0.0.1:8080;
    }
}

The contents of the conf files are explained in my Nginx article

Enable the site with:

ln -s /etc/nginx/sites-available/pfa2.example.com /etc/nginx/sites-enabled/

Configuring Apache

We then create an Apache2 virtualhost:

nano /etc/apache2/sites-available/pfa2.example.com.conf
<VirtualHost 127.0.0.1:8080>

        Protocols http/1.1

        ServerAdmin admin@example.com
        DocumentRoot /usr/share/postfixadmin/public
        ServerName pfa2.example.com

        <Directory /usr/share/postfixadmin/public>
            Options -Indexes +FollowSymLinks +MultiViews
            AllowOverride All
            Require all granted
            <FilesMatch \.php$>
                SetHandler "proxy:unix:/var/run/php/php7.4-fpm-www-postfixadmin.sock|fcgi://localhost/"
            </FilesMatch>
            <IfModule mod_apparmor.c>
                AAHatName postfixadmin-a2
            </IfModule>
        </Directory>

        SetEnvIf HTTPS on HTTPS=on

        RemoteIPHeader X-Forwarded-For
        RemoteIPTrustedProxy 127.0.0.1

        ErrorLog ${APACHE_LOG_DIR}/error.log
        CustomLog ${APACHE_LOG_DIR}/access.log combined

</VirtualHost>

As you can see, we've allocated this site to the www-postfixadmin fpm pool (please see the PHP-FPM article for further information) and added an apparmor changehat which we'll configure shortly.

Now enable the site with:

a2ensite pfa2.example.com.conf

Creating the PHP-FPM Pool

First we'll create the user:

groupadd www-postfixadmin
useradd -g www-postfixadmin www-postfixadmin

We'll also create a tmp directory for PHP:

mkdir /usr/share/postfixadmin/tmp

And change permissions across the structure

chown -R www-postfixadmin:www-data /usr/share/postfixadmin
find /usr/share/postfixadmin -type d -exec chmod 550 {} \;
find /usr/share/postfixadmin -type f -exec chmod 440 {} \;
chmod g+s /usr/share/postfixadmin
chown -R www-postfixadmin:www-postfixadmin /usr/share/postfixadmin/tmp
chmod g+s /usr/share/postfixadmin/tmp
chmod 770 /usr/share/postfixadmin/templates_c
chmod 770 /usr/share/postfixadmin/templates
chmod g+s /usr/share/postfixadmin/templates_c
chmod g+s /usr/share/postfixadmin/templates
find /usr/share/postfixadmin -type f -name '*.php' -exec chmod 600 {} \;

Next we add www-postfixadmin to the dovecot group so that it has the required permissions to perform certain tasks:

usermod -aG dovecot www-postfixadmin

Now we create our PHP-FPM pool:

nano /etc/php/7.4/fpm/pool.d/www-postfixadmin.conf
; pool name
[www-postfixadmin]

; Unix user/group of processes
; will be used.
user = www-postfixadmin
group = www-postfixadmin
listen.owner = www-data
listen.group = www-data

; Socket to which the Apache will connect
listen = /run/php/php7.4-fpm-www-postfixadmin.sock

apparmor_hat = postfixadmin

pm = ondemand

pm.max_children = 5
pm.process_idle_timeout = 10s
;pm.start_servers = 2
;pm.min_spare_servers = 1
;pm.max_spare_servers = 3
pm.max_requests = 500

php_admin_value[allow_url_fopen] = On
php_admin_value[allow_url_include] = On
php_admin_value[memory_limit] = 512M
php_admin_flag[output_buffering] = Off
php_admin_value[max_execution_time] = 1800
php_admin_value[max_input_time] = 3600
php_admin_value[post_max_size] = 10M
php_admin_value[upload_max_filesize] = 10M
php_admin_value[upload_tmp_dir] = /usr/share/postfixadmin/tmp
php_admin_value[max_file_uploads] = 100
php_admin_flag[session.cookie_secure] = True
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source
php_admin_value[open_basedir] = /usr/share/postfixadmin/

After running the site for a while, feel free to come back and tune the settings according to the instructions in the earlier PHP-FPM guide

Now check the Apache2 and Nginx configs:

apache2ctl configtest
nginx -t

And restart the services:

service php7.4-fpm restart && service apache2 restart && service nginx reload

Creating the Apparmor Changehat

edit the php-fpm apparmor profile:

nano /etc/apparmor.d/usr.sbin.php-fpm7.4

Add the following changehat:

^postfixadmin flags=(attach_disconnected, complain) {
    #include <abstractions/base>
    #include <abstractions/dovecot-common>
    #include <abstractions/mysql>
    #include <abstractions/nameservice>
    #include <abstractions/openssl>
    #include <abstractions/php>
    #include <abstractions/ssl_certs>

    signal receive peer=php-fpm7.4,

    deny /var/www/** r,

    /etc/dovecot/conf.d/ r,
    /etc/dovecot/conf.d/10-auth.conf r,
    /etc/dovecot/conf.d/10-director.conf r,
    /etc/dovecot/conf.d/10-logging.conf r,
    /etc/dovecot/conf.d/10-mail.conf r,
    /etc/dovecot/conf.d/10-master.conf r,
    /etc/dovecot/conf.d/10-ssl.conf r,
    /etc/dovecot/conf.d/10-tcpwrapper.conf r,
    /etc/dovecot/conf.d/15-lda.conf r,
    /etc/dovecot/conf.d/15-mailboxes.conf r,
    /etc/dovecot/conf.d/20-imap.conf r,
    /etc/dovecot/conf.d/20-lmtp.conf r,
    /etc/dovecot/conf.d/20-managesieve.conf r,
    /etc/dovecot/conf.d/90-acl.conf r,
    /etc/dovecot/conf.d/90-plugin.conf r,
    /etc/dovecot/conf.d/90-quota.conf r,
    /etc/dovecot/conf.d/90-sieve-extprograms.conf r,
    /etc/dovecot/conf.d/90-sieve.conf r,
    /etc/dovecot/conf.d/auth-sql.conf.ext r,
    /etc/dovecot/conf.d/auth-system.conf.ext r,
    /etc/dovecot/dovecot.conf r,
    /run/php/php7.4-fpm-www-postfixadmin.sock rw,
    /usr/bin/dash mrix,
    /usr/bin/doveadm mrix,
    /usr/bin/doveconf mrix,
    /usr/share/dovecot/protocols.d/ r,
    /usr/share/dovecot/protocols.d/imapd.protocol r,
    /usr/share/dovecot/protocols.d/lmtpd.protocol r,
    /usr/share/dovecot/protocols.d/managesieved.protocol r,
    owner /usr/share/postfixadmin/** rw,

  }

Create an apache2 changehat for postfixadmin:

nano /etc/apparmor.d/apache2.d/postfixadmin-a2
^postfixadmin-a2 flags=(attach_disconnected) {
   #include <abstractions/apache2-common>
   #include <abstractions/base>
   #include <abstractions/nameservice>
   #include <abstractions/php>

   capability setuid,
   capability setgid,

   # for log writing (could be abstracted)
   /var/log/apache2/access.log w,
   /var/log/apache2/error.log w,

   # Socket access
   /run/php/php7.4-fpm-www-postfixadmin.sock wr,

   # Access to standard PostfixAdmin files
  /usr/share/postfixadmin/public/ r,
  /usr/share/postfixadmin/public/** r,

   # Deny access to these locations
   deny /var/www/** r,

   # Deny access to Bash
   deny /bin/bash r,
 }

Reload apparmor:

service apparmor reload

If you encounter problems with the next step, you may want to put the php-fpm profile into complain mode with aa-complain usr.sbin.php-fpm7.4 followed a little later with aa-logprof to update the profile. Once happy with it, you can enforce again with aa-enforce usr.sbin.php-fpm7.4

Final steps

Browse to the setup page in a browser at https://pfa2.example.com/setup.php. The Postfix Admin Setup Checker should pass all the tests.

Enter a new password and select 'Generate Password Hash'.

Follow the instructions and add the password hash to /usr/share/postfixadmin/config.local.php

$CONF['setup_password'] = 'password_hash'

proceed to create a superadmin account and you should then be free to log in with the new credentials.

Configuring Postfix

First add MySQL support to Postfix:

apt install postfix-mysql

Edit the main Postfix configuration file:

nano /etc/postfix/main.cf

and add the following lines at the end:

virtual_mailbox_domains = proxy:mysql:/etc/postfix/sql/mysql_virtual_domains_maps.cf
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
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
virtual_transport = lmtp:unix:private/dovecot-lmtp

Next we create the cf files one by one. First we create the sql directory to store the config files in:

mkdir /etc/postfix/sql/

Create the files as follows:

nano /etc/postfix/sql/mysql_virtual_domains_maps.cf
user = postfix
#Replace password with the postfixadmin password you set earlier
password = password
hosts = localhost
dbname = postfix
query = SELECT domain FROM domain WHERE domain='%s' AND active = '1'
#query = SELECT domain FROM domain WHERE domain='%s'
#optional query to use when relaying for backup MX
#query = SELECT domain FROM domain WHERE domain='%s' AND backupmx = '0' AND active = '1'
#expansion_limit = 100
nano /etc/postfix/sql/mysql_virtual_mailbox_maps.cf
user = postfix
#Replace password with the postfixadmin password you set earlier
password = password
hosts = localhost
dbname = postfix
query = SELECT maildir FROM mailbox WHERE username='%s' AND active = '1'
#expansion_limit = 100
nano /etc/postfix/sql/mysql_virtual_alias_domain_mailbox_maps.cf
user = postfix
#Replace password with the postfixadmin password you set earlier
password = password
hosts = localhost
dbname = postfix
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'
nano /etc/postfix/sql/mysql_virtual_alias_maps.cf
user = postfix
#Replace password with the postfixadmin password you set earlier
password = password
hosts = localhost
dbname = postfix
query = SELECT goto FROM alias WHERE address='%s' AND active = '1'
#expansion_limit = 100
nano /etc/postfix/sql/mysql_virtual_alias_domain_maps.cf
user = postfix
#Replace password with the postfixadmin password you set earlier
password = password
hosts = localhost
dbname = postfix
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'
nano /etc/postfix/sql/mysql_virtual_alias_domain_catchall_maps.cf
# handles catch-all settings of target-domain
user = postfix
#Replace password with the postfixadmin password you set earlier
password = password
hosts = localhost
dbname = postfix
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'

Since the database passwords are stored in plain text so they should be readable only by user postfix and root, which is done by executing the following two commands.

chmod 0640 /etc/postfix/sql/*
setfacl -R -m u:postfix:rx /etc/postfix/sql/

Remove the apex domain name from the mydestination variable:

postconf -e "mydestination = \$myhostname, localhost.\$mydomain, localhost"

Edit the main Postfix configuration file:

nano /etc/postfix/main.cf

and add the following to the end of the file:

virtual_mailbox_base = /var/vmail
virtual_minimum_uid = 2000
virtual_uid_maps = static:2000
virtual_gid_maps = static:2000

Restart Postfix:

systemctl restart postfix

Create a user named vmail with ID 2000 and a group with ID 2000:

adduser vmail --system --group --uid 2000 --disabled-login --no-create-home

Create the mail base location:

mkdir /var/vmail/

Make vmail the owner:

chown vmail:vmail /var/vmail/ -R

Configuring Dovecot

Install the mysql dovercot plugin:

apt install dovecot-mysql

Edit the 10-mail.conf file:

nano /etc/dovecot/conf.d/10-mail.conf

Change or add the following lines to change the mail location:

mail_location = maildir:/var/vmail/%d/%n
mail_home = /var/vmail/%d/%n

Now edit the 10-auth.conf file:

nano /etc/dovecot/conf.d/10-auth.conf

Change or uncomment the following lines:

auth_username_format = %u
!include auth-sql.conf.ext
#Comment the below line if you don’t want local Unix users to send emails 
#without registering email addresses in PostfixAdmin
#!include auth-system.conf.ext

#Uncomment the below lines for extra login debug info in log
#auth_debug = yes
#auth_debug_passwords = yes

Edit the dovecot-sql.conf.ext file (replace 'password' with the password you set for postfixadmin):

nano /etc/dovecot/dovecot-sql.conf.ext
driver = mysql

connect = host=localhost dbname=postfix user=postfix password=password

default_pass_scheme = ARGON2I

password_query = SELECT username AS user,password FROM mailbox WHERE username = '%u' AND active='1'

user_query = SELECT CONCAT('/var/vmail/',maildir) AS home, CONCAT('maildir:/var/vmail/',maildir) AS mail, 2000 AS uid, 2000 AS gid, CONCAT('*:bytes=',quota) AS quota_rule FROM mailbox WHERE username = '%u' AND active='1'

iterate_query = SELECT username AS user FROM mailbox

Edit the /etc/dovecot/conf.d/10-master.conf file, find the service service auth section and replace it with the following lines:

nano /etc/dovecot/conf.d/10-master.conf
service auth {

  unix_listener auth-userdb {
    mode = 0600
    user = vmail
  }

  # Postfix smtp-auth
  unix_listener /var/spool/postfix/private/auth {
    mode = 0660
    user = postfix
    group = postfix
  }

  # Auth process is run as this user.
  user = dovecot

}

Then change the service auth-worker section to the following:

service auth-worker {
  user = vmail
}

Enable the Dovecot service to start on server boot, and restart Dovecot so that all of these new configuration files are in effect:

systemctl enable dovecot
systemctl restart dovecot

Setting up Quota support

Open the conf.d/10-master.conf file, and modify it as follows:

nano /etc/dovecot/conf.d/10-master.conf
...
service dict {
  unix_listener dict {
    mode = 0660
    user = vmail
    group = vmail
  }
}
...

Next, edit the conf.d/10-mail.conf file and edit the following variables:

nano /etc/dovecot/conf.d/10-mail.conf
...
mail_plugins = $mail_plugins quota
...

Open the conf.d/20-imap.conf file and activate the imap_quota plugin:

nano /etc/dovecot/conf.d/20-imap.conf
...
protocol imap {
  ...
  mail_plugins = $mail_plugins imap_sieve imap_quota
  ...
}
...

Edit the 15-lda.conf file:

nano /etc/dovecot/conf.d/15-lda.conf
...
mail_plugins = $mail_plugins quota
...

Edit the 90-quota.conf file:

nano /etc/dovecot/conf.d/90-quota.conf
plugin {
  quota = dict:User quota::proxy::sqlquota
  quota_rule = *:storage=5GB
  quota_rule2 = Trash:storage=+100M
  quota_grace = 10%%
  quota_exceeded_message = Quota exceeded, please contact your system administrator.
  quota_warning = storage=100%% quota-warning 100 %u
  quota_warning2 = storage=95%% quota-warning 95 %u
  quota_warning3 = storage=90%% quota-warning 90 %u
  quota_warning4 = storage=85%% quota-warning 85 %u
}

service quota-warning {
  executable = script /usr/local/bin/quota-warning.sh
  user = vmail

  unix_listener quota-warning {
    group = vmail
    mode = 0660
    user = vmail
  }
}

dict {
  sqlquota = mysql:/etc/dovecot/dovecot-dict-sql.conf.ext
}

We also need to tell dovecot how to access the quota SQL dictionary. Open the dovecot-dict-sql.conf.ext file and edit the following lines:

nano /etc/dovecot/dovecot-dict-sql.conf.ext
...
connect = host=127.0.0.1 dbname=postfixadmin user=postfixadmin password=password
...
map {
  pattern = priv/quota/storage
  table = quota2
  username_field = username
  value_field = bytes
}
map {
  pattern = priv/quota/messages
  table = quota2
  username_field = username
  value_field = messages
}
...
# map {
#   pattern = shared/expire/$user/$mailbox
#   table = expires
#   value_field = expire_stamp
#
#   fields {
#     username = $user
#     mailbox = $mailbox
#   }
# }
...

Make sure to replace 'password' with the correct password.

Create the following shell script which will send an email to the user if its quota exceeds a specified limit:

nano /usr/local/bin/quota-warning.sh
#!/bin/sh
PERCENT=$1
USER=$2
cat << EOF | /usr/lib/dovecot/dovecot-lda -d $USER -o "plugin/quota=dict:User quota::noenforcing:proxy::sqlquota"
From: postmaster@example.com
Subject: Quota warning

Your mailbox is now $PERCENT% full.
EOF

Make the script executable by running the following chmod command:

chmod +x /usr/local/bin/quota-warning.sh

Finally, restart the Dovecot service:

service dovecot restart