-
Notifications
You must be signed in to change notification settings - Fork 3
Stopping Spam
This page began as a chapter of the 6.2.10 manual and has been corrected against the code of 6.2.28. The Control Panel's pages are grouped differently now, so the paths below use today's groups; the TLS ports 465, 993 and 995 exist only after you create them on the TCP/IP ports page (a fresh install seeds 25, 587, 110 and 143); and everything added since 6.2.10 is in Changes-Since-6210. Where a value here disagrees with the Settings Reference, which is generated from the code, the reference is right.
hMailServer filters in layers. Each one is cheap, each catches a different kind of junk, and none of them is a verdict on its own: every test contributes a score, and two thresholds decide what the score means.
Open Spam & virus filtering → Anti-spam settings:

SURBL servers checks the links inside a message body against URL blocklists:

Start here instead if you only want the current state. Spam & virus filtering → Spam filtering overview is a read-only page that puts the whole configuration on one screen — which checks are on, what each scores, what the thresholds then do, and which checks are switched on but cannot run. It exists precisely because the answer to "what will my spam filtering do to a message" was previously spread over five editors (
Tools/ControlPanel/Views/SpamOverviewView.cs).
Everything on this page happens inside one of two moments: before the body
(pre-transmission, at MAIL FROM or RCPT TO) and after the body (post-transmission,
after DATA or the last BDAT). The diagram is drawn from
Server/SMTP/SMTPConnection.cpp and Server/Common/AntiSpam/SpamProtection.cpp.
flowchart TD
A["SMTP session accepted"] --> B{"Does the matching IP range<br/>have spam protection on?"}
B -- no --> SKIP["No spam test runs at all"]
B -- yes --> C{"Session authenticated?"}
C -- yes --> SKIP
C -- no --> D{"Envelope sender or IP on the<br/>anti-spam white list?"}
D -- yes --> SKIP
D -- no --> E{"Is the connecting IP<br/>a configured incoming relay?"}
E -- yes --> POSTALL["Everything is deferred:<br/>both phases run after DATA"]
E -- no --> PRE["Pre-transmission tests run<br/>at MAIL FROM by default"]
PRE --> SCORE1{"Total score at or above<br/>the delete threshold?"}
SCORE1 -- yes --> REJ550["550 5.7.1 with the first failing test's text"]
SCORE1 -- no --> GREY{"Greylisting armed and<br/>not bypassed for this triplet?"}
GREY -- "blocked, real sender" --> R451["451 Please try again later"]
GREY -- "blocked, null sender" --> DELAY["Deferred to end of DATA:<br/>450 Please try again later"]
GREY -- pass --> DATA["Body accepted"]
POSTALL --> DATA
DATA --> POST["Post-transmission tests run:<br/>SURBL, DKIM, ARC, DMARC,<br/>SpamAssassin, filter hook"]
POST --> SCORE2{"Total score at or above<br/>the delete threshold?"}
SCORE2 -- yes --> Q{"Quarantine enabled<br/>and the store accepted it?"}
Q -- yes --> Q250["250 Queued for delivery,<br/>message held for review"]
Q -- no --> REJ554["554 5.7.1 with the first failing test's text"]
SCORE2 -- no --> MARK{"Total score at or above<br/>the mark threshold?"}
MARK -- yes --> TAG["X-hMailServer-Spam: YES,<br/>reason headers, optional subject prefix"]
MARK -- no --> CLEAN["Delivered untouched"]
TAG --> ACCT["Per-account overrides re-judge<br/>each recipient's own copy"]
CLEAN --> DELIVER["Delivered"]
SKIP --> DELIVER
ACCT --> DELIVER
Three things in that picture surprise people, and all three are deliberate:
-
An authenticated session is never spam-tested.
SMTPConnection::GetDoSpamProtection_returns false the momentisAuthenticated_is true. Filtering your own users' outbound mail is what the per-account sending limits and the Security Hardening page's rate limits are for. -
A whitelisted sender skips the tests entirely, not merely the scoring — so SPF is
not evaluated, no
Authentication-Resultsverdict is recorded, and nothing is written into the reason headers. -
Where the pre-transmission tests run is a setting.
DNSBLChecksAfterMailFromdefaults to1, which runs them atMAIL FROM. Set it to0and they run at the firstRCPT TOinstead — later, so the client has named a victim before it is refused, which leaks less to an address-probing client but accepts more of the conversation first (SMTPConnection.cpp, the twoDoSpamProtection_(SPPreTransmission, …)call sites).
Each test that fails adds a score. Two thresholds decide the outcome, and both are on Spam & virus filtering → Anti-spam settings → General:
| Threshold | Setting | Default | What it does | What "0" means |
|---|---|---|---|---|
| Spam mark threshold | AntiSpam.SpamMarkThreshold |
5 | At or above it, the message is delivered but tagged: X-hMailServer-Spam: YES, X-hMailServer-Reason-* headers, and optionally a subject prefix |
Never mark |
| Spam delete threshold | AntiSpam.SpamDeleteThreshold |
20 | At or above it, the message is refused during the conversation — or quarantined, if quarantine is on and the verdict came after the body | Never refuse |
| Max size to scan | AntiSpam.MaximumMessageSize |
1024 KB | Post-transmission tests are skipped entirely for a message larger than this | No limit of its own — but the MIME parser's own 80 MB ceiling still applies, and above it the message is never scanned |
The maximum of the two thresholds is also the early-out. SpamTestRunner::RunSpamTest
stops calling further tests the moment the running total reaches it, which is why the
order in 10.3 matters: a message condemned by the blocked-senders list never pays for a
single DNS lookup.
The early-out has one trap, and it is documented in the code. With both thresholds at 0 the early-out condition would be true before any test had run, so the pipeline is written to skip it entirely when
iMaxScoreis 0. That matters even though no action is armed:SpamProtection::PerformGreyListinglooks for the SPF result in the result set to honour "bypass greylisting on SPF success", and SPF is the sixth test (SpamTestRunner.cpp, the comment above thebreak).
Sensible starting values: mark at 5, delete at 20 — the shipped defaults. Set
delete to 0 while you are learning what your mail looks like.
Start by tagging, not deleting. Run for a fortnight with deletion off and read what got tagged. Only then turn on deletion, and set the threshold well above anything legitimate has scored.
With the default AddHeaderSpam and AddHeaderReason both on, a marked message reaches
the mailbox carrying:
X-hMailServer-Spam: YES
X-hMailServer-Reason-1: Blocked by SPF. (example.com does not designate 203.0.113.9) - (Score: 3)
X-hMailServer-Reason-2: Rejected by Spamhaus. - (Score: 3)
X-hMailServer-Reason-Score: 6
Subject: [SPAM] Your invoice
Only failing tests get a Reason-N line; a pass or a neutral verdict is not mentioned
(SpamProtection::AddSpamScoreHeaders). X-hMailServer-Reason-Score is the sum of the
failing tests only — with AddHeaderReason off it is not written at all, and that has a
consequence in 10.11.
The subject prefix is off by default; its text is [SPAM]
(antispamprependsubjecttext), and it is not prepended twice if it is already there.
This is the registration order in SpamTestRunner::LoadSpamTests. Nothing reorders it at
runtime; the phase filter simply skips the tests that belong to the other moment.
| # | Test | Phase | Switched on by | Default | Score setting | Default score | Cost per message |
|---|---|---|---|---|---|---|---|
| 1 |
Blocked senders (SpamTestSenderBlacklist) |
before body | a non-empty list on Blocked senders | list empty | per entry | 100 on a new entry | one walk of an in-memory list |
| 2 |
DNS blacklists (SpamTestDNSBlackLists) |
before body | at least one active row on DNS blacklists | two rows seeded, both inactive | per list | 3 for both seeded rows | one DNS lookup per active list |
| 3 |
HELO host (SpamTestHeloHost) |
before body | AntiSpam.CheckHostInHelo |
off | AntiSpam.CheckHostInHeloScore |
2 | one A/AAAA lookup |
| 4 |
PTR record (SpamTestPTR) |
before body | AntiSpam.CheckPTR |
off | AntiSpam.CheckPTRScore |
1 | one PTR lookup plus one forward lookup |
| 5 |
Sender MX (SpamTestMXRecords) |
before body | AntiSpam.UseMXChecks |
off | AntiSpam.UseMXChecksScore |
2 | one or two MX lookups |
| 6 |
SPF (SpamTestSPF) |
before body | AntiSpam.UseSPF |
off | AntiSpam.UseSPFScore |
3 | up to 10 DNS-querying terms |
| 7 |
SURBL (SpamTestSURBL) |
after body | at least one active row on SURBL servers | one row seeded, inactive | per list | 3 | up to 15 URLs × one lookup each |
| 8 |
DKIM (SpamTestDKIM) |
after body | AntiSpam.DKIMVerificationEnabled |
off | AntiSpam.DKIMVerificationFailureScore |
5 | one TXT lookup per signature |
| 9 |
ARC (SpamTestArc) |
after body |
AntiSpam.ArcFilteringEnabled and a non-empty trusted-sealer list and DMARC on |
off, list empty | fixed | minus the DMARC failure score | header parse; DNS and crypto only for a trusted chain |
| 10 |
DMARC (SpamTestDMARC) |
after body | AntiSpam.DMARCEnabled |
on | AntiSpam.DMARCFailureScore |
5 | policy lookup plus SPF and DKIM evaluation |
| 11 |
SpamAssassin (SpamTestSpamAssassin) |
after body | AntiSpam.SpamAssassinEnabled |
off |
AntiSpam.SpamAssassinScore, or the merged score |
5 | the whole message over a socket |
| 12 |
Filter hook (SpamTestFilterHook) |
after body | a non-empty FilterHookUrl
|
empty | engine's own score, or FilterHookRejectScore
|
100 for a "reject" verdict | the whole message over HTTP |
DMARC is the only test on by default, and at its default failure score of 5 a DMARC failure marks a message (mark threshold 5) without refusing it (delete threshold 20). That is the shipped posture: tell the recipient, do not destroy the mail.
The first and last positions are chosen, not accidental. The blocked-senders list is first because it is the cheapest verdict in the pipeline and, at the default entry score of 100, it crosses the delete threshold on its own so nothing after it ever runs. The filter hook is last because it is the most expensive — a whole message across a socket and a wait on somebody else's process.
ARC must run immediately before DMARC, and the code says why: its only output is a negative offset of the DMARC failure score, and a test registered after DMARC would be skipped by the early-out in exactly the case the offset exists for.
| Test | The question it asks | What makes it fail | What makes it silently do nothing |
|---|---|---|---|
| Blocked senders | Is MAIL FROM on the administrator's list? |
An exact address match, or a domain entry matching the domain or any subdomain at a label boundary | An empty envelope sender (<>) is never matched — bounces are exempt |
| DNSBL | Does <reversed IP>.<list host> resolve to an address the row expects? |
Any returned A record matching the row's expected-result pattern | An originating IP of 0.0.0.0/unset; a resolver that answers nothing |
| HELO host | Do the A/AAAA records of the HELO name include the connecting IP? | The name resolves and none of its addresses match | An empty HELO, a connection from the loopback range, or a DNS failure — which is read as "not spam" |
| PTR | Does the connecting IP have a PTR whose forward lookup comes back to it? | No PTR, or a PTR whose forward lookup does not include this IP | A DNS failure is read as "not spam" |
| Sender MX | Does the envelope sender's domain publish MX records? | Neither the host nor its organizational domain has any MX | An empty envelope sender; a sender domain that is an IP literal; a failed DNS query |
| SPF | Does the sender's SPF record authorise this IP? | A hard Fail only — SoftFail, Neutral, None, TempError and PermError score nothing |
An originating IP of 0.0.0.0/unset |
| SURBL | Is any domain in a body URL listed? | A listed domain, checked as <domain>.<list host>
|
No URLs; more than 15 URLs (the rest are skipped); 10 seconds elapsed; a failed lookup abandons the whole list |
| DKIM | Does a signature verify? |
PermFail only. TempFail and Neutral score nothing |
No signature at all is None, not a failure |
| ARC | Did a chain sealed by a trusted domain record a pass at its first hop? | It never fails — it can only subtract | An untrusted sealer, a chain that does not validate, a DNS temp error, more than one From, or DMARC not about to fail |
| DMARC | Is the From domain aligned with a passing SPF or DKIM? |
A p=reject or p=quarantine policy the message failed — or more than one From header field, which is scored on its own |
No policy published; p=none (logged only); a policy lookup that temp-fails |
| SpamAssassin | Did spamd tag it? |
X-Spam-Status: Yes in the returned message |
spamd unreachable, unresolvable, or slower than the timeout |
| Filter hook | What score did the engine return? | Any non-zero score, or an action of reject/soft reject
|
Message above FilterHookMaxMessageSizeKB (10 MB); an unanswered check, unless FilterHookFailClosed=1
|
Every DNS-backed check fails open. That is the right call per message — refusing mail because our resolver is sick would be worse — but it is the wrong thing to do silently, because a broken resolver then looks exactly like a run of clean mail. See 10.13.
Greylisting is not a score. It is a temporary refusal of a triplet — sender address, recipient address, connecting IP — that this server has not seen before. A real MTA queues the message and retries; most spam engines do not.
Configure it under Spam & virus filtering → Anti-spam settings → Greylisting.
stateDiagram-v2
[*] --> Unseen
Unseen --> Blocked: first sight of this sender, recipient and IP
Blocked --> Blocked: retry before the block ends, answered 451
Blocked --> Confirmed: retry after the 30-minute block, message accepted
Confirmed --> Confirmed: every later message pushes the delete time out
Blocked --> [*]: no retry within 24 hours, row deleted
Confirmed --> [*]: nothing seen for 864 hours, row deleted
| Setting | Control Panel label | Default | Meaning |
|---|---|---|---|
AntiSpam.GreyListingEnabled |
Enable greylisting | off | Master switch |
AntiSpam.GreyListingInitialDelay |
Initial delay (minutes) | 30 | How long a new triplet is refused for |
AntiSpam.GreyListingInitialDelete |
Delete unconfirmed after | 24 hours | An unconfirmed triplet is forgotten after this, so the delay starts again |
AntiSpam.GreyListingFinalDelete |
Delete confirmed after | 864 hours (36 days) | A confirmed triplet's expiry, pushed forward on every accepted message |
AntiSpam.BypassGreylistingOnSPFSuccess |
Bypass on SPF success | on | Needs the SPF test to be enabled and to have actually run |
AntiSpam.BypassGreylistingOnMailFromMX |
Bypass when sender matches MX | off | Costs an A and an MX lookup of the sender's domain per message |
GreylistingRecordExpirationInterval (INI) |
Record expiration interval | 240 minutes | How often the cleaner task deletes expired rows |
GreylistingEnabledDuringRecordExpiration (INI) |
Keep greylisting active during expiration | on | See the note below |
Greylisting is also skipped when the recipient domain has it switched off — the domain dialog's Enable greylisting for this domain checkbox — and when the connecting IP is on the greylisting white list, or the sender or IP is on the anti-spam white list.
The refusal codes differ by moment, and the difference is deliberate: a greylisted
recipient normally gets 451 Please try again later at RCPT TO, but when the envelope
sender is empty the refusal is postponed to the end of DATA and answered 450,
because an empty sender at RCPT TO is very often another server's callout verifying that
the address exists, and delaying that is unhelpful (SMTPConnection.cpp,
rejected_by_delayed_grey_listing_).
GreylistingEnabledDuringRecordExpiration=0currently does nothing.GreyListCleanerTask::DoWorkcallsSetGreylistingTemporarilyDisabled(true)around the expiry sweep, but in 6.2.28 nothing anywhere readsAntiSpamConfiguration::GetGreylistingTemporarilyDisabled()— grep the tree and the only hits are the setter, the getter's own definition and the header. Greylisting therefore stays in force during the sweep whatever this setting says. Verified inServer/SMTP/GreyListCleanerTask.cppandServer/Common/AntiSpam/AntiSpamConfiguration.cppat the 6.2.28 release commit.
Greylisting is remarkably effective and costs nothing, but it delays the first message from every new sender by up to the initial delay. Warn your users, or leave it off for a customer-facing domain and rely on the score-based layers.
The button Clear greylisting triplets on that tab empties the table, which makes every sender new again — useful after a migration, unhelpful on a Monday morning.
Spam & virus filtering → DNS blacklists is the best effort-to-reward ratio on this
page. zen.spamhaus.org alone removes most spam.

