Repository navigation
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:

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

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).
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 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 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.
| 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.
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
| 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.
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.comanda@example.comare one - a change at the defaults that makes a sender wait once less, never once more. -
A sliding wait and an abuse lock. With
GreyListingSlideOnEarlyRetryon, 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. WithGreyListingAbuseThresholdabove 0 (the operator who asked for it uses 20), the attempt that reaches it is answered550 5.7.1 Too many delivery attempts before the greylisting delay had passed. ...and the triplet is locked forGreyListingAbuseLockMinutes(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, whereBypassGreylistingOnSPFSuccessgrants 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.
Spam filtering → DNS block lists (DNSBL) 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 | 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-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.
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
SurblTooManyDomainsScoreonce (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.1to 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.
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 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
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.
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.
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
Fromis 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).
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 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 |


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.
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.
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 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. |
| 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.
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 block lists (DNSBL) 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.
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
hMailServer 6.3.7, the latest release (17 October 2026, schema 6108) · AGPL-3.0-or-later · Repository · Mirror on GitHub · Report a documentation error (a GitLab issue naming the page)
Home — full index
Start here
1. Install and run
- Before You Install
- Installing hMailServer
- Installing on Linux
- Running in a Container
- Profiles and Environment
- The Control Panel
- The Control Deck
- Your First Domain and Mailbox
- Connecting a Mail Client
- The Webmail
- DNS for Your Domain
- Migration
2. Secure it
- Encryption and Certificates
- Database TLS
- Encryption for People
- Stopping Spam
- Stopping Viruses
- Security Hardening
- Secure Defaults
- The Service Account
- Secrets at Rest
- Sign-in and Identity
- Security Policy
3. Operate it
- Monitoring and Health
- The SNMP Agent
- Backup and Restore
- Message Storage
- Compliance and eDiscovery
- Troubleshooting
- Diagnosing Stalled Mail
- Relocating an Installation
- Upgrading hMailServer
- Upgrading Guide
- The Update Feed
- Migrating the Database Backend
- High Availability Runbook
- Warm Standby
- Runbooks Digest
4. Extend it
- Rules and Sieve
- Aliases Lists and Public Folders
- Rooms and Equipment
- Mailing Lists
- Routes and Relays
- Mail In to a Webhook
- Outbound Shaping
- The COM API and Scripting
- Event Scripts
- The REST API
- Command Line
- 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
- Settings Reference
- Settings Overview
- Capabilities and Configuration
- Control Panel Reference
- Control Panel Pages
- Ports Reference
- Changes-Since-6210
- Version-History-to-6210
- Glossary
About this wiki
- hMailServer 6.3.7 is the latest release (17 October 2026, schema 6108)
- Pages say since 6.3.4 where a feature is new in it
- AGPL-3.0-or-later
- Repository, the one place the project is worked on
- Its mirror on GitHub, since 18 October 2026, with no Issues
- Questions: a GitLab issue with the Question template, GitHub Discussions Q&A, or the forum
- Report a documentation error: a GitLab issue naming the page