SpamAssassin in Postfix

This article is not an endorsement of SpamAssassin. It is the content-based filtering solution I personally use but I am not convinced it is the best. The problem is it is very popular, so professional spammers often adjust their spam in an attempt to outsmart SpamAssassin specifically. This results in a constant whack-a-mole with the filtering rules. There very well may be better solutions out there that are not as popular. That being said, it does work fairly well.

Originally I was not going to write any instructions on SpamAssassin as it is not really related to LibreSSL. However after looking for a decent third-party write-up on the topic I could link to, I was very sadly disappointed. It appears that the search engine top results spent their money on increasing their search ranking rather than on paying knowledgeable authors what they deserve to write for them. The result is many tutorials written by novices who I personally do not believe understand some of the fundamental concepts involved.

You can easily tell many of the tutorials were written by Ubuntu users by the frequent inappropriate use of the sudo command.

The instructions presented here are intended to be simple yet sane instructions for integrating SpamAssassin into the Postfix server. I also attempt to explain why it is the right way, so you can adjust with intelligence if you disagree with my reasoning.


SpamAssassin Installation

To install SpamAssassin, run the following command:

yum install spamassassin

Unless you already have a lot of perl modules installed, yum will probably have a long list of perl modules it wants to install. Many of those modules are base modules that ordinarily would be on a system with perl installed from source, the large list is not indicative of bloat on the part of SpamAssassin. It is just a symptom of the way RHEL/CentOS packages perl.

Post Install Setup

By default, when SpamAssassin identifies a message as spam, it will modify the Subject and place a [SPAM] at the beginning. Personally I do not like that practice, I consider the message Subject header to be sacred. It should not be altered from what it was when it arrived at the mail server.

Edit the file /etc/mail/spamassassin/local.cf and comment out the line that reads:

rewrite_header Subject [SPAM]

SpamAssassin will still create the various X-Spam-* headers needed for client filters, but the actual e-mail Subject will not be changed. This is important for cases where SpamAssassin mis-identifies a message that is not spam that a user needs to send a reply to.

Next, we need to configure the SpamAssassin daemon to start at system boot, and start it:

systemctl enable spamassassin.service
systemctl start spamassassin.service

Spam Definition Updates

This is important, I have personally seen SpamAssassin become less effective over time simply because the spam definitions were never updated.

To ensure they are updated regularly, create the following shell script as /etc/cron.weekly/sa-update.sh

#!/bin/bash

/usr/bin/systemctl status spamassassin.service > /dev/null 2>&1
if [ $? -eq 0 ]; then
  umask 022
  /usr/bin/sa-update > /dev/null 2>&1
  /usr/bin/systemctl restart spamassassin.service > /dev/null 2>&1
fi
#End Of Script

Make sure the script is executable:

chmod +x /etc/cron.weekly/sa-update.sh

The only reason I do not include the above script in the SpamAssassin RPM packaged here is because it makes an external connection, and it is bad form for a package to include an automated cron daemon script that makes external connections.

Once a week, as long as the daemon is running, the script will automatically update the spam definitions for you. Assuming you started the daemon in the previous sub-section, go ahead and run the script manually now:

sh /etc/cron.weekly/sa-update.sh

For the paranoid, there are some options to the /usr/bin/sa-update command you may want to add to the above script. See the man 1 sa-update documentation for details.

User and Group

There are many ways that SpamAssassin can be used. To integrate it into Postfix, we will need to run the daemon. Postfix can call the daemon directly as the user nobody but that method can be problematic, it can result in messages that are not filtered if the daemon is temporarily unavailable.

It is better to create a shell script that Postfix calls to acts as a wrapper between Postfix and the SpamAssassin daemon. If the daemon is temporarily unavailable, the shell script can then instruct Postfix to defer the message so it is checked at a future point in time rather than being delivered unchecked. The downside is that the shell script will have to write the e-mail message to a file and then read it from that file to pass it back to Postfix for delivery.

Many tutorials recommend using /tmp for this, and in theory that works fine. However it does create the possibility of a race condition vulnerability. It really is best to create a specific directory used by the script, and a specific user that can write inside that directory. That protects the spam filtering from other processes that may be running as the user nobody. As the root user:

