DEV Community

Cover image for My Spam Filter Can No Longer Learn
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

My Spam Filter Can No Longer Learn

This morning I asked myself a simple question: is carrying my own mail still worth it? I meant to do a cost calculation — server rent, hours spent, what the cloud alternative would invoice. I would look at the table and say either "yes" or "not anymore."

I never got to that calculation, because something came up that sent me into source code: my spam filter having stopped learning turns out not to be neglect but a natural consequence of the design. And it cannot recover on its own.

Let me walk through how I got there.

First I Counted: Fifteen, Not Nine

I had it in my head that the mail server carried nine domains. I asked the database, and the answer came back fifteen.

The records keep the dates. Seven of the fifteen were created on 7 May, the day of installation; the other eight are scattered across the months: 16 May, 5 June, 18 June, 19 June, 20 July, 24 July, 1 September, 9 September. So my memory was accurate up to the first nine — I had no recollection at all of the six domains added after mid-June.

None of them arrived by decision. Each was the small task of a day that began with "the server's already up, it'll take five minutes," and each genuinely took five minutes. The problem isn't the five minutes; it's that those five minutes get written down nowhere.

Nineteen Mailboxes, Twelve Letters a Day

The domain count says nothing on its own, so let's look at the mail.

The server has nineteen active mailboxes and fifty-nine active aliases. I counted the last seven days of postfix logs: 158 messages delivered. Of those, 86 went to Dovecot, meaning into someone's inbox; 72 went out. Bounces zero, deferred messages zero, and the queue is empty right now.

Fifteen domains and an eighteen-container stack carry twelve incoming letters a day.

The maintenance side turned out calmer than I expected too. Since May I've done three real updates: 10 August, 1 September, 24 September. Failed authentication attempts in the last twenty-four hours: six. A post office that turns over by itself and bothers nobody — up to this point, the answer to the cost question looked like "yes, worth it."

The Filter's Entire Memory Is 4.6 MB

Looking at the resource table, I noticed the scales didn't line up. mailcow's eighteen containers together hold around 3.5 GiB of RAM, and the hungriest component isn't mail, it's the virus scanner: clamd alone takes 1 GiB. Webmail 523 MB, the spam filter 420 MB, the database 401 MB. The machine has 11.4 GiB with 7.4 GiB idle, so there's no pressure.

All the mail accumulated over five months comes to 325.9 MB.

Here I had to correct myself, because I misread this at first glance. The filter has a 285.6 MB data volume and I took it for "the archive of what it has learned." It isn't: mailcow's configuration sets the classifier up with backend = "redis", so everything learned lives in Redis. That 285.6 MB is reference data the filter downloads and keeps; I didn't break down what it consists of, but it isn't a learned corpus.

What the filter has actually learned sits in the entire Redis volume: 4.6 MB.

Next to a gigabyte of virus scanner, five months of learning comes to 4.6 MB. Seeing that number made me want to look inside.

2,969 to 9

The classifier keeps two memories. The counter object in Redis says this:

Field Value
learns_ham (normal mail) 2,969
learns_spam (unwanted) 9

My first guess was "the classifier must never have switched on." rspamd's own repository ships a default requiring at least 200 samples before a class becomes usable (min_learns = 200), and nine is far below that. My guess was wrong: mailcow overrides that default and drops the threshold to five. Nine samples count as enough, so the classifier is on.

To be sure, I ran two experiments on the machine. First I composed a message resembling ordinary business correspondence and fed it to the filter; the log line came back:

BAYES_HAM(-5.35){99.65%;}
Enter fullscreen mode Exit fullscreen mode

"99.65 percent chance this is normal mail," subtracting 5.35 from the score. Then I tried the opposite: free credit, you've won, click now, last chance. An easily recognizable spam draft. The result carried no BAYES symbol at all.

My first instinct was to write "it doesn't recognize spam," but the source code gave me a more accurate phrasing. bayes.c only adds the symbol under this condition:

if (cl.processed_tokens > 0 && fabs(final_prob - 0.5) > 0.05)
Enter fullscreen mode Exit fullscreen mode

If the probability stays between 0.45 and 0.55, no symbol is added at all. So the filter didn't say "this is clean"; it said "I don't know," and went quiet. Silence means undecided, not clean.

