Skip to content

Stopping Spam

chrisholloway5 edited this page Sep 8, 2026 · 3 revisions

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:

The Anti-spam settings page, General tab, showing the spam mark and delete thresholds, the header and subject-prefix switches, the recipient tarpit and the quarantine card

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

The SURBL servers page: one row per blocklist, each with a DNS host, a reject message, a score and an active checkbox

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).


10.1 The whole decision, once

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
Loading

Three things in that picture surprise people, and all three are deliberate:

  • An authenticated session is never spam-tested. SMTPConnection::GetDoSpamProtection_ returns false the moment isAuthenticated_ 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-Results verdict is recorded, and nothing is written into the reason headers.
  • Where the pre-transmission tests run is a setting. DNSBLChecksAfterMailFrom defaults to 1, which runs them at MAIL FROM. Set it to 0 and they run at the first RCPT TO instead — 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 two DoSpamProtection_(SPPreTransmission, …) call sites).

10.2 How scoring works

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 iMaxScore is 0. That matters even though no action is armed: SpamProtection::PerformGreyListing looks 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 the break).

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.

What the headers look like

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.


10.3 Every test, in the order it runs

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.

What each test actually asks

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.


10.4 Greylisting

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
Loading
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=0 currently does nothing. GreyListCleanerTask::DoWork calls SetGreylistingTemporarilyDisabled(true) around the expiry sweep, but in 6.2.28 nothing anywhere reads AntiSpamConfiguration::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 in Server/SMTP/GreyListCleanerTask.cpp and Server/Common/AntiSpam/AntiSpamConfiguration.cpp at 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.


10.5 DNS blacklists (DNSBL)

Spam & virus filtering → DNS blacklists is the best effort-to-reward ratio on this page. zen.spamhaus.org alone removes most spam.

The DNS blacklists page, listing zen.spamhaus.org and bl.spamcop.net with their expected results and scores

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-8 means .2 through .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.


10.6 SURBL

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.


10.7 Sender authentication: SPF, DKIM, DMARC and ARC

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
Loading

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 reject or quarantine and the message failed it. p=none failures are logged and score nothing — the domain owner asked to be told, not obeyed;
  • the message carries more than one From header field. That is scored at the DMARC failure score before any alignment is computed, and the reason is in SpamTestDMARC.cpp: DKIM signs the last From while the header reader answers with the first, so an attacker who signs their own From and 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"]
Loading

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.


10.8 SpamAssassin

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-Path header 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.

10.9 An external filtering engine over HTTP

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.


10.10 The three lists

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

The anti-spam white list, one row per exempt IP range and sender pattern

The greylisting white list, one row per exempt IP address

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 550 says 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.

10.11 Quarantine, and per-account overrides

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 attach X-hMailServer-Reason-Score: 1 to 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.

10.12 What you see when something is refused

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.


10.13 When a check quietly stops working

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

10.14 A configuration that works

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:

  1. Leave the thresholds at 5 and 20 and leave DMARC on. Watch for a fortnight.
  2. Activate zen.spamhaus.org on the DNS blacklists page. Score 3.
  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.
  4. 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.
  5. Add greylisting if your users can tolerate a first-message delay. Leave "bypass on SPF success" on.
  6. 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


Clone this wiki locally