Janos Pasztor

Filtering spam with Exim and Spamassassin (properly)

For the purpose of this article I am going to assume you are fairly familiar with writing your own Exim configuration and you are also able to set up your SpamAssassin configuration. If you lack either of these abilities, please read up on both topics first.

Setting up SpamAssassin

As usual, I will be using Ubuntu as a platform for my tests, but any Linux should work quite similarly. Once you have installed SpamAssassin (apt-get install spamassassin) you will find a lot of configuration files in /etc/spamassassin. These configuration files regulate how spam filtering is done. I very strongly recommend reading the Mail::SpamAssassin::Conf man page before continuing!

So since you read the manual, you now know that there are a lot of options for allowing user-level rules. Therefore SpamAssassin needs a username to act on. This is one of the reasons you need to set up a more complicated setup than just putting the SpamAssassin server in your Exim configuration.

Once you’re done with fine tuning SpamAssassin to your liking, you need to add some headers to filter by. To do this, add the following lines to local.cf:

always_add_headers 1
report_safe 0
add_header all Report _REPORT_
add_header spam Flag _YESNOCAPS_
add_header all Status _YESNO_, score=_SCORE_ required=_REQD_ tests=_TESTS_ autolearn=_AUTOLEARN_ version=_VERSION_
add_header all Level _STARS(*)_
add_header all Checker-Version SpamAssassin _VERSION_ (_SUBVERSION_) on _HOSTNAME_

This will add the following headers to all your mails passing through SpamAssassin:

    *  0.0 FREEMAIL_FROM Sender email is commonly abused enduser mail provider
    *      (****[at]gmail.com)
    * -0.7 RCVD_IN_DNSWL_LOW RBL: Sender listed at http://www.dnswl.org/, low
    *      trust
    *      [ listed in list.dnswl.org]
    * -0.0 SPF_PASS SPF: sender matches SPF record
    *  0.0 HTML_MESSAGE BODY: HTML included in message
    *  0.0 T_DKIM_INVALID DKIM-Signature header exists but is not valid
X-Spam-Status: No, score=-0.7 required=5.0 tests=FREEMAIL_FROM,HTML_MESSAGE,
    RCVD_IN_DNSWL_LOW,SPF_PASS,T_DKIM_INVALID autolearn=ham version=3.3.2
X-Spam-Checker-Version: SpamAssassin 3.3.2 (2011-06-06) on

As you can see, there’s quite a lot of information in there. This will help you to debug any problems you may have with your spam checker. However, for security reasons I recommend removing these headers when sending e-mails to remote servers (e.g. forwarding mails) in the SMTP transports.

Sending mails to SpamAssassin

So as I mentioned, unless you want to do some frontend baseline filtering for all mails, I recommend completely disabling SpamAssassin checks in Exim itself. This involves removing any SpamAssassin routers and/or the spamd_address configuration option.

Instead we will pipe the mail to SpamAssassin using the spamc client, then piping it back into Exim, effectively creating a loop:

Remote server → [via SMTP] Exim → [via pipe] SpamAssassin → [via pipe] Exim → Mailbox

In order to do this, we add an extra router after any mail forwards, but before any delivery routers:

    driver         = accept
    condition      = ${if and {\
                         {!eq {$received_protocol}{spam-scanned}}\
                     } }
    headers_remove = X-Spam-Flag:X-Spam-Report:X-Spam-Status:X-Spam-Level:X-Spam-Checker-Version
    transport = spam_check

This will send all mail that is smaller than 256k AND hasn’t yet been checked to SpamAssassin for checking. It will also remove any foreign X-Spam-* headers that may have been contained in the mail.

In order to avoid doing double forwards, you should also exclude any routers before the spamcheck router from the looped mails by adding this condition:

condition = ${if !eq {$received_protocol}{spam-scanned}}

Of course we are also going to need a transport for this:

    driver            = pipe
    command           = /usr/sbin/exim -oMr spam-scanned -bS
    transport_filter  = /usr/bin/spamc -u $local_part@$domain
    home_directory    = /tmp
    current_directory = /tmp

As you can see, the message is sent back to Exim, using spamc as a filter in the process. The e-mail address is passed to SpamAssassin as a username to use when looking up per-user configs.

Delivering spam mail

Once this is done, spam delivery is quite simple. However, you need to write your own router and transport. Do not copy the examples here brainlessly, because chances are they won’t work for you.

So you need to duplicate your regular delivery transport and add the following condition to the first copy:

condition = ${if and {\
                {eq {$h_X-Spam-Flag:}{YES}}

You should also change your transport setting to a different name. In my case my router looks like this:

    condition = ${if and {\
                    {eq {$h_X-Spam-Flag:}{YES}}\
                    {eq {${lookup mysql{\
                        SELECT \
                            local_part="${quote_mysql:$local_part}" \
                            AND \
                } }
    driver    = accept
    domains   = +local_domains
    transport = spam_delivery

As I said, don’t copy this!

Delivering into a Maildir

If you have a standard maildir setup, you need to create a similar transport. Again, don’t copy this, write your own.

    driver = appendfile
    maildir_format = true
    directory = /your/maildir/path/.SPAM/
    Other transport options here

Delivering with Dovecot

If you are using the Dovecot LDA for delivery, the setup is slightly different. You need to pass the folder to Dovecot using the -m parameter like this:

     driver            = pipe
     message_prefix    =
     message_suffix    =
     user              = dovecot
     group             = dovecot
     command           = /usr/lib/dovecot/dovecot-lda -d $local_part@$domain -a $original_local_part@$original_domain -f $sender_address -m .Junk
     temp_errors       = 64 : 69 : 70: 71 : 72 : 73 : 74 : 75 : 78

Testing the whole setup

Spamassassin has a test string, so if you wish to test your spam delivery, simply send an e-mail with this test string in it:


Frequently Asked Questions

I added spam filtering, suddenly everything is broken!

You may have made an error with the configuration. Please be sure to test all changes in a development environment first, don’t go about editing your production server’s configuration.

Why can’t I use SMTP-time (Exim) spam filtering with per-user configuration?

It is due to how SMTP works. When delivering the same mail to several recipients (e.g. CC’d mail), the mail is only delivered once per server, sending multiple RCPT TO commands to deliver the mail. The DATA ACL is only run once per such a mail, so you can’t really filter on a per-user basis, that’s why you need the pipe transport, because it splits the mail into per-user instances.

I still have questions open

Exim is a complicated topic and requires a lot of learning. You can’t just go about copying someone else’s code brainlessly because there is a high probability it simply won’t work or even worse, cause a bug you didn’t anticipate. You really need to understand what your configuration does. If you need more help with Exim, read my Big Exim Tutorial.

Janos Pasztor

I'm a DevOps engineer with a strong background in both backend development and operations, with a history of hosting and delivering content.

I run an active DevOps and development community on Discord, and if you like what I do and would like me to do more, you can also support me on Patreon.

Support me on


Join the community



Facebook Facebook Twitter Twitter GitHub GitHub
YouTube YouTube RSS Atom Feed
Do you want more? Click the buttons below!