When You Need the C Version

The Python version of Spam Killer (currently v1.9.1) handles roughly 100-200 messages per second on a single-CPU server, with all features enabled including classification, SPF/DKIM checks, MX validation, and Bayesian scoring. For the vast majority of users — anyone running a small or medium business mail server — that is far more capacity than you'll ever need.

But certain workloads push past those limits. MSPs handling mail for hundreds of customer domains. Shared hosts processing mail for thousands of mailboxes. Email service providers doing inbound filtering at scale. For these cases, the C version (currently v2.6.1) provides the same feature set with significantly lower per-message overhead.

What's Actually Different

The two versions are functionally identical from the outside. Same configuration format, same X-Spam headers, same classification logic, same statistics. You can switch between them by changing the binary your systemd unit invokes.

Internally, they're built on different foundations:

Python: asyncio-based SMTP server with a ThreadPoolExecutor for message processing. The asyncio event loop handles I/O concurrency, while CPU-bound work (regex matching, Bayesian scoring) is delegated to worker threads to avoid blocking the loop.

C: pthreads-based SMTP server with a fixed worker pool. Each connection is handled by a dedicated worker thread for the duration of the SMTP conversation. Shared state (Bayesian database, MX cache, stats) is protected by mutexes and read-write locks where appropriate.

Why this difference? The Python implementation is constrained by the GIL — even with a thread pool, only one Python thread runs Python bytecode at a time. The thread pool helps because the work is I/O bound enough that threads spend most of their time waiting on syscalls. But for CPU-heavy spam scoring, the GIL caps total throughput.

The C version has no equivalent constraint. With N worker threads on an N-core machine, all cores can be processing messages simultaneously. On an 8-core server, the C version can sustain roughly 8x the throughput of the Python version for the same per-message cost.

Memory Footprint

The other major difference is memory. The Python interpreter itself consumes 30-50MB before any application code runs. Add the asyncio infrastructure, the loaded modules (email parsing, DNS resolver, YAML parser, Bayesian model), and per-connection state, and a typical Python deployment uses 150-300MB of resident memory.

The C version starts at under 5MB and grows linearly with the working set — primarily the Bayesian database (loaded into memory for fast scoring), the MX cache (configurable size, typically 1-10MB), and per-connection buffers. A typical C deployment runs in 20-50MB total.

For a single mail server this difference is invisible — modern servers have gigabytes of RAM. For a containerized deployment where you're running many small mail proxies on shared infrastructure, the difference matters.

The MX Cache: A Small Implementation Detail That Matters

One concrete example of where C's lower-level control pays off: the MX cache used by promotional classification.

The Python version uses a dict protected by a threading.Lock. Reads and writes both acquire the lock. Under high concurrency this becomes a contention point — every classification check needs the lock, and the GIL plus the lock combine to limit how many threads can be doing classification simultaneously.

The C version uses a fixed-size open-addressed hash table protected by a pthread_rwlock_t. Reads acquire only a shared lock, allowing many threads to read simultaneously; writes acquire an exclusive lock briefly to insert or evict entries. In a workload where 95% of MX lookups hit the cache (typical after warm-up), this means 95% of classification checks take the shared read lock and never block each other.

The performance difference shows up most clearly under bursty load. Both versions handle steady-state traffic fine; the C version handles bursts (sudden 10x spikes from a campaign or attack) without falling behind.

Dependencies and Build

The C version has more native dependencies than the Python version: OpenSSL for TLS, cJSON for stats serialization, libyaml for config, libspf2 for SPF verification, plus standard libc and libresolv. The install script handles dependency installation on Rocky Linux, RHEL, AlmaLinux, and CentOS Stream.

Build is a single make invocation; install is sudo bash install.sh from the source directory. The result is a single static-ish binary at /usr/local/bin/spam-filter plus the systemd unit, config templates, and the spam-filter-stats CLI.

For Debian/Ubuntu, manual dependency installation is currently required. We're tracking that as an issue and plan to add proper package builds in a future release.

When the Python Version Is Better

Despite the performance advantage, the C version isn't always the right choice:

You want to read or modify the source. Python is dramatically more accessible. If you're going to fork the code to add custom rules or change behavior, the Python version is much easier to work with.

You're running on a non-RHEL distro and don't want to build from source. The Python version installs on basically anything that has Python 3.6+; the C version requires a supported distro or willingness to handle the build dependencies yourself.

Your mail volume is modest. If you're handling under 50 messages per second sustained, the Python version's overhead is irrelevant. Stick with what's easier to operate.

Both versions are kept in feature parity. New capabilities ship in both at the same time, configured the same way. You can switch between them safely if your needs change.