Minimal Mail Setup
Few years back, I decided to move my personal mail out of the cloud providers. Although, it is technically possible, those few attempts, that I had performed in the past, miserably failed, due to complexity and maintenance overhead requiring to support the required infrastructure and configuration. However, today I think I found that simple, maintainable and moreover arguably secure layout for my personal mail. In this article I will be discussing what does it mean to do emails on Linux (possibly applicable to other Unix-like environments) and my current setup.
Note of Precaution
This article will be applicable to those who are more or less familiar with Linux and/or other Unix-like environments. Here, by saying familiar, I mean that, user has enough experience and knowledge to operate with process, file systems, connections, private keys etc. It is definitely not for an everyday average user, thus I intentionally will stay away from saying "... here is my config use it like ...". Instead, I will be explaining why some piece in the picture are needed and providing snippets on here, how it can be achieved. One, who decides to implement similar setup, should be capable to collect pieces of the puzzle into whole. If you are not one of such people, just stay away from this for your own safety, at least you may screw/loose all your own emails, at worst you may harm others. Information provided here, for learning purposes only. OK, enough precautions, let's dive…
Why
Since I first started interacting with computer and computer systems, I had chance to deal with numerous mail servers, mail clients and various tools around them, both in Unix-like and Dos-like (including Windows) environments. Some of them are Sendmail (on very first versions of Linux), Procmail, Postfix, MDaemon (Windows-based server), Lotus Domino, MS Exchange of various versions, The Bat (Windows-based popular clients), Firefox Thunderbird, MS Outlooks, numerous cloud based mail service providers etc. List will go on and on. Unfortunately, in the beginning I was spending more time in Dos/Windows environments, rather than Unix-like environments for various reasons. However, last 6-7 years at least, I do spend almost all of my time on Linux and other Unix-like environments. This could become possible only after I left Windows-based mail client, for both my personal and business life. Before that transition, I was bouncing between happiness of "oh man, my mail setup (server/client) works nice" and "here is your money, Cloud, just take this pain away". Horrible moments…
With time, I realised that such struggle is caused by lack of understanding how mail is working and what we really want from it. While there was no lack of understanding how mail is working, formulating the requirements and expectations was a problem for me.
In this article I will be explaining what requirements I formulated for myself, and how I achieved to meet them.
Goals
High level goals for my mail setup I set as following:
- Must be minimal, i.e. use as less programs, modules, protocols as possible, re-use anything useful without introducing more complexity
- Must be maintainable, i.e. I should not spend tons of time to configure, backup and support my systems
- Must be local, i.e. single source of truth for my mails should be one local machine of my choice, backed up by me personally
- Should be secure as much as possible according to my understanding
- Should be easy to use as final user
For the one reading this, I should mention that making my mails accessible 24/7, from all devices and from all places is NOT among my goals. I don't want to spend all my free time staring on the screen of my phone, tablet or what's so ever device I may use. New mail in my mail box, does not signify that, I have to drop everything I do at the moment and start reading it, thinking on it, and/or taking actions on it. It just says that, here is new information arrived from the world. I want to be free to choose my time, which I will spend on processing this information. Thus, I deliberately choose not know what mails are arriving into my mail box, while I'm sleeping, eating, reading or doing some other thing.
Suppose, you are on vacation, and you received worst mail in your life, on which you won't be able to take action on straight away. Basically, you will loose peace of mind, and your vacation will be ruined. Normally we do not receive super urgent mail requiring immediate actions from people we don't know. In case of emergency, people we know will find a way to may contact.
Inputs and Requirements
Here is small picture, which illustrates the big picture, that we should keep in mind all the time.
digraph big_picture { rankdir = LR; bgcolor = transparent; internet -> MTA [label = "SMTP"]; internet -> MDA [dir = back, label = "SMTP"]; MTA -> store; store -> MUA [dir = back]; MDA -> MUA [dir = back, label = "SMTP"]; store [shape = cylinder]; internet [shape = plaintext]; MUA [shape = circle]; MTA [shape = rect]; MDA [shape = rect]; }
Figure above, illustrates very basic, functions and protocol level connections between them. Surprise, surprise, there is no POP3
or IMAP
as you may see, just because they are totally optional if one decides to simplify things.
SMTP (Simple Mail Transfer Protocol)
Since mails are being sent over the wire, nodes on both ends of such wire should be able to understand each other, using some common protocol. In our context, most important protocol with SMTP. It is very old, and very simple protocol, having very few commands. We will be interested in even less commands:
MAIL FROM:<somebody-else@other-domain.net> RCPT TO:<me@staging.muradm.net> DATA Hello, this is my first e-mail to you. .
Above is very simplified data, sent from some SMTP client to some SMTP server, where:
MAIL
is a command with single argumentFROM
and its value<somebody-else@other-domain.net>
, with this command, client informs server that, mail is coming from specified address.RCPT
is a command with single argumentTO
and its value<me@staging.muradm.net>
, with this command, client informs server that, mail being submitted should be delivered to specified address.DATA
is a command which delimits the start of actual message body, which is terminated by the final line containing single.
(dot) character.
This is all we are interested in for now. Of course there are few more commands, and potentially more arguments which are not illustrated. We are not going to focus on them. What is important for us, is flow of messages, which are identified by submitting client, receiving server, MAIL
, RCPT
commands and their arguments sent from client to server.
MTA (Message Transfer Agent)
Responsible for answering on SMTP
port 25 facing to the global internet, for receiving mails from other nodes. I.e. if my mail address is me@staging.muradm.net, server that serves MTA
function for me, will be receiving mails in the form:
MAIL FROM:<somebody-else@other-domain.net> RCPT TO:<me@staging.muradm.net>
Where, FROM
argument could hold address of any sender that wish to send me mail, and TO
argument must be only my mail address.
MDA (Message Delivery Agent)
Responsible for finding right MTA
server address for mail on hands, establishing a client connection to it, and submitting the mail over SMTP
protocol. MDA
server, which is serving me, will be connecting to resolved MTA
server and sending mails in the form:
MAIL FROM:<me@staging.muradm.net> RCPT TO:<somebody-else@other-domain.net>
Where, FROM
argument can be only my mail address, and TO
argument could hold any address I'm sending mail to.
Message Flow Cases
From the above simple illustration, we can conclude that, technically, there are eight possible traffic cases:
[1] MTA - FROM:<someone> TO:<me>
[2] MTA - FROM:<me> TO:<someone>
[3] MTA - FROM:<someone> TO:<someone>
[4] MTA - FROM:<me> TO:<me>
[5] MDA - FROM:<someone> TO:<me>
[6] MDA - FROM:<me> TO:<someone>
[7] MDA - FROM:<someone> TO:<someone>
[8] MDA - FROM:<me> TO:<me>
However, practically, we require only 3 of them:
[1] MTA - FROM:<someone> TO:<me>
[6] MDA - FROM:<me> TO:<someone>
[8] MDA - FROM:<me> TO:<me>
While cases [1] and [6] clear, last case [8] could be a bit confusing. Here, me denotes as my own address, as well as any my additional address I would like to serve. For instance, my personal address could be <me@staging.muradm.net>
and my blog address could be blog@staging.muradm.net>
, which I could use in my communications related to my blog.
We should keep these cases in mind all the time while crafting our mail setup.
Store and Interfaces
Sender sending mail to me, and receiver of mail I'm sending to are totally unaware of how do I store and access my mails. Thus, in picture it is single component, which could be as complex as distributed system interfacing with cloud object storage, or as simple as directory on my server. In our context we will be making it as simple as possible.
There are a number of different storage formats that could be used to store mails. As far as I am aware, two of them are most popular/standard:
maildir
- stores each mail in separate file, moreover it specifies a sequence of operations, that should be preformed to in order to add mail to store, and some additional useful flagsmbox
- stores all mails in one file, good for storing or out of the band transferring of group of mails, but definitely not all my emails in one file
maildir
format is recognised by many tools and quite reliable, thus it is my choice of preference for now.
One of the most important aspects of message delivery, is verifying existence of mail addresses. While researching the subject, I saw that everybody relays on a concept of one mail address belongs to one or a number of users. Once it is formulated that way, it means at least two things:
- We have to maintain list of or system that maintains list of users
- We have to validate mail address against that list or query the system that maintains that list
Both tasks are explained in details everywhere, both tasks adds extra complexity in maintaining the list of users and/or additional system/program that maintains that list, which is a bit against of my goals.
Instead, I reformulated mail address existence verification and delegated it to the storage, in the way that, from received message we definitely know which directory it should be stored in. Then my verification process will query the storage and ensure that destination directory exists, if so message will be accepted, other wise it should be rejected. Creation of new mail addresses becomes as easy as creating directories on the file system.
I was able to implement this functionality using various mail server programs, in my previous attempts to setup mail system. This solves tons of problems, and simplifies design and maintenance drastically.
Authentication
The elephant in the room, which is omitted from above picture completely, but not without a reason. If we lookup documentation of any mail server program, especially authentication part, we will find so many ways to authenticate. As with mail address existence verification, mentioned in before, I found an alternative way of authentication, re-using existing mechanisms, without adding complexity.
In attempts to clean up the mail traffic, today additional mechanism are used to authenticate mail messages, transferred between mail servers, which are SPF
, DKIM
, relatively new addition to them, DMARC
. In parallel to these standard ones, some global providers introduce their own proprietary mechanisms, but the basic idea is the same, ensure authenticity of mail message being transferred. And here we see the common word here, which is authentication. Can we re-use it? Seems so, and here is how.
For authentication purposes, we will be using (or abusing some may say) DKIM
, which is expanding as Domain Keys Identified Mail. Basically, it is applied as following:
- [1] private key is generated and stored on mail server carrying out
MDA
function - [2] public key of that private key is published as special
TXT
record in theDNS
system under[selector]._domainkey.staging.muradm.net
- [3] user submits message to serving
MDA
for delivery - [4] as per configuration
MDA
selects some data from message and hashes it with private key generated on step [1] and adds it to the message being delivered inDKIM-Signature
header which also includes value of[selector]
field which may change in time if private key is changed - [5]
MDA
attempts to submit the message to the destinationMTA
- [6] receiving
MTA
server queriesDNS
system to fetch public key - [7] receiving
MTA
server using fetched public key validates authenticity ofDKIM-Signature
header - [8] if validation successful,
MTA
accepts message for delivery
The process above might not be accurate or complete, provided here just for illustration purposes, for very technical details, one should lookup official specifications and/or related guides on the subject. With this process, it is supposed that, mail message received by MTA
can be validated to be correct, by adding complexity and extra credentials to manage (additional private key).
There are three components private key, published public key and [selector]
field which identifies which published key to use for verification. We can map these to:
[selector]
- identifies the private key holding user- private key - holding by the user
- public key - published, so that others can validate signatures created by this private key
Thus, we will move creation of DKIM-Signature
from mail server functioning as MDA
, to our local machine. Then we can provide [selector]._domainkey.staging.muradm.net
record for every user who is able to submit messages through our mail server. As the result we will solve two problems using one mechanism:
- authenticate our users how are allowed to submit messages for delivery
- provide message authenticity as required by
DKIM
There are pros and cons to this approach, that one should keep in mind:
- [pros] private key never lives user machine
- [pros] no need to maintain additional database of users with additional credentials
- [pros] with
DKIM-Signature
header, mail authenticity tracked down to specific sender, evenGPG
signature becomes unnecessary - [cons] potentially we are advertising number of users via
DNS
(this could be mitigated by obfuscated[selector]
, multiple private keys shuffled etc.) - [cons] extra load provided on
DNS
with public keys
In addition to this, we will be submitting messages to our mail server via SSH
tunnel, without exposing SMTP
submission port. This will be adding additional level of security, which one may optionally implement or not.
Software Components
There are many programs that implement SMTP
protocol in some or another way. However, there are two most widely used SMTP
serving programs:
While both of them are great pieces of software, I found exim
be more simple, serving my minimal goals, in the way that:
- I could run it as single process
- I could use it both as
SMTP
server andSMTP
client
Minimal Mail Setup Design
Considering all above, my minimal mail setup looks like following:
digraph minimal_mail_setup { bgcolor = transparent; subgraph cluster_virtual_server { label = "Virtual Server"; MTA_MDA -> transient_store [label = "local\nfile\naccess"]; } subgraph cluster_local_machine { label = "Local Machine"; labelloc = b; MTA_MDA -> sendmail [dir = back, label = "SMTP over SSH"]; sendmail -> MUA [dir = back, label = "execute\ncommand"]; permanent_store -> MUA [dir = back, label = "local\nfile\naccess"]; } internet -> MTA_MDA [label = "SMTP", dir = both]; transient_store -> permanent_store [label = "rsync over SSH"]; transient_store [shape = cylinder, label = "transient\nmaildir\n/var/mail"]; permanent_store [shape = cylinder, label = "permanent\nmaildir\n~/.private/mail"]; internet [shape = plaintext]; MUA [shape = circle]; MTA_MDA [shape = rect, label = "exim server\nMTA/MDA"]; sendmail [shape = rect, label = "exim mua-wrapper\nsendmail\ncommand"]; }
Covering Required Message Flow Cases
When starting from scratch, first thing to do, is to make sure that messages at least flowing. exim
has quite complex configuration syntax, but basically it drills down do three things, which are router
, transport
and acl
. When message arrives over SMTP
to exim
, using routers
it decides which transport
should be used to deliver the message.
Considering our goals, here is the sample router, that we may use to select transport in exim
:
begin routers # SKIPPED local: driver = accept domains = +local_domains require_files = /var/mail/$domain/$local_part/ transport = localdir
Very simple and easy to understand, basically saying that, we accept mail messages for local addresses, domain part of mail address in the list of local_domains
and /var/mail/$domain/$local_part/
directory exists. For some address like <me@staging.muradm.net>
, this means that, router
named local will check existence of /var/mail/staging.muradm.net/me/
directory. Does this work? For exim
version before 4.94
it could, but for 4.94
it fails, while it works at the stage of router
computation, it fails later in transport
. exim
on version 4.94
introduced tainted data thing, where variables filled with data from parsing the incoming mail message cannot be used directly to form destination of this message. To solve this we can use dsearch
lookup mechanism (note that, your exim
should have compiled support for it, i.e. LOOKUP_DSEARCH=yes
should be set in Local/Makefile
). Here is improved working snipped:
begin routers # SKIPPED local: driver = accept # we will serve mail addresses if there is a directory # named after it domain part under /var/mail directory domains = dsearch,filter=subdir;/var/mail # we will accept an optional suffix after local part # so that me/some-topic@my-domain.com is a valid mail address local_part_suffix = /* local_part_suffix_optional # our local part of mail address is valid only if # there is a directory named after it under directory # named after domain part local_parts = \ ${lookup {$local_part} dsearch,filter=subdir {/var/mail/$domain_data}} # we will accept this message if and only if there is # a destination directory for it require_files = /var/mail/$domain_data/$local_part_data/$local_part_suffix_v/ # once destination directory is validated for existence # we are preparing untainted version of destination for # use in transport and save it as address_data address_data = ${if eq{$local_part_suffix_v}{} \ {${lookup {$local_part_data} \ dsearch,filter=subdir,ret=full \ {/var/mail/$domain_data}}} \ {${lookup {$local_part_suffix_v} \ dsearch,filter=subdir,ret=full \ {/var/mail/$domain_data/$local_part_data}}} \ } # we will deliver it using transport named localdir transport = localdir
While it looks very crowded, due to configuration syntax, it is very simple in action, and also avoids use of global list maintenance like local_domains
. Once I introduced dsearch
lookup, I decided to add support for delivering into sub-directory based on optional suffix, which is very useful feature. Again, mail will not be accepted for any arbitrary directory, but only for the one is already created.
Now, as per exim
, we need transport
named localdir
, where we are going to store received mail messages. As noted before, my choice was to store messages in maildir
format. By default, maildir
support in exim
packaged for my Linux distribution, just like dsearch
support was missing. To enable it, exim
should be compiled with SUPPORT_MAILDIR=yes
set in Local/Makefile
. Here is transport, which delivers mail messages to maildir
formatted directory.
begin transports # SKIPPED localdir: driver = appendfile directory = $address_data maildir_format = true
appendfile
transport driver, stores messages locally, while maildir_format = true
specifies that it should be locally in maildir
format. Our destination directory is already computed by our router and provided in variable named $address_data
.
Finally, exim
requires us to have ACLs (Access Control Lists). The simplest form to make our basic flow working, can be as following:
# we will use diffrerent rules for incoming and outgoing # messages based on the port value of connection established acl_smtp_rcpt = ${if ={25}{$interface_port} \ {acl_check_rcpt} \ {acl_check_rcpt_submit} \ } begin acl # rules for incoming messages acl_check_rcpt: # again our address domain part should exist as directory deny sender_domains = dsearch,filter=subdir;/var/mail # sender's domain part should not exist as directory deny domains = ! dsearch,filter=subdir;/var/mail require verify = sender require verify = recipient accept # rules for outgoing messages acl_check_rcpt_submit: # domain part to whom we are sending should not exist deny sender_domains = ! dsearch,filter=subdir;/var/mail # our domain part should exist as directory deny sender_domains = dsearch,filter=subdir;/var/mail require verify = sender require verify = recipient # if one decides to add aditional level of protection # by submittion own mail over ssh, accepting can be # restricted to from localhost only # accept hosts = 127.0.0.1/8 accept
With this, we are covering our required message flows. At this point we can test and validate that exim
accepts only mail address combinations as we outlined before in Message Flow Cases.
Accessing Mail Store
Now that, our mails are flowing, we can observe new mail messages are being written to /var/mail/staging.muradm.net/me/
. Let's look at what is inside:
# find /var/mail/staging.muradm.net/me/ /var/mail/staging.muradm.net/me/ /var/mail/staging.muradm.net/me/tmp /var/mail/staging.muradm.net/me/new /var/mail/staging.muradm.net/me/new/1631303258.H136013P19212.localhost /var/mail/staging.muradm.net/me/new/1631303130.H958762P19203.localhost /var/mail/staging.muradm.net/me/cur #
We see three sub-directories, and two messages in sub-directory new
. This is how maildir
is working. Under each target directory, three directories are being created by exim
:
tmp
- directory whereexim
writes message when deliveringnew
- once write operation is complete, thus message is fully written to disk, it is being moved here, this is need in order to prevent readers access messages which are in the process of being writtencur
- tools, after successful presentation of message to the user, are moving it under this directory
But, this is on remote virtual server, how do we access them? Idea is very simple, if we use same maildir
format to store messages on our local machine, the directory layout will be similar to the one on the server. According to maildir
specification, file names of every message is generated in unique manner. So what is left is to move message from server to local machine. Which can be achieved with simple rsync
over SSH:
rsync -avz \ --remove-source-files \ --exclude=*temp* \ our-mail-server:/var/mail/staging.muradm.net/me/ \ ~/.private/mail/me/
Provided that, you are accessing remote mail server over SSH using public key authentication, it should just work. And our "mail checking" or "mail retreival" procedure is becomes as simple as periodically run this command either manually, or using some periodic job scheduler. The following should be noted:
--remove-source-files
- will remove the files copied from the server--exclude=*temp*
- will exclude files having "temp" in their name, we don't wont to receive partial messages which are in the progress of being written byexim
This way, we are keeping our remote footprint as minimal as possible. In the event of disaster on remote server, we might loose at most only messages we didn't move to local machine.
One should note that, file names generated according to maildir
format, supposed to be unique. This will protect us from overwriting any message being moved to local machine.
When all messages are stored locally, we are free to arrange our backup strategy the way we want. All responsibility to manage our messages now lays on our shoulders.
Adding Authentication
As explained previously, we will be using DKIM
for both authentication of users, and mail authenticity verification. Here again, exim
comes to be very handy program. It has special mode where it is able to operate as command line tool to submit messages via SMTP
, called mua_wrapper
. This makes our life very much easier, since exim
supports DKIM
out of the box.
Since we are already familiar with exim
configuration, figuring out way to use it on client side is not very hard task. Basically, we need one router
and one transport
without any ACLs in our configuration. router
should pick the transport
and transport
will be deliver the message provided via standard input.
begin routers remote_delivery: driver = manualroute # we already validate sender/recipient on our mail server # just delegate this function to it, all messages to be # submitted there route_list = * mail.staging.muradm.net::587 # if one uses suffix or prefix, it should be known to # exim client as well for proper $sender_address_local_part local_part_suffix = /* local_part_suffix_optional # using "from" address, we are picking which private key # to use for creating DKIM-Signature header # again in order to avoid tainted data, we are proving # its existence using dsearch address_data = ${lookup {$sender_address_local_part@$sender_address_domain-dkim.pem} \ dsearch,filter=file,ret=full \ {/home/muradm/.private/mail}} # finally pick transport transport = remote_forward
exim
signs message in transport
, when it delivers message to receiving mail server. In router
, we will be leveraging from similar technique to pick the right primary key and store its absolute path in address_data
. Here I'm using a number of mail addresses, for each I'm using separate private key for DKIM-Signature
. But logic in picking private key can be different.
Once we have all necessary input, we can add transport
:
begin transports remote_forward: driver = smtp dkim_selector = $sender_address_local_part dkim_domain = $sender_address_domain dkim_private_key = $address_data dkim_hash = sha256 dkim_canon = relaxed dkim_strict = true
Very simple and straight forward. This way, exim
will sign every message and add DKIM-Signature
header for every message before submitting to our mail server.
In addition to that, one should be careful with exim_user
and exim_group
configurations when running exim
as command line client.
Now let's go back to the mail server configuration. By default exim
validates DKIM-Signature
out of the box, but it does not decide whether to accept or reject messages with absent or invalid DKIM-Signature
. Since we are using DKIM-Signature
for authentication purposes also, we need to add extra ACLs to make decision.
# we might need different treatment of DKIM # for incoming and outgoing messages # note that this acl only applied # if DKIM-Signature is present acl_smtp_dkim = ${if ={25}{$interface_port} \ {acl_dkim} \ {acl_dkim_submit} \ } # DKIM acls work only when DKIM-Signature is present # in case of absence we have to validate it manually # which can be done at DATA stage acl_smtp_data = ${if ={25}{$interface_port} \ {acl_check_data} \ {acl_check_data_submit} \ } # SKIPPED begin acl # SKIPPED acl_dkim: # let's reject all incoming mail messages # submitted to us with missing or invalid # DKIM-Signature deny dkim_status = none:invalid:fail accept acl_dkim_submit: # we definitely must reject outgoing messages # submitted by us from our local machine if # DKIM-Signature is missing or invalid deny dkim_status = none:invalid:fail accept acl_check_data: # here we don't require public incoming messages to # have DKIM-Signature, but it is possible in the same # way as for our outgoing messages below accept acl_check_data_submit: # deny our outgoing messages # if there is no DKIM-Signature header deny condition = ${if eq{$h_DKIM-Signature:}{} {yes}{no}} accept
Now we have DKIM
based authentication and protection in our mail server.
Conclusion
Setting up mail system is not very complex task, once one understands how does it work and what to expect from it. Method of configuration, provided here, is for illustration purposes, to show how minimal could mail setup be, using non-common, alternative approaches.
We didn't mention few important topics here, which could complete the picture:
STARTTLS
we didn't discuss at all, because it is trivial task to automate certificate acquisition using tools like Let's Encrypt and itscertbot
- we didn't cover SPAM filtering, that is subject for another article
MUA
tools to use by user for managing their mail, there are tons of choices, once we have our mails properly stored on local machine, it should be trivial to configure indexers likemu
or ~notmuch to index emails and use clients like GNU Emacs or mutt, however their configuration will require additional article
In case if you really need to check new mails from your phone, user could easily write small script that will aggregate the contents of /var/mail/
and provide some additional web interface tool to view them, or probably use that phone to login server via ssh
using termux for example.