mkdir /var/spamfilter
groupadd -r spamd
useradd -r -g spamd -s /sbin/nologin -d /var/spamfilter spamd
chown spamd:spamd /var/spamfilter

We now have a place for our wrapper script to create the temporary files it needs after passing messages through the SpamAssassin daemon, and only the specific user the wrapper script will run as has the necessary permissions to create files there, avoiding any race conditions.

Postfix Integration

There are three philosophies for how Postfix should deal with messages that are identified as spam:

Reject
This only works when the message is identified as spam before the message is accepted and placed in the mail queue. The problem with rejecting it after it is already in the message queue, the sending MTA is no longer connected. The only way to reject it is to bounce it to the address in the From header, resulting in unwanted backscatter.
Blackhole
Simply delete all messages flagged as spam, or flagged as spam beyond a certain threshold. This sounds attractive but it is not appropriate, any message that Postfix receives without rejecting it from the connecting MTA should be delivered to the user. The first time a user is expecting a message that is deleted by your server because it was mis-identified as spam, you will know why a blackhole is bad, and sooner or later it will happen if you use a blackhole method.
X-Spam-Flag
With this method, a new header is added to the message called X-Spam-Flag which is set to a value of YES when it is identified as spam. The message then is delivered to the user, the user can set their own policy in their mail client for what to do with messages flagged as spam.

I believe it is best to employ a hybrid between the first mentioned philosophy and the third. Reject messages that Postfix itself can easily identify as spam as described in Postfix configuration section Basic Spam Filtering. That will eliminate a significant amount of incoming spam without the performance penalties of passing every incoming message through SpamAssassin.

That filtering happens before the connecting MTA disconnects. When the messsage rejected is a false positive, which will happen, the connecting MTA can then notify the user who sent the message that it was rejected by your server and why. They will know it was not delivered, and the issue can be dealt with.

That removes a significant amount of spam if you use a few good blacklists. Messages that are left are then passed through SpamAssassin where the third philosophy is used. If the content scanning that SpamAssassin performs believes the message is spam, it still gets delivered to the recipient but with a header identifying it as spam.

With that in mind, the wrapper script that follows can process the messages that make it into the mail queue. It will defer messages when the SpamAssassin daemon is temporarily not available. When the daemon is available it will process the messages scanning their content to attempt to determine if it is spam or not, and then return the messages to Postfix for the actual message delivery.

Spam Filter Wrapper Script

Create the following script as /usr/local/bin/spamfilter.sh

#!/bin/bash
SENDMAIL="/usr/sbin/sendmail -G -i"
SPAMC="/usr/bin/spamc -x"
FILTERDIR="/var/spamfilter"

EX_TEMPFAIL=75
EX_UNAVAILABLE=69

umask 077

TMPFILE="`mktemp -p ${FILTERDIR} filtered.XXXXXXXXXX`"
if [ "$?" != "0" ]; then
  /usr/bin/logger -s -p mail.warning -t filter \
    "Could not create temporary file for spamc."
  exit ${EX_TEMPFAIL}
fi

trap "rm -f ${TMPFILE}" EXIT TERM

$SPAMC > ${TMPFILE}

return="$?"

if [ "$return" = 1 ]; then
  echo "spamc rejected message"
  exit ${EX_UNAVAILABLE}
elif [ "${return}" != 0 ]; then
  /usr/bin/logger -s -p mail.warning -t filter \
    "Temporary SpamAssassin failure, spamc exit code ${return}"
  exit ${EX_TEMPFAIL}
fi

${SENDMAIL} "$@" < ${TMPFILE}
exit $?
#End Of Script

Make sure to make the above script executable:

chmod +x /usr/local/bin/spamfilter.sh

First the script attempts to create the temporary file where it will write the mail message. It uses the mktemp command for that purpose, and it creates the temporary file in the /var/spamfilter directory we created earlier.