Both messages were rejected, but Bayes rejected neither: FREEMAIL_POLICY_FAILURE at 16 points and HFILTER_HOSTNAME_UNKNOWN at 8.5. The first is a composite rule, and its definition on the server looks for this: the message comes from a free provider and DMARC did not let it through. So what screened both out wasn't their content but the sender's failure to prove its identity. The mechanism that looks at content, meanwhile, can form no opinion about spam.

The Real Finding: The Lock Is Permanent

Everything up to here could be summarized as "I neglected to train it." Reading the mechanism showed me that isn't what happened.

rspamd's autolearn has a balance guard. The code computes this ratio:

local max_ratio = 1.0 / min_balance
local ham_learns_ratio = ham_learns / (spam_learns + 1)
Enter fullscreen mode Exit fullscreen mode

mailcow sets min_balance to 0.9, which puts the threshold at 1.11.

If the ratio exceeds the threshold, new learning in that class is switched off. My ratio is 2969 / (9+1) = 296.9. The threshold is 1.11. The ham side closed long ago; there isn't a single learn call in the last seven days of logs.

The critical question is whether this lock can open by itself. For that, the ham ratio would have to fall below 1.11, which means the spam count would have to climb to roughly 2,672. It currently stands at nine.

Could the ham counter decay over time? mailcow's configuration carries expire = 7776000 — ninety days. That could have misled me, because the setting applies to tokens. I checked the counter itself in Redis: the hash named RS has a TTL of -1. It never expires.

The ham counter is frozen at 2,969 and will stay there. The spam side is technically still open — its ratio is 0.003, far under the threshold — but only nine samples have made it in over five months. At that rate, reaching 2,672 would take more than a century.

I don't have a broken filter. I have one that followed its own rules to the letter and is permanently one-sided as a result. Without intervention, it stays that way.

The Band Autolearn Cannot See

Why did the spam side never get fed, given that it's open? Here I had to rebuild my first model, because I had the flow backwards.

Autolearn looks not at the score but at the verdict. lua_verdict.lua assigns each message a verdict, and the spam verdict is only born when the action is reject. If the action is add header or rewrite subject, the verdict is junk. A ham verdict needs the action to be no action plus either a negative score or more than three negative rules firing — but autolearning it as ham additionally requires the score to sit at or below ham_threshold, which is −4.5.

mailcow's thresholds are these: greylist 7, add_header 8, reject 15. Combining them with the verdict table exposes a silent gap:

Score Action Verdict Autolearn
15 and above reject spam learns as spam
8 – 15 add header junk learns nothing
−4.5 and below no action ham would learn ham (locked)

The middle row comes down to one missing setting. A junk verdict is learned as spam only if junk_threshold is defined — and mailcow's configuration carries no such value. I searched for it on the server; it genuinely isn't there. So every borderline message, flagged as "suspicious but not enough to reject" and delivered anyway, teaches the filter nothing. That band is the most natural source of spam training there is.

There's a side effect too. mailcow's autolearn block says spam_threshold = 12.0, but the spam verdict can't arise before 15 anyway. That 12 never becomes the binding constraint; in practice the spam learning threshold is 15.

Diagram

So why did only nine of the 95 rejected messages reach the counter? I honestly don't know. A rejection alone isn't enough for the spam verdict — more than one positive rule has to have fired — and there are further conditions before learning. My log window holds not a single autolearn line, so I can't separate which rejections became samples. For the same reason I can't say how many of the nine I marked by hand.

What I can say: most of those nineteen mailboxes have no human sitting behind them. There's a domain, an MX record, a mailbox, and no reader — and as long as the junk band teaches nothing, those people are the only source that could feed the spam side.

Same Software, Five-Week-Old Server, Recoverable Lock

There was an easy way to check whether this was a software flaw: I have a second installation running the same software. A server that sits in my fleet but belongs to a client, set up in August, carrying four domains.

My server Client's server
Installed 7 May 2026 21 August 2026
Domains 15 4
Active mailboxes 19 33
Messages scanned (counter) 9,215 532
Rejection rate 1.03% 9.59%
learns_ham / learns_spam 2,969 / 9 52 / 22
Ham ratio (threshold 1.11) 296.9 2.26
Spam needed to open the lock 2,672 46

