A spam filter that reads every language and explains every decision.
Spam Scanner catches spam, phishing, scams and malware for Node.js, the command line and mail servers such as Postfix, Exim and Haraka. Every result lists the tests that fired and why.
npm install -g spamscanner
Get startedWatch the video (1:58)
- Node.js 18+
- Milter, spamd, HTTP
- Ollama, Claude, ChatGPT
- No telemetry
$ spamscanner scan message.eml SPAM score 16.3 (spam at 5.0, reject at 15.0) action: reject language: en +6.3 BAYES_999 Classifier spam probability 99.9% +5.0 PHISHING_LOOKALIKE_DOMAIN "paypa1-secure.top" imitates paypal by swapping characters +3.0 DECEPTIVE_LINK A link shows "https://www.paypal.com/signin" but goes to paypa1-secure.top +2.0 FROM_NAME_BRAND Display name says "paypal" but the message is from paypa1-secure.top $ echo $? 1
How a message is judged
Every check adds or removes points, and the total decides. Thresholds and points can be changed, and nothing is a black box: the result names each test.
Read
The message is parsed and its text normalized: styled letters, invisible characters and lookalike alphabets are undone.
Check
The classifier, link, attachment, authentication, blocklist and rule checks run at the same time, each with a timeout.
Score
Each test adds or removes points. At 5 a message is spam; at 15 a mail server should refuse it.
Ask
Close calls can go to a language model, which adds up to 6 points or removes up to 3.
- below 5accept
- 5 to 15tag as spam
- 15 and upreject
What it checks
Words are one signal. Links, senders, attachments and authentication say as much, and none of them depend on the language of the message.
A classifier for every script
Words, word pairs, links, senders, HTML and attachments, counted and combined the way SpamBayes does, with an honest "unsure" when the clues disagree.
How it decidesEvery language, carefully
Unicode word segmentation for Chinese, Japanese and Thai, disguises undone, and no penalty for a language the model saw little ham in.
LanguagesPhishing
Lookalike domains with Cyrillic or swapped letters, links whose text shows another address, brands in display names, and Cloudflare's malware resolver.
Phishing checksAttachments
Executables found by their bytes, also renamed to .pdf; double extensions; programs in ZIP files; Office macros; active PDFs; ClamAV.
Attachment checksAuthentication
SPF, DKIM, DMARC and ARC, DNS blocklists, allow and deny lists, and a rule for mail that spoofs your own domain.
AuthenticationLanguage models
Close calls go to a model: Ollama on your own machine, or Claude, ChatGPT, Gemini and any OpenAI-compatible server.
Language models
Every language, without guessing
Words are found with the Unicode rules, so languages written without spaces work, and disguises are undone before counting. These are the words the classifier counts, computed when this page was built.
A language the bundled model saw little ham in gets "unsure" instead of "spam", so ordinary mail in Arabic or Korean is never flagged for its script. Languages
Claim your fr␣ee prіze: V1agra 𝐅𝐑𝐄𝐄
- claim
- your
- free
- prize
- viagra
- free
A zero-width space, a Cyrillic і, a digit for a letter and mathematical bold letters
恭喜您获得一百万元大奖
- 恭喜
- 您
- 获得
- 一
- 百万
- 元
- 大奖
Segmented with a dictionary
ยินดีด้วยคุณได้รับรางวัลหนึ่งล้านบาท
- ยินดี
- ด้วย
- คุณ
- ได้
- รับ
- รางวัล
- หนึ่ง
- ล้าน
- บาท
Segmented with a dictionary
Поздравляем! Вы выиграли миллион
- поздравляем
- вы
- выиграли
- миллион
Lowercased by Unicode rules
Plugs into your mail server
A milter for Postfix and Sendmail, a SpamAssassin-compatible spamd server for spamc, Exim and Haraka, a content filter, an HTTP API, a TCP server, and the library.
All of them add X-Spam-Flag, X-Spam-Score, X-Spam-Status headers, and remove any a sender forged.
spamscanner milter --port 7831 --auth --subject-tag "[SPAM]"
# /etc/postfix/main.cf
smtpd_milters = inet:127.0.0.1:7831
milter_default_action = acceptA milter checks mail during the SMTP session, so spam at the reject threshold is refused before it is accepted. Postfix guide
spamscanner spamd --port 783 --auth
spamc -c < message.eml # prints 16.3/5.0 and exits 1A SpamAssassin-compatible spamd server: stop spamd, start Spam Scanner on the same port, and SpamAssassin clients keep working. spamd guide
import SpamScanner from 'spamscanner';
const scanner = new SpamScanner({authentication: true});
const result = await scanner.scan(stream, {session});
if (result.action === 'reject') {
// refuse with a 4xx or 5xx reply
}The library, for smtp-server, Haraka plugins or any service that handles mail. API reference
spamscanner http --port 7832 --token "$TOKEN"
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
--data-binary @message.eml "http://127.0.0.1:7832/scan?ip=203.0.113.5"For any language and any server that can make an HTTP request. HTTP API guide
A language model for the close calls
Clear spam and clear ham never reach it. When the score is close or the classifier is unsure, a model reads the message and answers spam, phishing, scam, malware or ham. Run one on your own machine, or use a hosted one, on any URL, port and authentication.
Instructions hidden in a message for AI filters are scored as spam, and the model is told the message is data. The end-to-end tests check it against a real model through Ollama.
- Ollama
- LM Studio
- llama.cpp server
- vLLM
- LocalAI
- Jan
- OpenAI
- Anthropic (Claude)
- Google Gemini
- Mistral AI
- Groq
- OpenRouter
- DeepSeek
- xAI
- Together AI
- Fireworks AI
- Cerebras
- Hugging Face Inference Providers
- Azure OpenAI
- Cloudflare Clef
- Cloudflare Clef Flash
- TypeSafe Jev
- TypeSafe Jev on OpenRouter
- Any decision model server
ollama pull qwen3.5:4b
spamscanner llm-test --llm ollama --llm-model qwen3.5:4b
spamscanner milter --llm ollama --llm-model qwen3.5:4bOutlined: runs on your own machine. Personal data is removed before a message goes to a hosted provider.
| Ollama | License | Size |
|---|---|---|
qwen3.5:0.8b | Apache-2.0 | 1.3 GB |
gemma4:e2b | Apache-2.0 | 4.6 GB |
qwen3.5:4b | Apache-2.0 | 3.3 GB |
qwen3.5:9b | Apache-2.0 | 6.6 GB |
gpt-oss-safeguard:20b | Apache-2.0 | 14 GB |
Recommended open models. All recommendations
Measured, and trainable
The bundled model is trained on public datasets and tested on messages it did not see. A model trained on your own mail does better: train it from mbox files, Maildirs or datasets, measure it, and teach it from "report spam" buttons.
| Held-out messages | Messages | Precision | Recall | False positives |
|---|---|---|---|---|
| English | 6,564 | 100.0% | 97.1% | 0.0% |
| Russian | 1,682 | 100.0% | 97.4% | 0.0% |
| Italian | 1,389 | 98.1% | 85.3% | 1.8% |
| German | 1,309 | 97.7% | 76.1% | 2.2% |
| Spanish | 1,281 | 97.5% | 82.6% | 2.6% |
| All | 13,840 | 99.2% | 85.1% | 0.6% |
The classifier alone, on every tenth message of its public training data, held out. Unsure messages count as missed. German, Italian and Spanish come from synthetic datasets with noisy labels. About the bundled model
Built for Forward Email
Forward Email keeps no logs of message content, so it could not send mail to an outside filtering service. Spam Scanner runs on your own servers, sends nothing anywhere unless configured to, and explains every decision without a person reading the mail. Forward Email and upgrading
A project by Forward Email, the privacy-focused email service.
