Catching spam with rspamd
You have come a long way in this guide and your mail server is already fully functional. Now it’s time to deal with filtering out incoming spam emails. I found that rspamd is well-performing choice for that purpose both in speed and detection. rspamd keeps a permanent process running on your mail server that listens to connections from Postfix using the milter (=mail filter) protocol. Every time an email enters your system, Postfix will send it to rspamd to have its content checked. rspamd runs a lot of checks on the email and computes a total score. The higher the score – the more likely it it spam. And the best thing about milters: the check happens in parallel while Postfix is still in the process of receiving the email. If the score is high enough, Postfix can still reject the email right at the doorstep.
Make Postfix use rspamd
Section titled “Make Postfix use rspamd”Let’s tell Postfix to send all incoming email through rspamd:
postconf smtpd_milters=inet:127.0.0.1:11332postconf non_smtpd_milters=inet:127.0.0.1:11332For testing we can use a sample spam email that comes with SpamAssassin. It is called GTUBE (Generic Test for Unsolicited Bulk Email). It contains a certain artificial pattern that is recognized as spam by SpamAssassin. Do you know EICAR.COM to test virus scanners? This is the same thing for spam.
Get the GTUBE file, send it to John and let’s see what happens:
wget http://spamassassin.apache.org/gtube/gtube.txtsendmail john@example.org < gtube.txtjournalctl -fu postfixYou will find something like this at the end of the log file:
postfix/cleanup[1386519]: 2B72320126: message-id=<GTUBE1.1010101@example.net>postfix/cleanup[1386519]: 2B72320126: milter-reject: END-OF-MESSAGE from localhost[127.0.0.1]: 5.7.1 Gtube pattern; from=<root@mailserver> to=<john@example.org>postfix/cleanup[1386519]: 2B72320126: to=<john@example.org>, relay=none, delay=0.16, delays=0.16/0/0/0, dsn=5.7.1, status=bounced (Gtube pattern)milter-reject tells that the rspamd recommended to reject the email. It gave the reason 5.7.1 Gtube pattern. SMTP
and LMTP always uses such three digit codes. They are defined in RFC 3463. The
first digit is most important:
- 2 = Success
- 4 = Temporary failure (come back later)
- 5 = Permanent failure (do not try again in this way)
So 5.7.1 tells us that the result code is a permanent failure in delivery. If you looked up the RFC you would find
that the 7 stands for an issue regarding the security policy. So it’s not a technical failure but instead a
security-relevant component on the system has rejected the email. That’s what rspamd did. It even told us the reason:
Gtube pattern. So you see that rspamd knows about the Gtube spam test pattern and reacts as expected.
That email will not be delivered to John. Instead Postfix will reject it at the doorstep – while it is still in the SMTP communication with the sending server. It just hung up the phone and now it’s the sending server’s problem to deal with.
It is important to either reject an email right away or to deliver it to a user. Never accept an email and later decide to complain about it to the alleged sender. Trust me: the sender you see on a spam email is never the bad guy who sent it. You would complain to the wrong person which is called backscatter.
Spam scores
Section titled “Spam scores”rspamd will however not reject all spam email. It computes a score that tells how likely a certain email is spam. You
can tell it which scores you would accept, flag as spam or make the incoming email get rejected. Rspamd checks incoming
emails in various ways. Take a look at the /etc/rspamd/actions.conf file:
actions { reject = 15; add_header = 6; greylist = 4;}These are the default actions. If rspamd computes a score of at least 15 then the email will get rejected right away just like the Gtube pattern in the previous test. Any other score above 6 will add a line “X-Spam: Yes” so that your mail software can detect them and maybe file the email to a different folder. And any other score above 4 will trigger greylisting which is a mechanism that temporarily rejects the email with a 4.x.x code and waits if the sending server will try again. After a waiting time of a few minutes greylisting will accept the email. The idea is to reject email from systems that do not have a sending queue. Malware like on infected Wind*ws computers used to try sending an email just once which triggered greylisting and successfully rejected the spammer. But even malware programmers have learned and may try again after a few minutes thus circumventing greylisting. Your mileage may vary. The problem with greylisting is that the recipient has to wait a couple of minutes for the email to be delivered which may be bothering the users.
X-Spam header
Section titled “X-Spam header”An email consists of headers and the actual body. Your users will usually only see common header information
like the subject, the sender, the recipient and the time the email was sent. But there is way more information
like the route the email travelled. Mail software can even add arbitrary extended headers that start with X-.
There is one specific header that rspamd adds for us if it finds a spam email:
X-Spam: YesClick here to dive deeper into rspamd’s scores…
rspamd can add an X-Spamd-Result header containing the various criteria that added to the total score:
X-Rspamd-Server: mailserverX-Rspamd-Queue-Id: C22E55A005B3X-Spamd-Result: default: False [11.55 / 15.00] R_PARTS_DIFFER(0.27)[63.4%] FROM_NO_DN(0.00)[] RCVD_COUNT_ZERO(0.00)[0] R_DKIM_NA(0.00)[] FUZZY_DENIED(12.00)[1:19305c7fdd:1.00:bin,1:35699594fd:0.91:txt] RBL_SENDERSCORE(2.00)[55.181.23.94.bl.score.senderscore.com] ARC_NA(0.00)[] RCPT_COUNT_ONE(0.00)[1] RCVD_TLS_ALL(0.00)[] FROM_EQ_ENVFROM(0.00)[] R_SPF_SOFTFAIL(0.00)[~all] BAYES_HAM(-2.71)[98.75%] TO_MATCH_ENVRCPT_ALL(0.00)[] MIME_GOOD(-0.10)[multipart/related,multipart/alternative,text/plain] MID_RHS_MATCH_FROM(0.00)[] ASN(0.00)[asn:16276, ipnet:94.23.0.0/16, country:FR] TO_DN_NONE(0.00)[] DMARC_POLICY_SOFTFAIL(0.10)[Chronopost.fr : No valid SPF, No valid DKIM,none] SUBJECT_ENDS_EXCLAIM(0.00)[]X-Spam: YesEnable those additonal headers:
# Enable rspamd's extended headerscat > /etc/rspamd/local.d/milter_headers.conf << EOFextended_spam_headers = true;EOF
# Restart rspamdsystemctl reload rspamd
# Send a normal (non-spam) test email. Do not use "localhost" here because rspamd# will only add extended headers for emails coming from the internet.swaks --server mailserver.example.org --to john@example.orgEach of the uppercase symbols like FROM_HAS_DN means that a certain detection rule of rspamd was triggered. It does not necessarily mean something bad about the email. For example R_SPF_ALLOW has a negative score that lowers the total score because it is something good about the email. There are a several symbols with a 0.00 score. These do not change the score but show you what rspamd has found. But if you consider certain criteria good or bad then you can define your own scores for them.
Sending spam to the Junk folder
Section titled “Sending spam to the Junk folder”Your users will not realize that their spam emails have an added X-Spam: Yes header. It is not shown in their mail
client. Nor does it move the email out of the inbox into their spam folder. Such emails just appear in their inbox. So
let’s be nice and move spam emails to the user’s Junk folder automatically. Dovecot has support
for Sieve filtering rules that are if-then scripts
that get run whenever an email arrives.
John could create such a Sieve script for himself (using the Roundcube webmail interface). But let’s find a solution
that applies to all your users. Create a new config file (/etc/dovecot/conf.d/99-ispmail-sieve.conf) that tells
Dovecot:
- whenever an email is delivered to our users, run an additional Sieve script
(
/etc/dovecot/sieve/spam-to-junk-folder.sieve) - the Sieve functionality is enabled during LMTP (when an email is passed on from Postfix to Dovecot)
- the user gets a Junk folder created in his mailbox and is subscribed to it so that it appears in their mail program
# Create a new config file for spam handlingcat > /etc/dovecot/conf.d/99-ispmail-sieve-movetojunk.conf << 'EOF'sieve_script spam-to-junk-folder { driver = file type = after path = /etc/dovecot/sieve/spam-to-junk-folder.sieve}
# Enable the execution of Sieve rules when Postfix sends an email to Dovecot over LMTPprotocol lmtp { mail_plugins { sieve = yes }}
# Make sure that every user has a Junk folder and is subscribed to itnamespace inbox { mailbox Junk { special_use = \Junk auto = subscribe }}EOF
# Restart Dovecotsystemctl reload dovecotNow we need to create that Sieve script (/etc/dovecot/sieve/spam-to-junk-folder.sieve) that is run on each delivery.
Its job is to check if the X-Spam: yes header is present. If it is, the email is filed into the user’s Junk folder.
# Create the directory for Sieve filesmkdir -p /etc/dovecot/sieve
# Create the Sieve script to move Spam mails to the user's Junk foldercat > /etc/dovecot/sieve/spam-to-junk-folder.sieve << 'EOF'require ["fileinto"];
if header :contains "X-Spam" "Yes" { fileinto "Junk"; stop;}EOF
# Make the sieve script machine-readablesievec /etc/dovecot/sieve/spam-to-junk-folder.sieveLet’s give it a test using Swaks. This time we impersonate Postfix and inject an email with an X-Spam: yes header
directly into Dovecot using the LMTP socket:
swaks --to john@example.org --header-X-Spam "yes" --socket /var/spool/postfix/private/dovecot-lmtp --protocol LMTPjournalctl -fu dovecotIt should read:
dovecot[1434406]: lmtp(john@example.org)<1436598><kh4lDNl26Wi26xUA5ANL0g>: sieve: msgid=<20251010211257.1436597@mailserver>: fileinto action: stored mail into mailbox 'Junk'The alleged spam email has been moved to the Junk folder just like we wanted.
Click here to get more detailed logs…
If you find that the delivery to the Junk folder did not work, you may want to increase the log level. Uncomment the line
log_debug=category=sievein the 99-ispmail-sieve.conf file.
That will give you a much deeper insight of what Dovecot has been doing. Restart Dovecot, send another email with swaks and check the logs again:
systemctl reload dovecotjournalctl -eu dovecotAutomatic spam training
Section titled “Automatic spam training”One of rspamd’s features is analyzing word patterns using probability theory. That functionality is contained in its statistical module. (Yes, the name is misleading.) Essentially you show rspamd lots of ham (good) and spam (bad) emails and its detection gets better over time.
Rspamd stores that training data (among other information) in a local Redis database.
Click here to learn more about Redis…
Redis is a kind of database system. It is way more limited than a traditional SQL database because it just stores keys and values. There aren’t several fields/columns like in SQL. But it is lightning fast the way it works. On my aged server it handles around 50,000 requests per second. It gets it speed from its simplicity and from keeping the data in RAM. So it doesn’t access the disk to fetch information. (But it copies its data to disk frequently to prevent data loss.) People use Redis as a cache or for very fast lookups of simple data structures. And so does rspamd.
You have already installed the “redis-server” package earlier. It has started automatically and listens to incoming connections on TCP port 6379 on localhost. You just need to tell rspamd to use it:
# Create a config file to enable automatic spam trainingcat > /etc/rspamd/local.d/classifier-bayes.conf << 'EOF'# Store training data in the Redis databaseservers = "127.0.0.1:6379";backend = "redis";
# Enable automatic trainingautolearn = true; # if rspamd is sure that an email is spam, it will be learnedmin_learns = 200; # do not trust the data before at least 200 mails have been learnedEOF
# Restart rspamdsystemctl restart rspamdYou will start with an empty training database. But that is not as bad as it sounds, because rspamd checks many properties of an email to determine if an email is ham or spam. If there is enough evidence that an email is likely ham or spam, then autolearning adds it to its training database. The rspamd documentation has further examples how to fine-tune auto learning. After a few hundred emails the training will contribute towards a better detection rate.
The defaults for auto-learning are:
- score < -0.5 ➞ learn as ham/good
- score > 4.0 ➞ learn as spam/bad
See rspamd’s documentation if you want to fine-tune that.
rspamc statBayes spam checking will not work before it learned at least 200 spam and ham emails. Teaching rspamd fewer emails or just spam emails will not work. This is defined by the min_learns variable defined in /etc/rspamd/statistic.conf.
In the output you will find lines beginning with “Statfile” like these…
Statfile: BAYES_SPAM type: redis; length: 0; free blocks: 0; total blocks: 0; free: 0.00%; learned: 21164; users: 214; languages: 0
Statfile: BAYES_HAM type: redis; length: 0; free blocks: 0; total blocks: 0; free: 0.00%; learned: 1411; users: 62; languages: 0(Don’t worry about the length: 0. That seems to be a bug that has been
ignored since 2019. Checking the actual contents of the Redis database reveals that there is actually data stored.)
Per-user spam training
Section titled “Per-user spam training”Usually the training database applies to all incoming emails for all users. But you split it up so that each recipient gets their own training.
Advantage: users work differently. Some have subscribed to a sales newsletter and now believe that marking it as spam gets them unsubscribed. Yes, that’s stupid but can thoroughly confuse the spam detection. Also you might not be very interested in “blue pills” while others are. So the training data of one user does not affect that of the others.
Disadvantage: training still requires many ham and spam mails before it has any effect. So unless a user gets 200 samples of good and bad emails, spam detection cannot work. Many users will not get that many emails so due to the lack of spam training the detection will not be improved. For a friends-and-family server I would suggest not to use it.
If you decide you want to use per-user spam training then add/edit the file /etc/rspamd/local.d/classifier-bayes.conf
and insert:
users_enabled = true;Learning from user actions
Section titled “Learning from user actions”Now we are getting to something really cool. Imagine you receive a spam email in your inbox that Rspamd didn’t catch. You could just move it to your Junk folder — but that wouldn’t help Rspamd learn to recognize that kind of spam in the future. Luckily, we can change that.
Let’s tell Dovecot that moving emails into the Junk folder teaches rspamd instantly that the email is spam. And if the email is moved out of the Junk folder, then learn it as ham. That can be done using another sieve script. Sieve scripts not only apply when an email is delivered. Thanks to Dovecot’s IMAPSieve plugin, such scripts can also be triggered if a user moves a mail between folders.
The setup requires three parts:
- Tap into the IMAP connection to know when an email is moved out of or into the Junk folder.
- Create Sieve scripts that call Shell scripts.
- Create Shell scripts that actually call
rspamd learn_hamorrspamd learn_spam.
Or as a diagram:
Create a new Dovecot configuration file (99-ispmail-imapsieve.conf) to enable the required functionality and add
triggers on the Junk folder:
cat > /etc/dovecot/conf.d/99-ispmail-imapsieve.conf << 'EOF'# Enable the imap_sieve pluginprotocol imap { mail_plugins { imap_sieve = yes }}
# Allow the use of the pipe plugin to send mails to shell scriptssieve_plugins { sieve_extprograms = yes sieve_imapsieve = yes}
sieve_global_extensions { vnd.dovecot.pipe = yes}
# Where to look for Sieve scripts that use the Pipe functionalitysieve_pipe_bin_dir = /etc/dovecot/sieve
# Moved into Junk? -> Learn as spam.mailbox Junk { sieve_script spam { type = before cause = copy path = /etc/dovecot/sieve/learn-spam.sieve }}
# Moved out of Junk? -> Learn as ham.imapsieve_from Junk { sieve_script ham { type = before cause = copy path = /etc/dovecot/sieve/learn-ham.sieve }}EOF
systemctl reload dovecotThe first rule tells Dovecot to run a Sieve script at /etc/dovecot/sieve/learn-spam.sieve whenever an email is moved
into a user’s “Junk” folder. We will create that Sieve script in a minute.
The second rule works the other way. Whenever an email is moved out of the “Junk” folder to any other folder, then
the /etc/dovecot/sieve/learn-ham.sieve Sieve script is called.
Let’s create both Sieve scripts:
# Create spam learning scriptcat > /etc/dovecot/sieve/learn-spam.sieve << 'EOF'require ["vnd.dovecot.pipe", "copy", "imapsieve"];pipe :copy "rspamd-learn-spam.sh";EOF
# Create ham learning scriptcat > /etc/dovecot/sieve/learn-ham.sieve << 'EOF'require ["vnd.dovecot.pipe", "copy", "imapsieve", "variables"];pipe :copy "rspamd-learn-ham.sh";EOF
# Compile the Sieve scriptssievec /etc/dovecot/sieve/learn-spam.sievesievec /etc/dovecot/sieve/learn-ham.sieve
# Fix permissionschmod u=rw,go= /etc/dovecot/sieve/learn-{spam,ham}.{sieve,svbin}chown vmail:vmail /etc/dovecot/sieve/learn-{spam,ham}.{sieve,svbin}By the way: the sievec comment compiles the *.sieve file into *.svbin files that Dovecot can work with. If you are
not doing this step, Dovecot will do that during runtime. However then you need to make sure that Dovecot has proper
write permissions to the target directory. The sievec command is also useful because it scans a *.sieve file for
errors. So if your changes do not seem to work it might be worth compiling them and looking for errors.
And the last step is to create the simple shell scripts that do the actual spam/ham training:
# Create the shell script for learning spamcat > /etc/dovecot/sieve/rspamd-learn-spam.sh << 'EOF'#!/bin/sh# Receives an email from Dovecot's Sieve script and pipe it into rspamcexec /usr/bin/rspamc learn_spamEOF
# Create the shell script for learning hamcat > /etc/dovecot/sieve/rspamd-learn-ham.sh << 'EOF'#!/bin/sh# Receives an email from Dovecot's Sieve script and pipe it into rspamcexec /usr/bin/rspamc learn_hamEOF
# Fix permissions of the shell scriptschmod u=rwx,go= /etc/dovecot/sieve/rspamd-learn-{spam,ham}.shchown vmail:vmail /etc/dovecot/sieve/rspamd-learn-{spam,ham}.shI am sure you are eager to try it out. Of course we don’t blindly believe that it works. We want to see it do its job. Usually you will not see much unless you increase the log level to debug. Let’s do that:
cat > /etc/dovecot/conf.d/99-ispmail-sieve-debug.conf << 'EOF'# Enable detailed logging of Sieve scriptslog_debug=category=sieveEOF
# Restart Dovecotsystemctl reload dovecot
# Follow Dovecot's logsjournalctl -fu dovecotNow open your IMAP client (Thunderbird, Evolution, Roundcube, mutt or whatever you prefer) and drag an email to John’s Junk folder. The mail log will show a lot of things that are going on. You will see a whole screen full of things that are going on. Some relevant lines:
imap(john@example.org)<1737478><FnGIR2BBA/5bar5M>: Debug: Mailbox Junk: imapsieve: storage spam: file: Using Sieve script path: /etc/dovecot/sieve/learn-spam.sieveimap(john@example.org)<1737478><FnGIR2BBA/5bar5M>: Debug: Mailbox Junk: imapsieve: storage spam: file: script 'learn-spam': Opened from 'spam'imap(john@example.org)<1737478><FnGIR2BBA/5bar5M>: Debug: sieve: Opening script 1 of 1 from 'spam/learn-spam'imap(john@example.org)<1737478><FnGIR2BBA/5bar5M>: Debug: sieve: Executing script from '/etc/dovecot/sieve/learn-spam.svbin'imap(john@example.org)<1737478><FnGIR2BBA/5bar5M>: Debug: sieve: action pipe: running program: rspamd-learn-spam.shimap(john@example.org)<1737478><FnGIR2BBA/5bar5M>: Debug: sieve: uid=19: Finalize pipe action (status=ok, action_status=ok, commit_status=ok, pre-commit=yes)imap(john@example.org)<1737478><FnGIR2BBA/5bar5M>: Debug: sieve: execute exec:/etc/dovecot/sieve/rspamd-learn-spam.sh (1737484): Connected to programimap(john@example.org)<1737478><FnGIR2BBA/5bar5M>: Debug: sieve: uid=19: pipe action: piped message to program 'rspamd-learn-spam.sh'Something similar should happen when you drag the email back from the Junk folder to the Inbox.
The /var/log/rspamd/rspamd.log file will also show that training:
rspamd_controller_learn_fin_task: <127.0.0.1> learned message as spam: GTUBE1.1010101@example.netrspamd_controller_learn_fin_task: <127.0.0.1> learned message as ham: 20251101173211.068172@mailserverIf you are happy and see no errors (they are highlighted in red), then switch off the debugging again.
Logging
Section titled “Logging”rspamd keeps a verbose log of its actions in /var/log/rspamd/rspamd.log. If a user complains that a certain email got blocked or at least flagged as spam then take a look at this log. You can match the /var/log/mail.log with it by comparing the Postfix queue ID. Those are the 12-digit hexadecimal number like “95CE05A00547”. Those IDs can be found in the rspamd.log, too:
<40985d>; task; rspamd_task_write_log: id: <undef>, qid: <95CE05A00547>, ip: 12.13.51.194, from: <…>, (default: F (no action): [3.40/15.00] [MISSING_MID(2.50){},MISSING_DATE(1.00){},MIME_GOOD(-0.10){text/plain;},ARC_NA(0.00){},ASN(0.00){asn:8220, ipnet:212.123.192.0/18, country:GB;},FROM_EQ_ENVFROM(0.00){},FROM_NO_DN(0.00){},RCPT_COUNT_ONE(0.00){1;},RCVD_COUNT_ZERO(0.00){0;},RCVD_TLS_ALL(0.00){},TO_DN_NONE(0.00){},TO_DOM_EQ_FROM_DOM(0.00){},TO_MATCH_ENVRCPT_ALL(0.00){}]), len: 181, time: 16.000ms real, 6.385ms virtual, dns req: 0, digest: <69b289a82827c11f759837c033cd800a>, rcpts: <…>, mime_rcpt: <…>Autoexpunge
Section titled “Autoexpunge”Andi Olsen pointed out that Dovecot has introduced an autoexpunge feature to automatically delete emails in a folder that reach a certain age. This is especially useful for the “Trash” and “Junk” folders. To enable this feature, create yet another configuration file:
cat > /etc/dovecot/conf.d/99-ispmail-autoexpunge.conf << 'EOF'# Remove mails from the Junk and Trash folders after 30 daysmailbox Junk { special_use = \Junk auto = subscribe mailbox_autoexpunge = 30d}mailbox Trash { special_use = \Trash auto = subscribe mailbox_autoexpunge = 30d}
# Make expunging more efficientmailbox_list_index = yesmail_always_cache_fields = date.saveEOF
# Restart Dovecotsystemctl reload dovecotrspamd’s web interface
Section titled “rspamd’s web interface”rspamd comes with a neat bonus feature: a web interface. It allows you to check emails for spam, get statistics and fine-tune scores. It is already installed and enabled by default and expects HTTP (not HTTPS!) requests on port 11334 on the localhost interface. I suggest you add a simple proxy configuration to your already working HTTPS-enabled web mail configuration to get access.

