Why Aggregated Numbers Lie
If your proxy handles mail for nine different domains and reports a 12% spam rate, that single number tells you nothing actionable. Some domains might be at 4%; others at 35%. Some might be receiving virtually no mail at all while one is being targeted by a sustained spam campaign. Your aggregate stats hide all of that detail.
This matters because problems on individual domains often need individual responses. A spike in spam to customer-service@one-domain.com might warrant tightening that domain's spam threshold or temporarily enabling greylisting. A sudden drop in legitimate mail volume on another domain might mean DNS issues or a misconfigured upstream — invisible if you only watch the total.
What's Tracked Per Domain
As of v1.9.1 / v2.6.1, Spam Killer tracks the following counters separately for each accepted domain:
- total — all messages accepted for this domain
- ham — messages classified as ham (passed through unmodified)
- spam — messages tagged as spam
- social — messages tagged as social
- promotional — messages tagged as promotional (including double-tagged)
- rejected — messages refused at SMTP time (blacklist, rate limit, etc.)
- greylisted — messages temporarily deferred for greylisting
- forwarded — messages successfully forwarded to upstream
Note that the counters partially overlap: a tagged spam message also increments forwarded because it was successfully delivered to the upstream after tagging. The rejected counter is for messages that never made it past the SMTP conversation.
Viewing Per-Domain Stats
The spam-filter-stats CLI (Python and C versions) shows per-domain breakdowns:
spam-filter-stats --by-domain
Output looks something like:
Domain Total Ham Spam Social Promo Reject Forward
yourdomain.com 8421 6234 412 187 1421 167 8254
anotherdomain.org 3211 2891 89 34 156 41 3170
thirddomain.com 421 389 8 2 12 10 411
For more detail on a specific domain:
spam-filter-stats --domain yourdomain.com
This shows hourly volume, the breakdown by classification, top sender domains for that recipient domain, and any forwarding errors specific to that domain's upstream.
Prometheus Per-Domain Metrics
If you have the optional Prometheus metrics endpoint enabled, all per-domain counters are exposed as labeled metrics:
spamfilter_messages_total{domain="yourdomain.com",class="ham"} 6234
spamfilter_messages_total{domain="yourdomain.com",class="spam"} 412
spamfilter_messages_total{domain="yourdomain.com",class="social"} 187
spamfilter_messages_total{domain="yourdomain.com",class="promotional"} 1421
spamfilter_forward_errors_total{domain="yourdomain.com",upstream="mail1.yourdomain.com"} 3
The bundled Grafana dashboard includes per-domain panels that automatically populate from these labels — a stacked bar chart of message classifications per domain, a per-domain spam rate timeseries, and a heatmap of forwarding errors by domain and upstream.
Per-Domain Alerts
The most useful Prometheus alerts are scoped per-domain rather than global. Examples:
Spam rate spike on a single domain:
(rate(spamfilter_messages_total{class="spam"}[1h])
/ rate(spamfilter_messages_total[1h])) by (domain) > 0.5
This fires if more than 50% of messages to a single domain are scoring as spam, which usually indicates either a targeted spam campaign or a misconfiguration on the receiving side (e.g., the domain's MX changed and someone is now blasting junk at the old IP).
Volume drop on a single domain:
rate(spamfilter_messages_total[1h]) by (domain)
< 0.1 * rate(spamfilter_messages_total[1d] offset 1d) by (domain)
Fires when a domain's hourly traffic drops to below 10% of the previous day's hourly average. Often the first sign of a DNS or upstream problem, before users start complaining that mail isn't arriving.
Backward Compatibility for Existing Deployments
If you're upgrading from a pre-v1.9.0 install, your existing stats.json file has per-domain entries that were created before the social and promotional classifications existed. v1.9.1 adds backfill logic that automatically initializes missing fields when an existing domain entry is loaded — your social and promotional counters start at zero on upgrade rather than throwing a KeyError.
This was a real bug in v1.9.0 that affected production users on upgrade day. The fix in v1.9.1 (and equivalent in C v2.6.1) ensures the upgrade is invisible — historical totals are preserved, new counters start from upgrade-day-zero, and no data migration is required.
Design Note: Why Not Per-Sender Stats?
You might wonder why we track per-recipient-domain stats but not per-sender stats. The answer is cardinality. A typical proxy handles mail from tens of thousands of unique sender domains over time — tracking a counter per sender would balloon memory and stats file size without providing useful insight (most senders send only one or two messages ever).
Per-recipient-domain cardinality, by contrast, is bounded by the number of domains you accept mail for. That's typically under a hundred even for large MSPs, so the storage and memory cost is trivial and every domain has enough volume to make the stats meaningful.
If you need per-sender investigation, that's what the message log is for — grep, jq, and the bundled CLI's --from filter let you investigate any specific sender on demand without paying the cost of always-on per-sender counters.