CentOS 5 POP3/IMAP/SMTP mail server with virtual users [Dovecot LDA+SASL, Postfix]

This how-to will show you how to configure:

  • A MySQL database to store information about email accounts, aliases (per-address or per-domain) and autoresponders
  • Postfix as your mail transfer agent (MTA) for SMTP
    • amavisd-new with clamav & spamassassin for automatic virus and spam filtering
    • Ability to define virtual users (via MySQL database) mapped to a real UID/GID or system users
    • Aliases generated automatically from the MySQL database, allowing one address to forward to another or all addresses on a domain to forward to another
    • Response handling email autoresponders
    • Dovecot as the local delivery agent (LDA) delivering mail to the corresponding user's mailbox
    • Dovecot as SASL authenticator
  • Dovecot for POP3 & IMAP
    • Dovecot LDA delivering mail as any system user (better security)
    • SASL authentication based on virtual user information stored in the MySQL database

As this may seem like a bit complex at first, below is a simple overview of how the system works once put together:

  • When a user connects to the server to retrieve mail (POP3 or IMAP), Dovecot performs the SASL authentication and then connects the user to their message inbox.
  • When a user sends mail to the server destined for another domain (outgoing SMTP), Postfix calls on Dovecot to perform SASL authentication. If it succeeds, relay access is granted and the message is sent to the external mail server.
  • When an external user sends mail to the server destined for a domain handled by Postfix (incoming SMTP), Postfix checks the system and virtual user tables to ensure that the recipient is valid. If so, then it passes through a few verifications first (valid sender, virus filter, spam filter) and if all pass then the message is delivered to the corresponding user's inbox via Dovecot LDA.

Before starting

Please ensure that you have followed the instructions in the getting started guide here. This tutorial may require you to rebuild RPMs as well as generate SSL certificates and assumes you have the proper environment described in that guide already setup.

If you have not setup the database server yet, please follow the database how-to first.

Setting up the database

Before installing dovecot or postfix, the MySQL database which will be used to authenticate all email clients needs to be initialized. Execute this SQL snippet:

USE mailconfig;
CREATE TABLE `users` (
  `userid` varchar(128) NOT NULL,
  `domain` varchar(128) NOT NULL,
  `password` varchar(255) NOT NULL,
  `home` varchar(255) NOT NULL,
  `uid` int(11) NOT NULL,
  `gid` int(11) NOT NULL,
  PRIMARY KEY  (`userid`,`domain`)
) DEFAULT CHARSET=utf8 COMMENT='Stores information about active email accounts';

CREATE TABLE `aliases` (
  `source` varchar(255) NOT NULL,
  `destination` varchar(255) NOT NULL,
  PRIMARY KEY  (`source`)
) DEFAULT CHARSET=utf8 COMMENT='Alias one address to another';

CREATE TABLE `domain_aliases` (
  `source` varchar(128) NOT NULL,
  `destination` varchar(128) NOT NULL,
  PRIMARY KEY  (`source`)
) DEFAULT CHARSET=utf8 COMMENT='Alias all addresses in one domain to another';

GRANT SELECT ON mailconfig.users TO 'mailconfig'@'localhost' IDENTIFIED BY 'random-password';
GRANT SELECT ON mailconfig.aliases TO 'mailconfig'@'localhost' IDENTIFIED BY 'random-password';
GRANT SELECT ON mailconfig.domain_aliases TO 'mailconfig'@'localhost' IDENTIFIED BY 'random-password';

Replace random-password by a long squence of random characters. The mailconfig user will only be used by scripts, so you will not need to remember this password.

Incoming mail: Dovecot

Grab dovecot from the repositories:

yum install dovecot

Dovecot is now ready to be configured. Execute the following to configure the Dovecot to allow imap/pop3 access securely with compatibility for some older clients with broken protocol support:

cat << EOF > /etc/dovecot.conf
protocols = imap imaps pop3 pop3s

# Log authentication failures

# Enable me to debug authentication failures
#verbose_ssl = yes

# Maildir storage in [HOME]/[DOMAIN]/mail/[USER]
mail_location = maildir:%h/mail/%d/%n
umask = 0077 # 700 permissions