In the event the script fails to create the temporary file, the script will exit with a status code of 75 which will cause Postfix to defer the message and try again later. A failure at this point is very unlikely but could be caused by one of the following four conditions:

  1. The directory /var/spamfilter does not exist. Solve this by creating it.
  2. The user account spamd does not have permission to create files inside the /var/spamfilter directory. Make sure the user spamd owns that directory.
  3. The script is being executed by a user that is not the spamd user. This usually indicates a mistake in the /etc/postfix/master.cf file. Correct the mistake.
  4. The filesystem has run out of inodes. Find out what is using up all the inodes on the /var filesystem.

The trap command is a built-in feature of the bash shell. It will execute when the script terminates. Now that the temporary file has been created, we are using trap to make sure it is deleted when the script terminates so we do not fill up the filesystem with mail messages.

The wrapper script proceeds to run the mail message through spamc, the client interface to the SpamAssassin daemon, attempting to write the output to the temporary file. The script looks at the exit code of that attempt.

An exit status of 1 should never occur and indicates SpamAssassin can not handle the content. In that case, the script exits with a status of 69 and Postfix will deliver the message without filtering.

Any other non-zero exit status from spamc and the script itself will exit with a status of 75 so that Postfix will defer delivery of the message and try again later. The actual exit status of spamc will be logged in /var/log/maillog so that you can inspect the problem and take action to resolve it, if you need to.

When there is an exit status of 0 it indicates the message was successfully passed through SpamAssassin and written to the temporary file. The script then reads the temporary file and sends it back to Postfix for delivery to the destination mailbox.

Postfix Configuration

Now that the wrapper script has been created, we can tell Postfix to use it. We will need to modify the /etc/postfix/master.cf configuration file.

First, find the line that reads:

smtp      inet  n       -       n       -       -       smtpd

It quite likely is the first un-commented line in the file.

Place a new line directly after it so that it now reads:

smtp      inet  n       -       n       -       -       smtpd
  -o content_filter=spamassassin

Make sure there is white space before the second line so that Postfix knows it is part of the previous directive.

That tells Postfix that incoming mail on Port 25 needs to be passed through that content filter before it is delivered to user mailboxes. Incoming mail on the submission port 587 however will not pass through the filter. This avoids any chance of false positives when one user on your server sends a message to another user on your server. It can be very annoying when that happens, especially when they are coworkers who need to collaborate.

Next we need to define the spamassassin filter that we just told Postfix to use. At the very bottom of the same file, add the following three lines:

spamassassin
          unix  -       n       n       -       -       pipe
   user=spamd argv=/usr/local/bin/spamfilter.sh -oi -f ${sender} ${recipient}

Make sure there is white space before the second and third line so that Postfix knows they are part of the previous directive.

That defines a filter called spamassassin and instructs Postfix on what to do, specifically to execute the script /usr/local/bin/spamfilter.sh as the user spamd.

After making those changes, reload Postfix:

/usr/sbin/postfix reload

Testing SpamAssassin

From an e-mail account un-related to the server you are configuring, send a message to a user account on the server you are configuring with the following precise subject:

XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X

That is a test subject that SpamAssassin will always flag as spam if it is working properly.

The message should be delivered. When delivered, if you look at the headers that accompany the message, you should see one that reads:

X-Spam-Flag: YES

That indicates the filter is working, and now users can use that header to filter spam out of their Inbox.

SpamAssassin Client Filtering

Instructions on how to filter spam based on the X-Spam-Flag: YES header in many popular e-mail clients are linked in the table below:

E-Mail Client Instructions
Mozilla Thunderbird http://www.zerolag.com/support/spam-filtering/thunderbird-spam-filtering/
Outlook 2010 http://hdc.tamu.edu/Connecting/Email/Spam/Microsoft_Outlook_Spam_Filter.php
Outlook 2003 http://www.zerolag.com/support/spam-filtering/outlook-2003-spam-filtering/
OS X Mail http://www.zerolag.com/support/spam-filtering/mac-osx-spam-filtering/
Evolution http://support.real-time.com/open-source/spamassassin/evolution.html

Dovecot Pigeonhole Filtering

With the Dovecot server, you can create a pigeonhole filter to filter the spam for you, assuming pigeonhole support is installed on the server:

require "fileinto";
if header :contains "X-Spam-Flag" "YES" {
  fileinto "Spam";
}