My first draft said "balance was preserved over there," which was wrong. That server's ratio is above the threshold too — 2.26, so ham learning is closed there as well. The difference is one of degree: on the client's server, opening the lock requires the spam counter to go from 22 to 46. At the current rate, a few months. On mine, the same distance is 2,672.

So what separates them isn't software, it's people. Real humans read those thirty-three mailboxes; the addresses sit in signatures, on invoices, on a website. Spam arrives, somebody sees it, somebody marks it, and the counter moves. Four domains make a working post office; my fifteen domains are an archive of good intentions.

Writing that stung a little, but it's what the measurement says: the healthiest part of my own infrastructure is the part other people use.

So How Do You Fix It

There's good news here, and finding it showed me another of my assumptions was wrong. The balance check sits only on the automatic learning path; can_learn, which governs manual teaching, contains no such check. Moving a message into the Junk folder or running rspamc learn_spam doesn't hit the lock at all. What's locked is the filter teaching itself.

The order should therefore be:

  • Add the missing setting. As long as junk_threshold is undefined, the 8-to-15 band teaches nothing. That band is the whole of borderline mail; defining it is the single change that makes autolearn meaningful.
  • Start teaching by hand. Since the balance gate doesn't block this path, cleaning out the spam folder helps directly, and the ratio improves as learns_spam climbs.
  • Reset the ham counter only if you must. For automatic ham learning to return, the ratio has to fall below 1.11, meaning spam would have to reach 2,672. Getting there by hand isn't realistic; the real remedy is resetting the counter, which also erases past training.
  • Make the mailboxes readable. I'll forward the unused addresses to a mailbox I actually check. Not clever, but the only thing the filter needs is someone looking.
  • Put the ratio under monitoring. learns_ham / (learns_spam + 1) > 1.11 can be written as an alarm rule. Until now, none of my panels asked that question.

That last item bothers me most. The server's health screen gave all eight components a hundred percent throughout, and the spam filter was on that list — correctly, because the process is up and answering. That's what the watchdog measures. Whether it is learning isn't its question. I fell into a similar gap with my MTA-STS policy that said enforce on paper while going unserved for three months; alarms ask "did it stop?" and here nothing stopped.

Two more notes. The 9,215-scan counter has been accumulating since installation and isn't on the same scale as the 167 scans of the last seven days; traffic was heavier early on, so I haven't derived a daily average from it. And that counter includes the two test messages I sent today — a small effect, but I'm the one who contaminated the number.

So, Is It Worth It?

Back to the question I started with, my answer is still yes — the reasoning changed.

It's worth it because the sending side genuinely works. The blog's alert emails leave from this server; the target in the server configuration reads mail.sysroot.app. They moved there in July: until then the newsletter went out through a third-party relay, and that relay had blocked the blog server's IP on 465 and 587. A ban set on a machine I have no access to isn't one I can lift. Having a second exit reduced the move to a configuration line. The sender address had to change as well, because the old one's domain wasn't registered on this server — the bill for not counting your domains showing up again. The same reasoning runs through what not to automate while carrying seven servers alone: a capability you keep in your own hands becomes bargaining power on the day you need it.

But the cost isn't money or hours. The cost is this: unused infrastructure doesn't break, it quietly stops teaching itself. The disk doesn't fill, the queue doesn't swell, the alarm doesn't sound. On the day you genuinely need it, you meet a filter that hasn't trained in five months — fixable, but only if somebody notices.

The Sum of Five-Minute Tasks

At the start I said I'd misremembered my domain count. Ending here, I want to make that mistake bigger, because the real lesson sits in it.

Not once while adding those six domains did I think "I'm growing the system." Each was a five-minute task and genuinely took five minutes. The system didn't grow at a moment of decision; it grew in the absence of decisions. Nobody said "let's carry fifteen domains" — it's just that after nine, nobody stopped and counted.

I think that's the most expensive habit shared by everyone running their own infrastructure: every step being small doesn't mean the total stays small. And when the total grows, the first thing lost isn't performance, it's attention.

That's why the filter never got angry, never threw an error, never printed a warning. It applied its rules and, in the end, learned to stay quiet. And it would have stayed quiet until the day someone asked it a question.

Official Sources

Top comments (0)