# for compatability with some older clients
pop3_uidl_format = %08Xu%08Xv
imap_client_workarounds = delay-newmail outlook-idle netscape-eoh
pop3_client_workarounds = outlook-no-nuls oe-ns-eoh

# Increases performance
maildir_copy_with_hardlinks = yes
# Enable SSL/TLS
ssl_disable = no
ssl_cert_file = /etc/pki/dovecot/certs/dovecot.pem
ssl_key_file = /etc/pki/dovecot/private/dovecot.pem
# disable insecure ciphers
ssl_cipher_list = ALL:!LOW:!SSLv2
# Force STARTTLS on non-secure protocols; users *must* have either secure auth
# (CRAM-MD5) or SSL enabled (or both)
disable_plaintext_auth = yes
login_process_per_connection = yes

# Keep username case-sensitive (otherwise delivery to local users with uppercase letters fails
auth_username_format = %u

auth default {
  mechanisms = plain login cram-md5
  passdb sql {
    args = /etc/dovecot-mysql.conf
  # Indicate that we want to prefetch userdb information from the passdb
  userdb prefetch {
  # The userdb below is used only by deliver.
  userdb sql {
    args = /etc/dovecot-mysql.conf
  # Offer SASL auth services
  socket listen {
    client {
      path = /var/run/dovecot/auth-client
      mode = 0660
      user = dovecot
      group  = mail # Postfix running as this user
    master {
      path = /var/run/dovecot/auth-master
      mode = 0660
      user = dovecot
      group = mail # User running deliver
protocol lda {
  postmaster_address = postmaster@yourdomain.com
  auth_socket_path = /var/run/dovecot/auth-master

Once again, substitute yourdomain.com and the SSL certification/key file locations accordingly. You'll notice we reference the non-existent file /etc/dovecot-mysql.conf in this configuration. Let's create that now so Dovecot has access to the MySQL database:

cat << EOF > /etc/dovecot-mysql.conf
# Substitutions: %u = user@domain.com, %n = user, %d = domain.com, L prefix=lowercase
driver = mysql
connect = host=/var/lib/mysql/mysql.sock dbname=mailconfig user=mailconfig password=random-password

# Password lookups with support for user information prefetching:
password_query = SELECT concat(userid, '@', domain) AS user, password, home AS userdb_home, uid AS userdb_uid, gid AS userdb_gid  FROM users  WHERE userid = '%Ln' AND domain = '%Ld'

# For deliver user info lookups:
user_query = SELECT home, uid, gid  FROM users  WHERE userid = '%Ln' AND domain = '%Ld'
chmod 600 /etc/dovecot-mysql.conf

This creates dovecot's MySQL configuration, /etc/dovecot-mysql.conf and sets the correct permissions accordingly so that users can't go snooping for the MySQL credentials. Again, replace random-password with the correct password.

Because the 'deliver' (Dovecot local delivery agent) executable run by Postfix has to deliver mail to different system users, it will need a setuid bit set so that Postfix can run deliver with root privileges, have it change to the appropriate user and then deliver mail to that user's inbox. We can do this easily with two commands:

chown root.mail /usr/libexec/dovecot/deliver
chmod 4750 /usr/libexec/dovecot/deliver

Notice that because the last permission bit is "0", nobody except root or users in the mail group will be able to execute deliver, reducing the security risk introduced by using enabling setuid bit.

Lastly, Dovecot needs to be started and exceptions need to be added to the firewall:

chkconfig dovecot on
service dovecot start
iptables -I RH-Firewall-1-INPUT 4 -m state --state NEW -m tcp -p tcp --dport 110 -j ACCEPT
iptables -I RH-Firewall-1-INPUT 4 -m state --state NEW -m tcp -p tcp --dport 143 -j ACCEPT
iptables -I RH-Firewall-1-INPUT 4 -m state --state NEW -m tcp -p tcp --dport 993 -j ACCEPT
iptables -I RH-Firewall-1-INPUT 4 -m state --state NEW -m tcp -p tcp --dport 995 -j ACCEPT
service iptables save

Outgoing mail: Postfix

Normally we could just install Postfix directly from the repository, however the stock CentOS postfix is provided without MySQL support. We can easily enable this support by rebuilding the RPM:

su - normaluser
yumdownloader --source postfix
rpm -i postfix*src*.rpm
cd rpmbuild/SPECS
sed -i.nomysql 's/%define MYSQL 0/%define MYSQL 1/' postfix.spec
rpmbuild -ba postfix.spec

Then install the generated Postfix RPM (rpm -Uhv ~/rpmbuild/RPMS/[arch]/postfix-[version].[arch].rpm)

Next, we will configure Postfix to accept/relay mail:

cat << EOF > /etc/postfix/main.cf
# Basic settings

# Domain/hostname information
mydomain = yourdomain.com
myhostname = mail.yourdomain.com

# Domains that Postfix will use to deliver mail to system users
mydestination = \$myhostname, localhost.\$mydomain, localhost

# Trusted networks that are always granted relay access
# You may remove if you are not going to use virtual machines
# with libvirt (default/user networking).
mynetworks =,

# Listen on these IP addresses
inet_interfaces = all

# Enter an IP address here and uncomment to get lots of info logged when
# connecting as a client from that IP address.
# debug_peer_list = your.ip.address.here

# File/folder locations
command_directory = /usr/sbin
config_directory = /etc/postfix
daemon_directory = /usr/libexec/postfix
html_directory = no
manpage_directory = /usr/share/man
sample_directory = /usr/share/doc/postfix-2.3.3/samples
queue_directory = /var/spool/postfix
readme_directory = /usr/share/doc/postfix-2.3.3/README_FILES

mailq_path = /usr/bin/mailq.postfix
newaliases_path = /usr/bin/newaliases.postfix
sendmail_path = /usr/sbin/sendmail.postfix

# Security & limits
mail_owner = postfix
setgid_group = postdrop
header_size_limit = 51200

# 30MB message size limit
message_size_limit = 31457280
# Must have 5*30MB free in order to accept mail
queue_minfree = 157286400

# Dovecot LDA & virtual users configuration
dovecot_destination_recipient_limit = 1
virtual_alias_maps = mysql:/etc/postfix/mysql-virtual-alias-maps.cf
virtual_alias_domains = mysql:/etc/postfix/mysql-virtual-alias-domains.cf
virtual_mailbox_domains = mysql:/etc/postfix/mysql-virtual-mailbox-domains.cf
virtual_mailbox_maps = mysql:/etc/postfix/mysql-virtual-mailbox-maps.cf
virtual_transport = dovecot

# Other settings in alphabetical order
alias_maps = hash:/etc/aliases
broken_sasl_auth_clients = yes
parent_domain_matches_subdomains = no
recipient_delimiter = +
smtpd_banner = \$myhostname ESMTP \$mail_name: Unauthorized use of this server, for spam, bulk mail or other purposes, is not permitted.
smtpd_client_connection_count_limit = 20
smtpd_client_connection_rate_limit = 60
smtpd_delay_reject = yes
smtpd_helo_required = yes
smtpd_helo_restrictions = permit_mynetworks, warn_if_reject, reject_non_fqdn_helo_hostname, reject_invalid_hostname
smtpd_recipient_limit = 100
smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_non_fqdn_recipient, reject_unknown_recipient_domain, reject_unauth_destination
smtpd_sasl_auth_enable = yes
smtpd_sasl_path = /var/run/dovecot/auth-client
smtpd_sasl_security_options = noanonymous
smtpd_sasl_type = dovecot
smtpd_sender_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_non_fqdn_sender, reject_unknown_sender_domain
smtpd_tls_auth_only = no
smtpd_tls_cert_file = /etc/pki/tls/certs/yourdomain.com.pem
smtpd_tls_key_file = /etc/pki/tls/private/yourdomain.com.key
smtpd_tls_loglevel = 1
smtpd_tls_security_level = may
smtpd_tls_session_cache_database = btree:/var/spool/postfix/smtpd_tls_cache
smtpd_tls_session_cache_timeout = 3600s
tls_random_source = dev:/dev/urandom
unknown_local_recipient_reject_code = 550

Be sure to substitute yourdomain.com for your actual domain name as well as set the smtpd_tls_cert_file and smtpd_tls_key_file configuration keys appropriately. You can use the cert.pem and private/localhost.key files that get autogenerated for the system, however it is recommended that you generate your own self-signed certificate so that the domain name matches (or even better, purchase validated ones). You may create a self-signed certificate and private key pair by running genkey --days 365 yourdomain.com and then following the on-screen instructions.

The configuration above references several configuration files that do not exist yet, so we need to create them now. A description of what each file does is available in the comments.

cat << EOF > mysql-virtual-alias-domains.cf
# Generates a list of domain names that postfix should accept mail for, but
# that have no real mailboxes (aliases only). See the virtual(5) manpage for
# more information.

# See the mysql_table(5) manpage for information on the format of this file
# and the SQL query.

user = mailconfig
password = random-password
hosts =
dbname = mailconfig
query = SELECT DISTINCT(source) FROM domain_aliases

cat << EOF > mysql-virtual-alias-maps.cf
# This file contains the SQL query for looking up virtual aliases. See the
# virtual(5) manpage for more information. The virtual alias maps allows you
# to forward messages from one address or from a catch-all address to another
# address (or optionally, a script). This file also handles the domain forwarding
# of addresses in one domain to another.

# Caveat: One would expect true domain forwarding to map user accounts as well
# as any existing alias on the source domain to the destination domain.
# For example, consider the use case of virtual user me@domain1.com and alias
# info@domain1.com -> me@domain1.com. After mapping domain2.com to domain1.com,
# one would expect info@domain2.com to map to info@domain1.com which in turn
# maps to me@domain1.com, so info@domain2.com -> me@domain1.com. Implementing
# this type of mapping is rather complex. The commented portion of the query
# below should handle this correctly, however the performance penalties of using
# such a query have not been considered nor tested on a large-scale production
# server. Enable at your own risk.

# See the mysql_table(5) manpage for information on the format of this file
# and the SQL query.

user = mailconfig
password = random-password
hosts =
dbname = mailconfig
query = SELECT destination FROM (
          SELECT concat(users.userid,'@',domain_aliases.source) AS source,
                 concat(users.userid,'@',domain_aliases.destination) AS destination
          FROM users,domain_aliases
          WHERE users.domain=domain_aliases.destination
          SELECT * FROM aliases
          # This commented part is experimental. See above for details.
          #SELECT replace(aliases.source,
          #               concat('@', SUBSTRING_INDEX(aliases.source, '@', -1)),
          #               concat('@', domain_aliases.source)
          #              ) AS source,
          #       aliases.destination
          #FROM aliases,domain_aliases
          #WHERE aliases.source regexp concat('@',domain_aliases.destination,'$')
        ) AS alias WHERE alias.source='%s'

cat << EOF > mysql-virtual-mailbox-domains.cf
# Generates a list of domain names that postfix should accept mail for, but are
# not part of $mydomains as these domains are for virtual user mailboxes only.
# See the virtual(5) manpage for more information.

# Caveat: The SQL query includes the domains listed in the virtual alias map,
# but there's no real good way to tell if a domain listed there has any real
# mailboxes at all without making the queries very expensive. For practical
# purposes though, it has no real effect if it's listed here or in
# virtual_alias_domains so you can rest easy.

# See the mysql_table(5) manpage for information on the format of this file
# and the SQL query.

user = mailconfig
password = random-password
hosts =
dbname = mailconfig
query = SELECT domain FROM (
          SELECT DISTINCT(domain) FROM users
          SELECT DISTINCT(SUBSTRING_INDEX(source, '@', -1)) as domain FROM aliases
        ) virtual_mailbox_domains WHERE domain='%s';

cat << EOF > mysql-virtual-mailbox-maps.cf
# Queries the list of virtual users to determine if a given user exists. See
# the virtual(5) manpage for more information.

# See the mysql_table(5) manpage for information on the format of this file
# and the SQL query.

user = mailconfig
password = random-password
hosts =
dbname = mailconfig
query = SELECT 1 FROM users WHERE concat(userid, '@', domain)='%s'
chmod 600 /etc/postfix/mysql-virtual-*.cf

Postfix now needs to be configured to add support for additional ports (26, 465, 587) as well as to add support for dovecot's local delivery agent (LDA):

cat << EOF >> /etc/postfix/master.cf
# Users can use port 26 as an alternative to 25 if it is blocked by their ISP
26        inet  n       -       n       -       -       smtpd
# Message submission on port 587 and unofficial use of SMTP+SSL/TLS on port 465
587       inet  n       -       n       -       -       smtpd
smtps     inet  n       -       n       -       -       smtpd
  -o smtpd_tls_wrappermode=yes -o smtpd_sasl_auth_enable=yes
# Dovecot LDA, ignores extensions (user+extension@domain.com --> user@domain.com)
dovecot   unix  -       n       n       -       -       pipe
  flags=DRhu user=mail:mail argv=/usr/libexec/dovecot/deliver -f \${sender} -d \${user}@\${nexthop}

Finally, add the firewall port exceptions and start postfix:

chkconfig postfix on
service postfix start
iptables -I RH-Firewall-1-INPUT 4 -m state --state NEW -m tcp -p tcp --dport 25 -j ACCEPT
iptables -I RH-Firewall-1-INPUT 4 -m state --state NEW -m tcp -p tcp --dport 26 -j ACCEPT
iptables -I RH-Firewall-1-INPUT 4 -m state --state NEW -m tcp -p tcp --dport 465 -j ACCEPT
iptables -I RH-Firewall-1-INPUT 4 -m state --state NEW -m tcp -p tcp --dport 587 -j ACCEPT
service iptables save

Spam and virus filtering: amavisd-new

First, install amavisd-new and some dependencies:

yum install amavisd-new spamassassin clamd

In the /etc/amavisd/amavisd.conf configuration file, the following items need to be changed:

  • Set $max_servers to 15
  • Set $mydomain to 'mail.yourdomain.com'
  • Set $virus_admin, $mailfrom_notify_admin, $mailfrom_notify_recip, and $mailfrom_notify_spamadmin each to "postmaster\@$mydomain"
  • Set $final_banned_destiny to D_DISCARD
  • Set $final_bad_header_destiny to D_DISCARD

If you wish to send spam to a certain user (ie spam@yourdomain.com) instead of just discarding it, you may also set the $spam_quarantine_to and $bad_header_quarantine_to options.

Next, Postfix needs to be configured so that it makes use of amavisd's spam/virus filtering:

cat << EOF >> /etc/postfix/main.cf
# amavisd
content_filter = smtp:[]:10024
default_process_limit = 15
cat << EOF >> /etc/postfix/master.cf
# Spam filtering inet n - - - 0 smtpd -o content_filter= -o smtpd_sasl_auth_enable=no

Unfortunately, the default clamd configuration for amavisd contains some errors, preventing the amavisd.clamd service from starting correctly. We need to overwrite it with this configuration:

cat << EOF > /etc/clamd.d/amavisd.conf
# Use system logger.
LogSyslog yes

# Specify the type of syslog messages - please refer to 'man syslog'
# for facility names.
LogFacility LOG_MAIL

# This option allows you to save a process identifier of the listening
# daemon (main thread).
PidFile /var/run/amavisd/clamd.pid

# Remove stale socket after unclean shutdown.
# Default: disabled
FixStaleSocket yes

# Run as a selected user (clamd must be started by root).
User amavis

# Path to a local socket file the daemon will listen on.
LocalSocket /var/spool/amavisd/clamd.sock

Now it is time to enable and start the services.

chkconfig clamd.amavisd on
service clamd.amavisd start
chkconfig amavisd on
service amavisd start
service postfix reload

Response mail autoresponder

Unfortunately, no CentOS 5 RPM package is currently available for the response daemon. I have attached the SRPM to this page which you can download and rebuild:

yum install python26 python26-{devel,sqlalchemy,distribute} python-sphinx
su - regularuser
wget http://www.firewing1.com/sites/default/files/response-0.8-1.src_.rpm
rpmbuild --rebuild response-0.8-1.src.rpm
wget http://www.firewing1.com/sites/default/files/MySQL-python26-1.2.3-1.src_.rpm
rpmbuild --rebuild MySQL-python26-1.2.3-1.src.rpm

Note that we are also rebuilding MySQL-python26, a dependency of response. Simply install the resulting two RPMs after the builds finish.

Before configuring Postfix, we need to create the MySQL tables that will be used to keep information about the autoresponders as well as which users have already been sent an autoresponse within a 24-hour period:

USE mailconfig;
CREATE TABLE `autoresponse_record` (
  `id` int(11) NOT NULL auto_increment,
  `sender_id` int(11) NOT NULL,
  `recipient` varchar(255) NOT NULL,
  `hit` datetime NOT NULL default '0000-00-00 00:00:00',
  `sent` datetime NOT NULL default '0000-00-00 00:00:00',
  PRIMARY KEY  (`id`),
  UNIQUE KEY `sender_id` (`sender_id`,`recipient`),
  CONSTRAINT `autoresponse_sender_refs` FOREIGN KEY (`sender_id`) REFERENCES `autoresponse_config` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='Response - Autoresponse Notification Records';

CREATE TABLE `autoresponse_config` (
  `id` int(11) NOT NULL auto_increment,
  `address` varchar(255) NOT NULL,
  `enabled` tinyint(1) NOT NULL,
  `changed` datetime NOT NULL,
  `expires` datetime NOT NULL,
  `subject` varchar(255) NOT NULL,
  `message` longtext NOT NULL,
  PRIMARY KEY  (`id`),
  UNIQUE KEY `address` (`address`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8 COMMENT='Response - Autoresponse Configurations';
GRANT SELECT ON mailconfig.autoresponse_config TO 'mailresponse'@'localhost' IDENTIFIED BY 'random-password2';
GRANT SELECT,INSERT,UPDATE ON mailconfig.autoresponse_config TO 'mailresponse'@'localhost' IDENTIFIED BY 'random-password2';
GRANT SELECT,INSERT,UPDATE ON mailconfig.autoresponse_record TO 'mailresponse'@'localhost' IDENTIFIED BY 'random-password2';

Similar to before, replace random-password2 with a long and complicated password. You'll never have to remember it, it is simply used internally by the response daemon.

In the next file, you'll notice we use port 10026 because ports 10024 and 10025 have already been taken by amavisd-new.

cat << EOF > /etc/postfix/transport_map_response
response.internal   response:[]:10026
postmap /etc/postfix/transport_map_response

Now we need to inform postfix about the transport map and add the bcc mapping that will forward appropriate messages to the response-lmtpd daemon:

cat << EOF > /etc/postfix/mysql-virtual-autoresponses.cf
# This file controls the bcc map so that users with an autoresponder enabled  
# have messages sent through the response transport in addition to their
# normal transport.
user = mailresponse
password = random-password2
hosts =
dbname = mailconfig
query = SELECT '%u#%d@response.internal' FROM autoresponse_config WHERE address = '%s' AND enabled=1
chmod 600 /etc/postfix/mysql-virtual-autoresponses.cf
cat << EOF >> /etc/postfix/main.cf
# Response autoresponder
response_destination_recipient_limit = 1
transport_maps = hash:/etc/postfix/transport_map_response
recipient_bcc_maps = proxy:mysql:/etc/postfix/mysql-virtual-autoresponses.cf
proxy_read_maps = \$local_recipient_maps \$mydestination \$virtual_alias_maps \$virtual_alias_domains \$virtual_mailbox_maps \$virtual_mailbox_domains \$relay_recipient_maps \$relay_domains \$canonical_maps \$sender_canonical_maps \$recipient_canonical_maps \$relocated_maps \$transport_maps \$mynetworks \$recipient_bcc_maps
cat << EOF >> /etc/postfix/master.cf
# Response autoresponder
response  unix  -       -       n       -       4       lmtp -o disable_dns_lookups=yes
service postfix reload

Of course, you'll need to replace random-password2 in /etc/postfix/mysql-virtual-autoresponses.cf with the random password created earlier for the mailresponse user.

That's it! You can now add a row in the mailconfig.autoresponse_config table to have response-lmtpd automatically reply to any message sent to that user.

Resources and further reading