Table-driven mail delivery


1. Design

We wish to build a mail server where the users do not each have entries in /etc/passwd. Not only does this keep the password file small, but it should make our system more secure. Also, some Unixes have a limit of 65,535 entries in /etc/passwd.

All our mail directories will be owned by one user (exim). The instructions for where to deliver mail will be held in a database file, which says how to deliver mail for a given incoming address - either to store it in a Maildir, or to forward it elsewhere.

At this stage, we will also introduce quotas. Quotas ensure that users cannot use more than a certain amount of disk space - those users who have set 'leave mail on server' will eventually have to download old mail to make space for new messages. We will send them a warning to let them know.

Actually, we require three database files. One is used to configure local_domains; keeping this in a database rather than directly in the configure file lets us scale to thousands of domains efficiently. The second is used to map actual E-mail addresses to the physical directory location on the disk. The third maps the physical directory to a quota value, although this can be set as a global default and overridden for "special cases" only.

This fully "table-driven" approach gives a lot of flexibility, if you are happy to maintain these database files!

2. Create the mail spool and database files

The mail spool is easy. Let's choose /u/mail (our /u partition has lots of space), and change its ownership so that only exim can access it. Exim will create subdirectories under here when it needs to.

# mkdir /u/mail
# chown exim:exim /u/mail
# chmod 700 /u/mail

Now we need to create the three database files.

# cd /usr/exim
# vi vdomains

*.linnet.org

# vi valiases

*@fred.linnet.org:   /u/mail/00/fred/
*@jim.linnet.org:    /u/mail/01/jim/

# vi mquota

/u/mail/00/fred/:        25000000

# cd /usr/exim
# bin/exim_dbmbuild vdomains vdomains.db
1 entry written
# bin/exim_dbmbuild valiases valiases.db
2 entries written
# bin/exim_dbmbuild mquota mquota.db
1 entry written

(In a production environment you would write a script to do the above commands). Check that the appropriate files have been created:

# ls /usr/exim
bin             mquota          valiases        vdomains
configure       mquota.db       valiases.db     vdomains.db

3. Test the configuration

Copy the new configure file to /usr/exim/configure-mx, then test it is doing database lookups correctly, as well as still being able to deliver remotely.

# exim -C /usr/exim/configure-mx -bt root@fred.linnet.org
root@fred.linnet.org -> /u/mail/00/fred/
# exim -C /usr/exim/configure-mx -bt root@jim.linnet.org
root@jim.linnet.org -> /u/mail/01/jim/
# exim -C /usr/exim/configure-mx -bt nobody@nsrc.org
nobody@nsrc.org
  deliver to nobody@nsrc.org
  router = lookuphost, transport = remote_smtp
  host psg.com [147.28.0.62] MX=10

Now deliver a message. We will turn on a little debugging (-d1) so you can see some of what is happening.

# exim -C /usr/exim/configure-mx -v -d1 root@fred.linnet.org
Exim version 3.22 debug level 1 uid=0 gid=0
probably Berkeley DB version 1.8x (native mode)
Subject: test

test message
[Ctrl-D]
LOG: 0 MAIN
  <= root@noc.ws.afnog.org U=root P=local S=329
bash# delivering message 14wB9p-000CJJ-00
LOG: 0 MAIN
  => /u/mail/00/fred/ <root@fred.linnet.org> D=valiases T=maildir_internal
LOG: 0 MAIN
  Completed
# ls -lR /u/mail/00/fred
total 3
drwx------  2 exim  wheel  512 May  5 23:08 cur
drwx------  2 exim  wheel  512 May  5 23:08 new
drwx------  2 exim  wheel  512 May  5 23:08 tmp

/u/mail/00/fred/cur:

/u/mail/00/fred/new:
total 1
-rw-------  1 exim  exim  400 May  5 23:08 989104129.47327.noc.ws.afnog.org,S=400

/u/mail/00/fred/tmp:

You can see that the Maildir and its subdirectories have been created, and the message has been delivered; use 'cat' to read it. Try using higher debug levels (-d2...-d9) for more information about exim internal operation.


4. Notes on the configuration

Database lookups have been introduced in this configuration.

local_domains = partial2-dbm;/usr/exim/vdomains.db : localhost : @

The domain part of the address is checked by doing a dbm lookup in vdomains.db, or if it is not there, see if it matches the string "localhost" or the hostname of this mailserver itself. "partial2-dbm" is a dbm lookup which allows partial matches on domains. For example, when looking up "mx-1.mail.example.com" the following lookups are attempted:

mx-1.mail.example.com
*.mx-1.mail.example.com
*.mail.example.com
*.example.com

The '2' means that at least two domain portions must remain, so that '*.com' does not match for example.

Another dbm lookup is done in the valiases director, this time using the aliasfile's lookup facility (search_type and file). The lookup type here is dbm*@, meaning "lookup in the dbm file, and if there is no match, replace everything to the left of @ with *".

valiases:
  driver = aliasfile
  search_type = dbm*@
  file = /usr/exim/valiases.db
  include_domain
  qualify_preserve_domain

  # For alias entries /which/look/like/this/, we deliver directly to a
  # Maildir at this location. Hide this path in any bounce messages though.
  directory_transport = maildir_internal
  hide_child_in_errmsg

Finally, there is a database lookup in the transport which drops the message into a maildir. We set the value of the 'quota' attribute using an expansion string which uses the ${lookup} operator, whose syntax for this type of lookup is ${lookup{key}type{file}{matchval}{failval}}

maildir_internal:
...
  maildir_tag = ,S=$message_size
  quota_size_regex = ,S=(\d+)

  # Quota handling
  quota = ${lookup{$address_file}dbm{/usr/exim/mquota.db}{$value}{DEFQUOTA}}

This last example gives an idea of how flexible Exim is, because there are many places in the configuration where expansion strings like this can be used to look up information from elsewhere.

We have also introduced a trick for efficient quota handling. 'maildir_tag' makes Exim encode the size of the file within the filename, as ,S=nnn, and 'quota_size_regex' tells Exim how to retrieve it from the filename. This saves Exim a lot of work when calculating quotas; it just has to do a readdir() to list the directory contents, without having to do a stat() on every single file to get its size. This only works because the users do not have accounts on the box (otherwise, they would be able to rename the files to get round the quota limits)