Skip to content

Stopping Spam

Christopher Holloway edited this page Oct 19, 2026 · 13 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. What 6.3.4 (27 September 2026, schema 6104), adds - the RFC 7208 SPF evaluator, hardened greylisting, the SMTP policy switches, the tag and the first-contact note, the allow and block lists and digests, impersonation protection, spam traps, country blocking and complaints - is in §10.4a, §10.7, §10.7a, §10.7b, §10.10a and §10.15, each labelled since 6.3.4; the page was brought up to 6.3.4 on 26 September 2026. Brought up on 5 October 2026 to 6.3.6 (6 October 2026, schema 6108), for what 6.3.6 changes on this page. 6.3.7, the latest release (17 October 2026, schema 6108), is not yet reflected here. 6.3.5's quarantine review - the HELO column and the grouping - is not yet written in.

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 block list, with Active, DNS host, Expected result, Score and Refusal text columns; the seeded multi.surbl.org arrives inactive, so the pill beside the title reads "Active: 0 of 1"

Start here instead if you only want the current state. Spam 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 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 block lists (DNSBL) 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 every registrable domain a message's links name, up to 200, then up to two host names under each, 256 names per list at most, up to sixteen lookups at a time (6.3.6; the first 16 names before)
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 registrable domain (by the Public Suffix List compiled in, since 6.3.6; it cut to two labels, or three under a name in SURBL's 2006 list, before) has any MX; a sender at a public suffix with no MX (github.io, co.uk) fails An empty envelope sender; a sender domain that is an IP literal; a name of one label; a failed DNS query
HELO is a name (6.3.6, RefuseHeloNotFqdn, off) Is the HELO/EHLO name a fully qualified domain name? 550 5.7.1 at MAIL FROM when it is an address, one label or a public suffix - a refusal, not a score An authenticated session; an IP range without spam tests; a white-listed sender; an incoming relay
Sender's domain is a name (6.3.6, RefuseMailFromDomainNotFqdn, off) Is the envelope sender's domain a fully qualified domain name? 550 5.7.1 at MAIL FROM when it is an address literal, one label or a public suffix MAIL FROM:<>; the same sessions as the HELO check
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. In 6.3.3 also any client in 127.0.0.0/8 or ::1, which the old library passed before reading any policy; since 6.3.4 loopback gets no such pass (§10.7)
SURBL Is any domain in a body URL listed? A listed domain, checked as <domain>.<list host> No URLs; a message whose links name more than 200 domains has 200 of them asked about, the HTML part's first, and scores SurblTooManyDomainsScore (5 by default) once; past 256 names the rest are not asked; 10 seconds elapsed (no lookup begins after it); a host DNS cannot carry is skipped, not the end of the list (6.3.6)
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 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.4a Greylisting, hardened (since 6.3.4)

Schema 6072. Three additions, each off by default except the first:

  • The triplet key no longer truncates. Each address is keyed by a digest of the whole address with its domain lower-cased, so two long addresses that share their first 200 characters are two triplets, and A@Example.com and a@example.com are one - a change at the defaults that makes a sender wait once less, never once more.
  • A sliding wait and an abuse lock. With GreyListingSlideOnEarlyRetry on, each retry before the window opens moves the window on by the initial delay, so a bot that retries from a timer never reaches it. With GreyListingAbuseThreshold above 0 (the operator who asked for it uses 20), the attempt that reaches it is answered 550 5.7.1 Too many delivery attempts before the greylisting delay had passed. ... and the triplet is locked for GreyListingAbuseLockMinutes (60). It counts attempts, not retries: a server delivering twenty queued messages to one recipient down one connection makes twenty attempts in seconds, so think of the bulk sender before turning it on. A triplet that has passed once is never refused.
  • Every triplet has its key, and a race is never a pass (since 6.3.6, schema 6107). The upgrade to 6107 finishes the greylisting key: every row has one, the key is unique - on MySQL and MariaDB over the stored generated column glipaddress2key, on PostgreSQL through two partial indexes - and the lookup asks for it alone. A race of two connections presenting the same new triplet is answered as the triplet that is there, never let through for the refusal.
  • The greylisting bypass list (Spam filtering → Greylisting bypass list, /api/v1/greylisting-bypass): a domain or a HELO suffix let past greylisting only when the message passes SPF, DKIM or DMARC for that name - at least one required, and a pass for some other identity counts for nothing. It is a privilege granted to named partners, where BypassGreylistingOnSPFSuccess grants it to every SPF pass in the world, a hijacked mailbox at a large provider included. It skips greylisting only; every spam test still runs.

10.5 DNS blacklists (DNSBL)

Spam filtering → DNS block lists (DNSBL) is the best effort-to-reward ratio on this page. zen.spamhaus.org alone removes most spam.

The DNS block lists (DNSBL) page, with its Active, DNS host, Expected result, Score and Refusal text columns - empty on the server this was captured on, "No block lists, so connecting addresses are not looked up in any.", with an Add the first entry… button; a fresh install has the two rows below, inactive

Two rows are seeded by a fresh install and both arrive inactive — you must tick them:

DNS host Expected result Refusal text 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.

Since 6.3.6 (Server/Common/AntiSpam/SURBL.cpp at the tag; CHANGELOG items 4 and 30) the test reads a link as a browser does. A link's host is the one after its user name, percent-decoded, without its port, with the characters a browser deletes from a host deleted; an IPv4 address in any form a browser takes is read as that address (an IPv4-mapped IPv6 address too); a link with no scheme is read where a browser follows one (href="//host/", ?u=//host). The host is asked about whole and cut to its registrable domain by the Public Suffix List compiled in, and under a shared-hosting suffix also as the ICANN section's domain; a link to an address is asked about reversed only. The lists' error codes (127.255.255.x, 127.0.0.1, 127.0.1.255, 127.0.2.255) never tag a message - a list of your own that answers 127.0.0.1 for a listing must answer another address, such as 127.0.0.2.

Every link of the text and HTML parts within the anti-spam size limit is read, the hosts of one registrable domain taken as one domain (its own names, then at most two host names under it), HTML's domains before text-only ones. Bounds:

  • every registrable domain and address, up to 200 for a message, then up to two host names under each domain, drawn at random when a domain has more, until 256 names in all, the domains taken in a random order when their host names do not all fit;
  • a message whose links name more than 200 domains scores SurblTooManyDomainsScore once (5 by default, the stock mark threshold; 0 scores nothing; nothing while no URI list is active) - a database gets the row at the first start of 6.3.6;
  • each list is asked up to sixteen names at a time - on the thread testing the message and on up to fifteen of the 64 threads the whole server has for the URI lists - and stops beginning lookups after 10 seconds; a host DNS cannot carry is skipped, not the end of the list;
  • a list that refuses queries, or answers 127.0.0.1 to every name, is named in the application log at most every 15 minutes (10.13).

An administrator with a query quota at a list should know that a message with many links now makes up to 256 queries per active list, where it made 16. Up to 6.3.5 the test pulled host names out with a regular expression, checked each whole and with its top label trimmed, stopped collecting after 15 distinct addresses, and abandoned a list at its first failed lookup, so links ahead of a listed one kept it from every list.

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

What decides the verdict depends on which build you run, and the difference matters more than the grammar:

6.3.3 and earlier Since 6.3.4
Evaluator RMSPF 1.10, vendored C written against RFC 4408 (SPFMAXLOOKUPS in RMSPF.cpp) An implementation of RFC 7208 ported from upstream hMailServer (Server/SMTP/SPF/SPFEvaluator.cpp and its neighbours), with the 203 cases of the openspf.org RFC 7208 conformance suite passing inside the server's own self-test
DNS Its own lookups through DnsQuery_A, so DNSServer, the DNS cache and the query timeout were ignored on Windows The server's own resolver: DNSServer, its cache and timeout apply, and a TXT answer passes the same DNSSEC gate as a DKIM key or a DMARC policy
Linux Every check was TempError (HM6406), so DMARC lost its SPF half and a domain that authenticates by SPF alone could not align Works, and the Linux build runs the conformance suite as spf-tests
Loopback clients 127.0.0.0/8 and ::1 passed before any policy was read No exemption: a local client is evaluated like any other, so a test or a relay on the same machine needs a policy that names it
Authentication-Results Everything but pass and fail was written as neutral — softfail, temperror, permerror, and none for a domain with no record; none appeared only for an empty envelope sender Each of the seven RFC 7208 results under its own RFC 8601 keyword

Nothing is scored differently: a fail scores and a pass is recorded, as before. The one thing to check when upgrading is the loopback row — anything that sends through this server from the same machine as an unauthenticated SMTP client, and relies on an SPF pass (for example to align DMARC), now needs its address in the domain's SPF record.

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.7b SMTP policy the score cannot express (since 6.3.4)

Schema 6073. All off by default:

Setting What it does
UseSPFSoftFailScore, UseSPFTempErrorScore, UseSPFPermErrorScore Score SPF's SoftFail (~all), TempError (the check did not finish) and PermError (the record is unusable) on their own, as a hard Fail is scored. 0 is "not scored", as before
SPFTempErrorDefer Answer a TempError with 451 4.4.3 for the transaction - RFC 7208's own answer - so a resolver blip defers the message rather than scoring it
RejectMultiRcptNullSender A null sender (MAIL FROM:<>) naming a second recipient is refused 550 5.7.1, and its DATA too: a delivery report has one recipient, so this is backscatter or a probe
BanMultiRcptNullSender, BanMultiRcptNullSenderHours Also ban the connecting address through the auto-ban, for 24 hours by default

And the domain white and black list (Spam filtering → Allowed and blocked domains, /api/v1/domain-policy-list), keyed by the envelope domain or the HELO suffix rather than by IP address - for the correspondent whose sending hosts change every week. It is consulted at MAIL FROM for unauthenticated sessions of an IP range that runs the spam tests: black is 550 5.7.1 Mail from <name> is refused by this server's policy. before anything else runs; white skips greylisting, is not deferred for an SPF TempError and is not refused by the score, while every test still runs and logs. It never overrides authentication.

10.7a Telling the reader: the external-sender tag and the first-contact note

Since 6.3.4 (schema 6044).

Neither of these is a spam test — neither scores, and neither refuses anything. They put a warning in front of the person reading, which is where a phishing message is finally decided. Both are per domain, off on every existing and every new domain, and while off they cost nothing: no extra read of the message, no query. They are set on the domain in the Control Panel's domain dialog, tab Tagging and disclaimer; in the Deck's domain editor; or with PUT /api/v1/domains/{name} and the keys external_tag_subject, external_tag_header, external_tag_text and first_contact_tip. The switches belong to the recipient's domain and are applied to that account's own copy at local delivery, before its rules and Sieve script run — so both can match on the header.

Who counts as outside is one rule (MessageOrigin::IsExternalSender). A message is inside only when the session authenticated (read from the topmost Received header this server wrote, never from X-AuthUser), or a configured incoming relay handed it over, or both the envelope sender and the From address are at domains this server hosts. Either address being outside is enough to be outside.

Switch What the reader gets Cost to know about
external_tag_subject The domain's tag text at the front of the Subject — external_tag_text, at most 100 characters, [EXTERNAL] when empty; a subject that already starts with it gets no second copy Breaks the sender's DKIM signature over the Subject for anyone who verifies downstream, exactly as the spam subject prefix does; the verdict this server reached is unaffected, because the tests ran during the conversation
external_tag_header X-hMailServer-External: YES, which the webmail shows as This message came from outside your organisation. above the message None to the signature: prefer this wherever the reader's client can show it
first_contact_tip X-hMailServer-First-Contact: YES when an outside sender writes to an account that has had mail before but never from them; the webmail shows You do not usually get mail from this sender. One indexed query per delivered copy, and an insert the first time an address writes to an account

Where the domain has turned a switch on, a copy of either header that a sender forged is removed from a message judged inside, so the field means what this server says.

The first-contact memory is hm_knownsenders, one row per account and sender. An account that signs in over SMTP and sends, in a domain with the note on, records its recipients, so their replies are not announced; an unauthenticated session records nothing, because its envelope sender is only a claim. Mail sent from the webmail or by send-later does not record its recipients — that path does not pass through the SMTP session — so a reply to a message first sent that way can be announced once. Two limits are by design: a cold start — a new installation, or the day the switch is turned on, remembers nobody, so for a while most senders are announced (the very first message an account ever receives carries no note); and a restore brings the memory back as of the backup (see Backup and Restore §15.1a).

Three things these do not do, said plainly:

  • They are not anti-spoofing. A message forging a hosted address in both its envelope and its From is judged inside and is not tagged. DMARC with a policy on your own domain is the control for that (§10.7).
  • An inbound filtering gateway listed as an incoming relay turns the tag off for everything it hands over, because the relay list says that machine is your own edge (Routes and Relays §14.3). Today the choice is between the tag and the relay declaration.
  • The domain disclaimer beside them on the same tab is not a filter either: it appends the domain's legal footer to mail leaving the organisation, once per reply chain, never to a signed or encrypted message, an automatic reply, a bounce or a list posting. It is added only on the SMTP path, so mail sent from the webmail or by send-later leaves without it (and without the domain signature, for the same reason).

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 filtering, in its Lists section.

Page What it does Matching Where it is consulted
Spam exemptions 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 exemptions Exempts an IP from greylisting only, still spam-scored IP address GreyListing::GetAllowSend, first thing

The Spam exemptions page, one row per exempt IP range and sender pattern, with Lower IP, Upper IP, Email address and Description columns - empty here

The Greylisting exemptions page, one row per exempt IP address, with IP address and Description columns - empty here, under a notice that greylisting is switched off and an Open Anti-spam settings button

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.10a Allow and block lists, and the quarantine digest (since 6.3.4)

Schema 6046. Beside the three lists above, which act during the SMTP conversation, there is one per-recipient list at three levels: each account's own safe and blocked senders, kept by the person in the webmail; a domain's allow and block list (the domain dialog's Allow and block list tab); and the server's (Spam filtering → Allow and block list). They are read at local delivery, once per recipient, after the spam tests, and the nearest wins: the account's list, then its domain's, then the server's - the reverse of Microsoft 365's order, because the person the message is addressed to is the one who can tell whether they wanted it. An administrator who needs a sender stopped for everyone, whatever anybody's own list says, still has Blocked senders.

The message was classified as spam It was not
Allowed Delivered unmarked for this recipient Delivered as it is
Blocked Held in the quarantine for this recipient (marked and delivered when the quarantine is off) The same

A block never deletes. An entry is an address, a domain (and its subdomains) or a URL - https://bad.example/... is stored as bad.example/ and covers a link in the message to that host, whoever sent it. Since 6.3.6 the entry is matched against the same host the SURBL test reads (§10.6), against every link of a message within the anti-spam size limit, not the first 16, and an address in its own order - an entry written reversed, 1.2.0.192 for 192.0.2.1, no longer matches - and an entry with an @ or a \ no longer names another host. An entry matches the envelope sender or the From header, because a mailing service's envelope carries its own address.

The quarantine digest is one plain-text message a day to each account of a domain that has something newly held for it, with a link under each entry that delivers it, one that delivers them all and one that stops the digest. It needs the quarantine on, a QuarantineDigestBaseUrl the links point at (empty: no digest is sent), and the Quarantine digest switch and hour, which are off until a domain turns them on, or an account holds a value of its own (quarantine_digest and quarantine_digest_hour, Profiles and Environment §2); an account can also stop its own digest. An empty digest is never sent.


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

Since 6.3.4, the quarantine also holds what a mail flow rule sends there (Rule: <name>), what impersonation protection holds (§10.15) and what a recipient's blocked-senders list holds (§10.10a). Two other holding places are not the quarantine: a message from a signed-in account that compromised-account detection keeps out of the queue sits in the review queue (/api/v1/review, the Deck's Held for review; Security Hardening), and an attachment waiting for a sandbox's verdict is held in no mailbox and not in the quarantine (Stopping Viruses).

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.
Since 6.3.4: domain black list 550 5.7.1 Mail from <name> is refused by this server's policy. at MAIL FROM —
Since 6.3.4: SPF TempError deferred 451 4.4.3 —
Since 6.3.4: null sender, second recipient 550 5.7.1 at RCPT TO, and at DATA —
Since 6.3.4: greylisting abuse lock 550 5.7.1 Too many delivery attempts before the greylisting delay had passed. ... one line per lock
Since 6.3.4: a spam trap addressed 550 Unknown user - the same words as any unknown address one line per hit, one per ban
Since 6.3.4: country policy the connection is refused before the greeting —
Since 6.3.4: a mail flow rule rejects 550 5.7.1 with the rule's text, or naming the rule —

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 block lists (DNSBL) 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.


10.15 More layers since 6.3.4

Each came in 6.3.4, and each is off, or inert, until an administrator sets it.

Impersonation protection (schema 6087; the domain dialog's Impersonation protection tab). Microsoft 365's anti-phishing policy, per domain. On mail from outside, a display name that is an active account's person or a named protected person while the address is not theirs, and a sender domain one edit or one look-alike character (rn for m, 1 for l, a Cyrillic а, an IDNA spelling) from a hosted domain or a named protected partner, are flagged with X-hMailServer-Impersonation - which the webmail turns into a warning bar - or held in the quarantine, as the domain chooses. A sender the reader marked safe is exempt.

Spam traps (the domain's spam traps, over REST, the Deck and COM). Addresses that were never real and never given to anybody, so a message to one is from a harvested or guessed list. At RCPT TO a trap is refused exactly as an unknown address is - 550 Unknown user - so the sender cannot tell; behind the reply, the connecting address is banned through the auto-ban (SpamTrapBanMinutes, 1440) and the envelope sender goes on the server's block list for SpamTrapBlockDays (30). The hits are counted in hmailserver_spam_trap_hits_total.

Country blocking. A policy per listener family (SMTP, IMAP, POP3, HTTP) applied when a connection is accepted, and per domain applied when somebody signs in: allow:GB,IE or block:CN,RU. It needs a MaxMind-format .mmdb country database you supply (GeoIpDatabaseFile); this server ships none and downloads none, and with nothing set nothing is judged by country.

Complaints: the feedback loops. With ComplaintProcessingEnabled on and a ComplaintMailbox named, the abuse reports the large providers post when their readers press this is spam (RFC 5965) are parsed, recorded against the sending domain and account, counted, and past a threshold put the address on the domain's suppression list; a complaint rate per domain is in the metrics and can raise an alert. It is about the mail this server sends, and is on Outbound Shaping beside the other sending-reputation tools.

The sandbox for attachments from outside is on Stopping Viruses.


See also: Stopping Viruses · Security Hardening · Encryption and Certificates · Settings Reference · Diagnosing Stalled Mail · Rules and Sieve


Home — full index

Start here

1. Install and run

2. Secure it

3. Operate it

4. Extend it

5. Contribute to it

Look it up — from any journey


About this wiki

Clone this wiki locally