Two rows are seeded by a fresh install and both arrive inactive — you must tick them:
| DNS host | Expected result | Reject message | Score |
|---|---|---|---|
zen.spamhaus.org |
127.0.0.2-8|127.0.0.10-11 |
Rejected by Spamhaus. | 3 |
bl.spamcop.net |
127.0.0.2 |
Rejected by SpamCop. | 3 |
The expected result field is richer than it looks (Server/SMTP/BLCheck.cpp):
-
|separates alternatives. - A trailing range on the last octet:
127.0.0.2-8means.2through.8. - Wildcards are matched with the server's own wildcard matcher, so
127.0.0.*works. - A malformed range is reported as error HM5342 and that alternative is dropped; the rest of the field still applies.
The lookup is <reversed IP>.<DNS host>, and every lookup — match or not — is written to
the TCP/IP log:
DNS lookup: 9.113.0.203.zen.spamhaus.org, 1 addresses found: 127.0.0.4, Match: 1
That line is the fastest way to prove a listing to a sender who disputes it, and the
reason the DNSBL rows carry per-list reject messages: the text of the matching row is what
the sender is told in the 550.
Two commercial-terms notes that are not the server's business but will become yours. Public mirrors of the large lists rate-limit or refuse queries from resolvers that use a public DNS service, and several require a paid data feed above a query volume. If a list suddenly stops matching anything, check the list operator's terms before you check this server.
SURBL asks a different question from DNSBL: not who sent this, but what is it asking me to click. It runs after the body, and it is a post-transmission test for that reason.
SURBL::ExtractUrls pulls candidate host names out of the plain-text body and the HTML
body with a regular expression, strips soft line breaks, and drops a small allow-list of
hosts that ordinary mail composers embed on their own — w3.org, w3c.org,
schemas.microsoft.com, fonts.googleapis.com, fonts.gstatic.com. For each URL it
checks both the full host and the host with its top label trimmed.
Bounds, from Server/Common/AntiSpam/SURBL.cpp:
- collection stops once more than 15 distinct addresses have been found;
- the lookup loop for a list stops once more than 15 have been checked;
- the whole run for one list aborts after 10 seconds;
- the first failed lookup abandons the rest of the list and reports through the diagnostics described in 10.13.
One row is seeded and arrives inactive: multi.surbl.org, reject message
"Rejected by SURBL.", score 3.
These four are one story, and the order they run in is the story.
sequenceDiagram
participant C as Sending server
participant S as hMailServer
participant D as DNS
C->>S: MAIL FROM and RCPT TO
S->>D: TXT for the envelope sender domain
D-->>S: v=spf1 ...
Note over S: SPF verdict recorded.<br/>Only a hard Fail scores 3.<br/>Everything else scores nothing.
C->>S: DATA, message body
S->>D: TXT for each selector._domainkey.d
D-->>S: DKIM public keys
Note over S: Only PermFail scores 5.<br/>Passing d= domains are collected.
S->>S: ARC: parse the chain, check every<br/>sealer against the trusted list
S->>D: TXT for _dmarc.From-domain
D-->>S: v=DMARC1 p=reject ...
Note over S: Alignment computed against the<br/>SPF and DKIM results above.
S->>S: ARC offset applied if and only if<br/>DMARC is about to fail on a trusted chain
S-->>C: 250, 554 or 250-and-quarantined
SPF (AntiSpam.UseSPF, off, score 3). Only a hard Fail scores. The evaluation is
bounded at 10 DNS-querying terms (SPFMAXLOOKUPS in RMSPF.cpp) and, separately, at
SpfVoidLookupLimit void lookups — terms whose query returns nothing at all — which
defaults to 2, the RFC 7208 §4.6.4 value. Raising it is safe; 0 switches that limit
off. Whatever the score, the verdict is recorded for the Received-SPF and
Authentication-Results headers when those are enabled.
DKIM (AntiSpam.DKIMVerificationEnabled, off, score 5). Only PermFail scores; a
message with no signature is None and costs nothing. RSA keys shorter than 1024 bits
are refused outright (RFC 8301), and rsa-sha1 signatures are treated as invalid unless
DkimAcceptSha1=1. Every verified d= is carried forward, because DMARC alignment needs
to know whose signature passed, not merely that one did.
DMARC (AntiSpam.DMARCEnabled, on, score 5). Two things score:
- the published policy is
rejectorquarantineand the message failed it.p=nonefailures are logged and score nothing — the domain owner asked to be told, not obeyed; -
the message carries more than one
Fromheader field. That is scored at the DMARC failure score before any alignment is computed, and the reason is inSpamTestDMARC.cpp: DKIM signs the lastFromwhile the header reader answers with the first, so an attacker who signs their ownFromand prepends the victim's gets a passing, aligned DMARC verdict on a message the recipient's client displays as the victim's.
Organizational-domain discovery for relaxed alignment uses the RFC 9989 DNS tree walk
by default (DmarcTreeWalkEnabled=1, up to eight queries per domain, cached five
minutes), falling back to the compiled public suffix list when the walk cannot complete.
Every message evaluated against a published policy is counted for aggregate reporting, passes included — see Encryption and Certificates §9.9 for the reporting side.
ARC (AntiSpam.ArcFilteringEnabled, off; AntiSpam.ArcTrustedSealers, empty).
Forwarding breaks SPF, because the envelope sender changes, and often breaks DKIM, because
the body is modified. A valid ARC chain carries the original verdict. The offset is
exactly minus the DMARC failure score, never more, so a repaired message scores the
same as one that passed DMARC outright and every other test's score still stands.
flowchart TD
A{"Message has ARC-Seal headers?"} -- no --> N["Nothing happens"]
A -- yes --> B{"ArcFilteringEnabled?"}
B -- no --> N
B -- yes --> C{"Trusted-sealer list<br/>non-empty?"}
C -- no --> N
C -- yes --> D{"DMARC failure score<br/>greater than zero?"}
D -- no --> N
D -- yes --> E{"Exactly one From<br/>header field?"}
E -- no --> N
E -- yes --> F{"Chain parses?"}
F -- no --> N
F -- yes --> G{"Every sealer domain<br/>on the trusted list?"}
G -- no --> N
G -- yes --> H{"First hop recorded<br/>dmarc, dkim or spf pass?"}
H -- no --> N
H -- yes --> I{"Full RFC 8617 validation<br/>of seals and message signature?"}
I -- no --> N
I -- yes --> J{"Will DMARC actually<br/>fail reject or quarantine?"}
J -- no --> N
J -- yes --> K["Add minus the DMARC failure score"]
The trusted-sealer list is not an option of ARC — it is ARC. Anyone can fabricate a whole chain and seal it with keys they publish in their own DNS, and it will validate perfectly; RFC 8617 §7.1 says a passing chain conveys no trust by itself. With the list empty this test does nothing at all, deliberately. Matching is exact, so a suffix of a trusted name is not trusted.
For serious content filtering, connect SpamAssassin. hMailServer speaks the spamd protocol over TCP to any SpamAssassin daemon, on this machine or on a Linux host.
Set the host and port under Anti-spam settings → SpamAssassin (127.0.0.1, port
783 are the shipped defaults) and press Test SpamAssassin connection before you
leave the page.
| Setting | Default | What it does |
|---|---|---|
AntiSpam.SpamAssassinEnabled |
off | Master switch |
AntiSpam.SpamAssassinHost |
127.0.0.1 |
Resolved with the server's own resolver; an unresolvable name is error HM5507 and the test is abandoned |
AntiSpam.SpamAssassinPort |
783 |
|
AntiSpam.SpamAssassinMergeScore |
off | Off: a tagged message scores the fixed score below. On: the integer part of SpamAssassin's own score= is used |
AntiSpam.SpamAssassinScore |
5 | The fixed score |
SAMinTimeout / SAMaxTimeout (INI) |
30 / 90 seconds | The effective timeout moves between them with server load |
SAMoveVsCopy (INI) |
off | Move the spool file instead of copying it — only correct for a spamd on the same volume |
SpamAssassinUser (INI) |
empty | The User: header of the spamd request, i.e. whose preferences spamd applies |
SpamAssassinUserFromRecipient (INI) |
off | Scan a single-recipient message as that recipient. A message with several recipients is scanned once, under the fixed profile |
SpamAssassinLearnOnMove (INI) |
off | Moving a message into the Junk folder reports it to sa-learn as spam; moving it out reports ham |
Mechanics worth knowing:
- hMailServer adds a
Return-Pathheader before the scan, so SpamAssassin's own SPF rules have an envelope sender to work from, and removes it again afterwards. - The verdict is read from
X-Spam-Status, so SpamAssassin's own headers survive into the delivered message. - If the scan does not complete within the timeout, error HM5508 is reported and the message is accepted with no SpamAssassin verdict.
- If the message cannot be reloaded after the scan (too large for the MIME parser, or malformed) the file is left exactly as SpamAssassin wrote it and error HM5509 is reported — deliberately, because writing an empty in-memory body back would truncate the message.
FilterHookUrl puts rspamd — or anything else that answers JSON over HTTP — in the path of
every message this server accepts. The message is the request body; the envelope,
connecting address and HELO travel as request headers, which is the shape rspamd's own
check endpoint already expects, so pointing this at http://127.0.0.1:11333/checkv2 needs
no adapter.
| Setting | Default | Notes |
|---|---|---|
FilterHookUrl |
empty | Plain HTTP only; the whole message is sent, so keep the engine local or on a trusted network |
FilterHookTimeoutSeconds |
10 | Bounds the connection as well as the reply — this runs while the sending server waits for its answer to DATA
|
FilterHookFailClosed |
off | Off: an unanswered check accepts the message. On: it scores FilterHookRejectScore and reports error HM5541
|
FilterHookRejectScore |
100 | What an action of reject or soft reject is worth |
FilterHookMaxMessageSizeKB |
10240 | A larger message is passed without being sent. 0 removes the ceiling |
The verdict arrives as a score, not as a separate notion of spam, so it lands beside SPF, DKIM and DMARC and your existing thresholds decide what happens. A fractional score is rounded, not truncated: an engine answering 4.6 means something closer to 5.
Three separate lists, for three different jobs. All three are under Spam & virus filtering.
| Page | What it does | Matching | Where it is consulted |
|---|---|---|---|
| White list | Exempts a sender from every spam test | IP range plus an optional address wildcard; an empty address or * means any sender in the range |
SpamProtection::IsWhiteListed, before the pipeline and again before greylisting |
| Blocked senders | Scores an envelope sender | Full address: exact only, no wildcards. Domain: the domain itself or any subdomain at a label boundary. @example.com is accepted as a spelling of example.com
|
Test 1 of the pipeline |
| Greylisting white list | Exempts an IP from greylisting only, still spam-scored | IP address |
GreyListing::GetAllowSend, first thing |