You can either create a new virtual host configuration or just edit the
/etc/apache2/sites-enabled/000-default-le-ssl.conf file. Anywhere within the VirtualHost tags add:
<Location /rspamd> Require all granted</Location>
RewriteEngine OnRewriteRule ^/rspamd$ /rspamd/ [R,L]RewriteRule ^/rspamd/(.*) http://localhost:11334/$1 [P,L]You also need to enable Apache’s modules for HTTP proxying and rewriting:
# Enable the required Apache modulesa2enmod proxy_httpa2enmod rewrite
# Restart Apachesystemctl restart apache2This piece of configuration will forward any requests to https://webmail.example.org/rspamd to localhost:11334 and
thus give you access to the rspamd web interface.
The web interface is password protected. Let’s generate a new access password, hash it and add it to rspamd’s configuration:
# Generate a random password that is 15 characters longPW=$(pwgen 15 1)
# Create a hash of that passwordHASH=$(rspamadm pw -p $PW)
# Add the hashed password to rspamd's configurationecho "password = \"$HASH\"" > /etc/rspamd/local.d/worker-controller.inc
# Restart rspamdsystemctl restart rspamd
# Show the passwordecho "Please note the password for the web interface: $PW"This gives you a password like “sae8thaoTaengeo”. If everything went as expected you should now be able to access the
rspamd web interface at https://mail.example.org/rspamd (of course you will use your own server name here).
You may miss the detailed history in the web interface. /etc/rspamd/local.d/redis.conf The content is: servers = “127.0.0.1”;