Rspamd in front of your inbox, phishing stops at the doormat
Install Rspamd in front of your mail server, tune the anti-spam and anti-phishing scoring, and feed every verdict into your Wazuh SIEM.
In the previous episode, you put CrowdSec up front. Your firewall now turns malicious IPs away before they touch your services, and every decision goes up into Wazuh. Your SIEM sees, and it bites.
Except the attack that’s going to cost you dearly won’t come in through the SSH port.
It’ll come in through an email. A fake invoice that mimics your usual supplier, a link that looks like your banking portal, an attachment your accountant will open because opening invoice attachments is her job.
That email crosses your firewall without a sound, because it comes in through a door you left open yourself, port 25. CrowdSec won’t block anything, the sender has a clean IP. Wazuh won’t see anything, nobody told it to look that way.
That’s the blind spot in the setup. Three bricks laid, and the number-one intrusion vector for small businesses still walks in unencrypted.
Today, we plug it. We install Rspamd, a filtering engine that slots in front of your mail server, scores every incoming message and decides its fate. Then we wire its logging into Wazuh, so that every verdict, spam blocked, phishing detected, message delivered, shows up in your single dashboard.
Before you begin
Three things to keep in mind before the first command.
What you need. A mail server you administer, Postfix or Exim, on a hardened server like the one from episode 1. The Wazuh manager from episode 2 in place on your VPS, and the Wazuh agent from episode 3 already installed on that mail server, for the integration part at the end of the tutorial. Administrator access, and Redis, which we’ll install alongside the engine.
What changes. By the end, every incoming message is scored before it reaches your employee’s mailbox. The obvious ones are refused at the door, the doubtful ones arrive marked, phishing attempts raise an alert in your SIEM. You move from a blind inbox to an instrumented one.
What doesn’t change. Rspamd filters incoming mail. It’s not an endpoint antivirus, it’s not a replacement for your employees’ common sense, and it doesn’t protect against an account whose password has leaked, the attacker is then inside, not outside. And Rspamd isn’t infallible, it will fool you sometimes. The question isn’t whether it gets it wrong, it’s which side you set the cursor on when it does.
Rspamd doesn’t say yes or no, it scores
The point that changes everything, and that sets Rspamd apart from classic anti-spam filters.
A binary filter looks at a message and rules, spam or not spam. The result is a door, open or shut, and the real world takes quick revenge on that oversimplification. A legitimate invoice with a shortened link and an attachment gets thrown out, a well-written scam gets through.
Rspamd, on the other hand, accumulates. It runs dozens of checks on the same message, each one reporting a small weight, positive or negative. Sender authentication fails, plus two points. The domain is on a reputable blacklist, plus four. The displayed URL doesn’t match the link’s real URL, plus seven. The message looks like a hundred others already flagged as spam, plus five. Conversely, the sender is properly signed and known, minus one.
At the end, it gets a score, and that score triggers an action based on thresholds you set yourself. The recommended configuration is this one.
Score 4, greylist. The sending server is asked to come back later. A real mail server does it, a spam bot rarely does.
Score 6, add header. The message is delivered, but marked. Your mail client can file it under junk.
Score 15, reject. The message is refused at the door, it never comes in.
Three levels of firmness, not a door. It’s the same philosophy as the SIEM in episode 2, we’re not after certainty, we’re after grading our response to an uncertain signal.
On the architecture side, Rspamd runs as several specialized processes. The proxy worker is the one that talks to your mail server, over the milter protocol, on port 11332. The normal worker computes the score. The controller worker serves the web interface and the learning. You’ll only need to know the first one, that’s the one we wire in.
Step 1, install the engine
On the machine that hosts your mail server. The official Rspamd repository, then the package, then Redis, which Rspamd needs for its statistics, its Bayesian filter and its greylisting.
curl -fsSL https://rspamd.com/apt-stable/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/rspamd.gpg
echo "deb [signed-by=/usr/share/keyrings/rspamd.gpg] https://rspamd.com/apt-stable/ $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/rspamd.list
sudo apt update
sudo apt install rspamd redis-server
The apt-stable repository is the production branch, the one you want on a mail server. Rspamd has been on version 4.x since March 2026.
Tell it where to find Redis, in /etc/rspamd/local.d/redis.conf.
servers = "127.0.0.1:6379";
A survival rule with Rspamd, and it holds for the whole tutorial. You never touch the files in /etc/rspamd/ directly. You write your settings in /etc/rspamd/local.d/, which is layered on top of the default configuration. A package update overwrites the first, never the second.
Then set a password for the web interface.
rspamadm pw
It hands you a hash that you copy into /etc/rspamd/local.d/worker-controller.inc.
password = "$2$your_hash_here";
The interface listens on port 11334, locally only. Leave it that way. The series’ doctrine since episode 1, nothing needless on the open internet, you reach it through your Tailnet or an SSH tunnel.
Step 2, wire it in front of your mail server
Rspamd is running, but no message is being handed to it. Your mail server has to submit every incoming email to it before accepting it.
The mechanism is called milter, for mail filter. Your mail server pauses the message during the SMTP transaction, hands it to Rspamd, waits for the verdict, and acts accordingly. The message is judged before being accepted, that’s the whole difference with a filter that sorts after the fact in the mailbox.
On Postfix, three lines in /etc/postfix/main.cf.
smtpd_milters = inet:localhost:11332
milter_protocol = 6
milter_default_action = accept
The line that deserves your attention is the third. accept means that if Rspamd is down, mail passes without filtering. You favor service continuity. The other value commonly chosen is tempfail, which asks the sender to come back later, your mail is protected even with Rspamd down, but a prolonged outage turns into a mail incident. Choose knowingly. For a small business, accept is the reasonable default, provided you monitor the Rspamd service in your SIEM.
On Exim, the wiring goes through the normal worker over HTTP, not through the milter.
spamd_address = 127.0.0.1 11333 variant=rspamd
Reload your mail server, and send yourself a message from an outside address. Rspamd starts scoring.
Step 3, set the thresholds and arm the anti-phishing
The thresholds first, in /etc/rspamd/local.d/actions.conf.
greylist = 4;
add_header = 6;
reject = 15;
These are the recommended values, and they’re deliberately lenient. A reject at 15 means it takes a massive accumulation of negative signals for a message to be refused. That’s what you want at the start, you tighten later, once you’ve read your own logs.
Sender authentication next. Three modules active by default, SPF (Sender Policy Framework), DKIM (DomainKeys Identified Mail) and DMARC (Domain-based Message Authentication, Reporting and Conformance). They check three things, that the sending server is allowed to send for that domain, that the message’s cryptographic signature is valid, and that the domain shown in the “From” field is indeed the one that’s authenticated. An email claiming to come from your bank and failing all three is exposed without needing to read a single line of its content.
By default, Rspamd scores these failures but doesn’t apply the policy published by the sending domain. To make it apply that policy, in /etc/rspamd/local.d/dmarc.conf.
actions {
quarantine = "add header";
reject = "reject";
}
You now obey what the real domain owner asked the world to do with messages that impersonate it. It’s free, it’s immediate, and it eliminates a whole family of fake senders.
The phishing module last, the heart of the matter. Its basic check is already active, it compares the URL displayed in the link text to the link’s real destination URL, and raises the PHISHED_URL symbol when the two domains diverge. That’s exactly the mechanism of the fake banking link.
You can back it with two public databases of known phishing URLs, in /etc/rspamd/local.d/phishing.conf.
openphish_enabled = true;
openphish_map = "https://raw.githubusercontent.com/openphish/public_feed/refs/heads/main/feed.txt";
phishtank_enabled = true;
No phishing { } block around it, and it’s the local.d rule from step 1 that applies. Rspamd already includes this file inside the module’s section, you write only the content. Add the block, and you get a section inside a section, silently ignored, no error at startup and no feed activated.
A message containing a URL already flagged elsewhere in the world raises PHISHED_OPENPHISH or PHISHED_PHISHTANK, with a heavy weight. CrowdSec’s community logic, transposed to malicious links.
Three other modules work for you without you configuring them. rbl queries the public blacklists of sending servers. fuzzy_check compares the message’s fingerprint to that of spam already flagged by the Rspamd network, a reworded spam keeps a close fingerprint. bayes learns from your own messages, provided you train it.
autolearn = true;
in /etc/rspamd/local.d/classifier-bayes.conf, and Rspamd learns on its own from the messages it classified with certainty. You can also correct it by hand when it gets it wrong, with rspamc learn_spam on a message that slipped through, or rspamc learn_ham on a legitimate one it wrongly marked.
Step 4, put it to the test
Rather than wait for a real spam, you make one.
There’s a standard string for that, the GTUBE, recognized by every serious anti-spam engine and designed to trigger a certain rejection without being real spam. You write it into a test message, and you submit it to the engine with rspamc, the command-line client.
printf 'Subject: test\n\nXJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\n' > /tmp/gtube.eml
rspamc symbols /tmp/gtube.eml
You should see the GTUBE symbol and the reject action. The GTUBE short-circuits the score computation and forces the rejection directly, that’s the whole point of the test. If you get it, the engine is running, it scores and it rules. You get something like this.
Results for file: /tmp/gtube.eml (0.014 seconds)
[Metric: default]
Action: reject
Spam: true
Symbol: GTUBE (0.00)
Message-ID: undef
No need for a score that blows past the threshold, the GTUBE symbol triggers a passthrough that forces the reject action whatever the total. That’s the expected behavior, your engine does apply the verdict.
Two check commands for later.
rspamc stat
It gives you the engine’s state, the messages scanned, the breakdown by action, the state of the Bayesian filter.
rspamc symbols /path/to/a/real/message.eml
It replays a real message and shows you the detail of the symbols with their weight. It’s your diagnostic tool when a message got through that shouldn’t have, or the reverse. You don’t guess, you read the tally line.
Step 5, feed Rspamd into Wazuh
Rspamd filters, very good. Your SIEM still doesn’t know it. We wire them together, and the series’ loop closes on mail.
Nothing to enable on the Rspamd side. It already writes, by default, one line per processed message in /var/log/rspamd/rspamd.log. A persistent file, on disk, exactly what Wazuh needs. A line looks like this.
2026-07-14 09:41:07 #3421(rspamd_proxy) <7f2c1a>; task; rspamd_task_write_log: id: <[email protected]>, qid: <4XjK2r1Yz>, ip: 203.0.113.42, from: <[email protected]>, (default: T (reject): [16.40/15.00] [PHISHED_URL,DMARC_POLICY_REJECT,R_SPF_FAIL,BAYES_SPAM]), len: 4021, time: 128.4ms real, dns req: 22
It’s all there. The sender’s IP, its address, the action taken, the score obtained against the threshold, and the full list of the symbols that brought it down. It’s an investigation report per message.
The wiring happens on two machines, and that’s the point where you trip up if you go too fast. The Wazuh agent, on your mail server, understands nothing of what it reads, it ships the raw lines. It’s the manager, on the VPS from episode 2, that decodes and applies the rules. One machine collects, the other understands.
On the mail server, you tell the agent to read the file. The block goes in its ossec.conf.
<localfile>
<location>/var/log/rspamd/rspamd.log</location>
<log_format>syslog</log_format>
</localfile>
You recognize the <localfile> from episode 3, the mechanism by which Wazuh swallows any log source.
On the manager, now, and on it alone. A decoder extracts the useful fields from the line, in /var/ossec/etc/decoders/local_decoder.xml.
<decoder name="rspamd">
<prematch>rspamd_task_write_log: </prematch>
</decoder>
<decoder name="rspamd-verdict">
<parent>rspamd</parent>
<regex type="pcre2">ip: (\S+?), .*?\(default: \S+ \(([\w ]+)\): \[(-?\d+\.\d+)/</regex>
<order>srcip, rspamd_action, rspamd_score</order>
</decoder>
Then rules that turn those fields into graded alerts, in /var/ossec/etc/rules/local_rules.xml, still on the manager.
<group name="rspamd,">
<rule id="100400" level="0">
<decoded_as>rspamd</decoded_as>
<description>Rspamd, scan verdict.</description>
</rule>
<rule id="100401" level="12">
<if_sid>100400</if_sid>
<match>PHISHED_</match>
<description>Rspamd, phishing detected, sender $(srcip).</description>
</rule>
<rule id="100402" level="10">
<if_sid>100400</if_sid>
<field name="rspamd_action" type="pcre2">^reject$</field>
<description>Rspamd, message rejected, sender $(srcip).</description>
</rule>
<rule id="100403" level="5">
<if_sid>100400</if_sid>
<field name="rspamd_action" type="pcre2">^add header$</field>
<description>Rspamd, message marked as spam and delivered, sender $(srcip).</description>
</rule>
</group>
The order of declaration isn’t cosmetic, it’s the heart of the tuning. The parent rule is at level 0, it decodes without alerting, otherwise your dashboard drowns under legitimate messages. Phishing goes first, at level 12, because Wazuh fires only one rule per message, the first in the list that matches. A phishing fake invoice is also a rejected message, and if you declare the rejection before it, it comes out at level 10 and the alert you were waiting for never comes. Then the rejection at 10, and the marked-but-delivered spam at 5.
Same logic on the anchoring. ^reject$ and not reject, because Rspamd also has a soft reject action, a mere temporary deferral, which contains the word and would trigger a rejection alert for a message that’s nothing of the sort.
Don’t take this decoder on faith, test it. Wazuh has the right tool, and it lives on the manager, not on the agent.
sudo /var/ossec/bin/wazuh-logtest
You paste a real line from your rspamd.log, and it shows you what it made of it, phase by phase, decoder recognized, fields extracted, rule triggered. If srcip and rspamd_action don’t come out, it’s the extraction pattern that needs adjusting, and you’ll know in thirty seconds instead of waiting in vain for an alert that will never come. Take a rejected-phishing line for your test, it’s the case that stresses the order of your rules. On the example line above, you’re aiming for a result of this shape.
**Phase 1: Completed pre-decoding.
full event: '2026-07-14 09:41:07 #3421(rspamd_proxy) <7f2c1a>; task; rspamd_task_write_log: id: <[email protected]>, qid: <4XjK2r1Yz>, ip: 203.0.113.42, from: <[email protected]>, (default: T (reject): [16.40/15.00] [PHISHED_URL,DMARC_POLICY_REJECT,R_SPF_FAIL,BAYES_SPAM]), len: 4021, time: 128.4ms real, dns req: 22'
**Phase 2: Completed decoding.
name: 'rspamd'
srcip: '203.0.113.42'
rspamd_action: 'reject'
rspamd_score: '16.40'
**Phase 3: Completed filtering (rules).
id: '100401'
level: '12'
description: 'Rspamd, phishing detected, sender 203.0.113.42.'
groups: '['rspamd']'
firedtimes: 1
mail: false
**Alert to be generated.
Phase 2 yields your three fields, phase 3 fires rule 100401 at level 12, that’s the phishing alert you were waiting for. Don’t take this block for a capture ripped from a production machine, it’s the intended result, not a prod reading. The syslog pre-decoding applied to an Rspamd line that doesn’t have the shape of a syslog log, and the decoder’s extraction pattern, can vary depending on your Wazuh version and the exact format of your rspamd.log lines. That’s precisely what wazuh-logtest confirms on your own machine, phase by phase, before you count on the alert.
Once the test is conclusive, restart both services, each on its own machine. The manager, so it loads your decoder and your rules.
sudo systemctl restart wazuh-manager
And the mail server’s agent, so it takes its <localfile> into account.
sudo systemctl restart wazuh-agent
An IP sends a fake invoice, Rspamd scores it, rejects it, writes it to its log, the Wazuh agent reads it and ships it, the manager decodes it and raises a level-12 alert, it shows up in your dashboard next to your CrowdSec blocks and your SSH authentication failures. In Discover, you filter it on the srcip, rspamd_action and rspamd_score fields, exactly like the rest of your events.
The blind spot is plugged.
If it doesn’t work
Problem, Rspamd is running but no message gets scored
Likely cause, the milter isn’t wired in. Rspamd is waiting on its port, your mail server hands it nothing.
Fix, check that the proxy worker is indeed listening on 11332, and that smtpd_milters points to it in your Postfix configuration. Reload the mail server, not just Rspamd.
Problem, legitimate messages are rejected
Likely cause, your rejection threshold is too low, or a sender you know fails its own SPF, which happens more often than people think.
Fix, replay the message with rspamc symbols, read the symbols that pushed it up. If the culprit is a single, legitimate symbol, raise your reject threshold rather than disabling the module.
Problem, the score learns nothing, the Bayesian filter stays mute
Likely cause, Redis is unreachable. Without it, no bayes, no greylisting, no statistics.
Fix, check the Redis service and the contents of /etc/rspamd/local.d/redis.conf. rspamc stat will tell you whether the classifier has data.
Problem, nothing goes up into Wazuh
Likely cause, three suspects, and you have to sort them out before touching anything. Either the file isn’t growing, or the agent isn’t reading it, or the manager doesn’t know how to decode it. The great classic, the decoder and rules edited on the mail server instead of the manager. There, they’re useless.
Fix, is the file growing? If it’s growing, go to the manager, run a line through wazuh-logtest and see at which phase it breaks. Check while you’re at it that local_decoder.xml and local_rules.xml are indeed on that machine, and that you restarted wazuh-manager after writing them. Don’t reinstall Rspamd over an extraction-pattern issue.
What it cost you
The license, nothing. Rspamd is open source, no paid version, no enterprise tier, no message quota.
The machine, little. The engine is written in C and runs on your existing mail server, alongside Redis. At a small business’s volume, you won’t change server size for this. Keep an eye on your RAM anyway after enabling the Bayesian filter and fuzzy_check, they’re the ones that consume.
Your time, a bit. Count a short hour for the installation, the milter and the Wazuh integration.
And the real price, the calibration. It isn’t paid on installation day, it’s paid over the following two weeks, reading your own logs and raising your thresholds when a legitimate message got thrown out. A badly tuned anti-spam doesn’t cost you RAM, it costs you a customer order lost in a silent rejection. That’s the reason this tutorial starts you off lenient, at a rejection threshold of 15, and tightens afterward. The other way around, you learn the value of a false positive the hard way.
It’s the only serious spending line, and no SaaS spares you it. It too will get it wrong, you just won’t see why.
For those in a hurry
What this article does. We install Rspamd on the mail server, wire it in as a milter in front of Postfix, set the scoring thresholds, enable the anti-phishing and the enforcement of DMARC policies, then feed every verdict into Wazuh via a homemade decoder and rules. The mail layer stops being the SIEM’s blind spot.
Concretely, the commands.
- Install the stable repository,
rspamdandredis-servervia apt. - Point to Redis:
servers = "127.0.0.1:6379";in/etc/rspamd/local.d/redis.conf. - Interface password:
rspamadm pw, hash in/etc/rspamd/local.d/worker-controller.inc. Interface locally only. - Wire in the Postfix milter:
smtpd_milters = inet:localhost:11332,milter_protocol = 6,milter_default_action = acceptinmain.cf. - Thresholds:
greylist = 4; add_header = 6; reject = 15;in/etc/rspamd/local.d/actions.conf. - Enforce DMARC:
actions { quarantine = "add header"; reject = "reject"; }block in/etc/rspamd/local.d/dmarc.conf. - Anti-phishing:
openphish_enabledandphishtank_enabledin/etc/rspamd/local.d/phishing.conf. - Learning:
autolearn = true;in/etc/rspamd/local.d/classifier-bayes.conf. - Test: GTUBE message then
rspamc symbols /tmp/gtube.eml, expected actionreject. - Integrate into Wazuh, on two machines:
<localfile>on/var/log/rspamd/rspamd.logon the agent side, decoder and rules on the manager side (local_decoder.xmlandlocal_rules.xml, phishing rule declared first), validation with/var/ossec/bin/wazuh-logteston the manager, then a restart ofwazuh-managerandwazuh-agent.
In short
You started from a setup that saw your servers and blocked your network, but let the fake invoice walk in through the front door.
You installed Rspamd in front of your mail server, as a milter, so that every message is judged before being accepted. You tuned the scoring rather than a binary verdict, three levels of firmness, greylist, marking, rejection, with thresholds you control.
You armed the anti-phishing, comparison of displayed and real URLs, public databases of malicious links, enforcement of the DMARC policies of impersonated domains. And you wired all of it into Wazuh, decoder and graded rules, so that a phishing attempt raises a level-12 alert in the same dashboard as the rest.
The whole thing stays with you. No message leaves your server to be analyzed elsewhere, no provider reads your mail to tell you whether it’s clean. That’s the difference between a filter you host yourself and a mail gateway in someone else’s cloud, to whom you hand your entire company correspondence so they can protect it.
One entry door remains, and it’s the one that sits in your employees’ hands. The file downloaded without a second thought, the macro that got authorized, the binary that should never have started. In episode 6 we go down onto the Macs, and we fix the problem by deciding which applications are allowed to run.
Series recap
Seven episodes to build your sovereign security plumbing, brick by brick.
- Episode 0, the bedrock of the series. Why an SME needs a sovereign SIEM, NIS2, GDPR, and the cost of doing nothing.
- Episode 1, the foundation. The hardened VPS beyond the CLOUD Act.
- Episode 2, Wazuh all-in-one. Manager, indexer and dashboard on a single node.
- Episode 3, agents everywhere. Deployment across your servers, Macs and Windows.
- Episode 4, community network defense. Blocking known attackers before they arrive.
- Episode 5, the mail filter. Anti-spam and anti-phishing in front of your mail server. You’ve just read it.
- Episode 6, execution control on Macs. Deciding which applications are allowed to start.
- Episode 7, alerting and total cost. Calibrated notifications and an honest financial reckoning against SaaS.
Technical terms? Check the glossary.