Three design notes taken straight from the code, because all three surprise people:
-
A blocked-senders match does not name the entry in the refusal. The
550says only "Sender address is blocked." The matched entry — and its score — goes to the debug log instead, so that a probe cannot learn whether the address or the whole domain is listed. - The blocked-senders list never matches the null sender. A bounce cannot be blocked by it.
- If the blocked-senders table cannot be read, the cache keeps whatever it already holds rather than replacing it with an empty list — otherwise one database hiccup would silently unblock every listed sender for the life of the process.
Quarantine (QuarantineEnabled, off; QuarantineRetentionDays, 30) changes what the
delete threshold does. Without it, a message over the threshold is refused during the
conversation and the sender is told. With it, a message over the threshold that was judged
after the body is answered 250 Queued for delivery and held for review on the
Spam & virus filtering → Quarantine page.
That is a different decision, not a tidier one:
- the sender believes the message was delivered and will not retry, which is what makes a false positive recoverable with no bounce to a probably-forged return path;
- the review queue becomes the only place that message exists, which is why a quarantine that fails to store falls through to refusing rather than accepting. Silently accepting mail that was not stored would turn a spam refusal into silent deletion.
A verdict reached before the body has no message to hold, so it stays a refusal. That is arithmetic, not a limitation waiting to be fixed.
Per-account overrides are on the account dialog's Spam tab and apply at delivery, to that account's own copy only:
| Account setting, as the dialog labels it | -1 |
0 |
A positive value |
|---|---|---|---|
| Mark threshold override | use the global setting | never mark this account's copies | re-judge: below it, the marking is removed from this copy |
| Delete threshold override | off | off | at or above it, the copy is not delivered — or is quarantined, if quarantine is on |
| Apply the server's spam filtering to this account | — | unticked: a classified message is still delivered here, unmarked | — |
Two guards on that feature are worth stating because they change what you should configure:
-
The overrides only ever look at the score this server recorded, in
X-hMailServer-Reason-Score, and only while Add X-hMailServer-Reason header is on. With that setting off the value in the file is whatever the sender put there, and a spammer could attachX-hMailServer-Reason-Score: 1to steer a recipient's own override into un-marking their mail. So with reason headers off, the overrides decline to act. - A message the global settings refused, quarantined or greylisted is stopped for every recipient before any per-account setting can run. Overrides can only ever soften a marking, or harden a delivery.
| Moment | Client sees | Application log |
|---|---|---|
| Score over delete threshold, before the body | 550 5.7.1 <text of the first failing test> |
hMailServer SpamProtection rejected RCPT (Sender: …, IP:…, Reason: …) |
| Score over delete threshold, after the body | 554 5.7.1 <text of the first failing test> |
as above |
| Same, but quarantined | 250 2.0.0 Queued for delivery |
hMailServer SpamProtection quarantined a message (Sender: …, IP: …, Score: N, Reason: …) |
| Greylisted, real sender | 451 Please try again later. |
nothing — the sender is being asked to retry, not refused |
| Greylisted, null sender |
450 Please try again later. at end of DATA
|
nothing |
| Relay not permitted | 550 Delivery is not allowed to this address. |
— |
| Authentication required for this route | 530 SMTP authentication is required. |
— |
| Per-account delete threshold reached | — (already accepted) | SMTPDeliverer - Message N: not delivered to <addr> - the score S reached the account's spam delete threshold T. |
| Per-account filtering off | — | SMTPDeliverer - Message N: delivered to <addr> unmarked - spam filtering is disabled for the account. |
Per-test timings are in the debug log, one line per test:
Spam test: SpamTestSPF, Score: 3, Time: 41 ms
Spam test: SpamTestSURBL, Score: 0, Time: 12 ms
Total spam score: 3
A test that takes 10 seconds or more is promoted to the application log instead, and
that promotion exists for a reason: post-transmission tests run on the thread that will
send the 250, so one slow test is what makes a relayed message time out at the sender.
Every DNS-backed check fails open. A resolver that has stopped answering therefore
produces scores of zero, no rejections and no errors — indistinguishable from a run of
clean mail. AntiSpamDiagnostics exists to make that visible: DKIM, DMARC and SURBL each
write one application-log line at most every 15 minutes when their lookups stop
completing. For example:
DMARC: The policy lookup for example.com did not complete. Policies cannot be applied
while that continues, and affected messages are accepted without a DMARC verdict.
SURBL: The lookup of example.com.multi.surbl.org did not complete. URI blacklists cannot
be checked while that continues, and affected messages are accepted without a SURBL verdict.
Deliberately not an error-log entry: it is an operational condition, not a defect in the
server (Server/Common/AntiSpam/AntiSpamDiagnostics.h).
Error numbers that belong to this page:
| Code | Source | Meaning |
|---|---|---|
| HM5342 | BLCheck::ExpandAddresses |
A DNSBL row's expected-result field could not be parsed |
| HM5507 | SpamTestSpamAssassin::RunTest |
The SpamAssassin host name could not be resolved; the test was abandoned |
| HM5508 | SpamTestSpamAssassin::RunTest |
The scan did not complete in time, or spamd is not running; the message was accepted without a verdict |
| HM5509 | SpamTestSpamAssassin::RunTest |
The message could not be reloaded after the scan; it was left unchanged |
| HM5541 | SpamTestFilterHook::RunTest |
The filtering engine did not answer and FilterHookFailClosed is on, so mail is being refused |
| HM5701 / HM5702 | SURBL::Run |
The body could not be parsed for URLs |
| HM6350 | LocalDelivery::ApplyAccountSpamOverrides_ |
A per-account quarantine failed; the marked copy was delivered instead |
There is no single right answer, but this is a defensible starting point for a small server, in the order you should turn things on:
- Leave the thresholds at 5 and 20 and leave DMARC on. Watch for a fortnight.
-
Activate
zen.spamhaus.orgon the DNS blacklists page. Score 3. - Turn on SPF (score 3) and DKIM verification (score 5). Neither refuses anything on its own at these scores; together with a DNSBL hit they reach 11, still under the delete threshold.
-
Turn on
AuthenticationResultsEnabled(Encryption and Certificates §9.8) so that every delivered message records what the server concluded. This is what makes the next step diagnosable. - Add greylisting if your users can tolerate a first-message delay. Leave "bypass on SPF success" on.
- Then, and only then, add SpamAssassin or an external engine, and consider raising the delete threshold rather than lowering it — the cheap layers should be doing the refusing.
Check your work on Spam filtering overview, which will tell you if any of it is switched on and inert.
See also: Stopping Viruses · Security Hardening · Encryption and Certificates · Settings Reference · Diagnosing Stalled Mail · Rules and Sieve
hMailServer 6.3.2 · AGPL-3.0-or-later · Repository · Report a documentation error
Hmail Server — full index
Start here
1. Install and run
- Before You Install
- Installing hMailServer
- Installing on Linux
- Running in a Container
- The Control Panel
- Your First Domain and Mailbox
- Connecting a Mail Client
- DNS for Your Domain
2. Secure it
3. Operate it
- Monitoring and Health
- Backup and Restore
- Troubleshooting
- Diagnosing Stalled Mail
- Relocating an Installation
- Upgrading hMailServer
- Upgrading Guide
- Migrating the Database Backend
- High Availability Runbook
- Warm Standby
- Runbooks Digest
4. Extend it
- Rules and Sieve
- Aliases Lists and Public Folders
- Routes and Relays
- The COM API and Scripting
- The REST API
- APIs Reference
5. Contribute to it
- Project Handbook
- Architecture
- Contributing
- Release Process
- Governance
- Assurance Case
- Regression Test Environment
- Fuzzing
- Regulatory Scope
- Third-Party Binaries
Look it up — from any journey