Repository navigation
Diagnosing Stalled Mail
Source of truth: hmailserver/docs/DiagnosingStalledMail.md, kept in the repository and copied here. Re-copied from master 92b8f9f63 on 26 September 2026, and the same at the tag v6.3.4 (51e2dbda0) and at the tag v6.3.6 (e3c448aa6), read again on 5 October 2026 (DiagnosingStalledMail.md at v6.3.6). Re-copied on 18 October 2026 from the document as the tag v6.3.7 (31d656c0c), the release, tagged and published on 17 October 2026, has it (DiagnosingStalledMail.md at v6.3.7): what a spam test does when it cannot look a name up - from 6.3.7 an address lookup that found nothing has failed when its A or its AAAA query failed, not only when both did; a deferral that names DNSSEC with no host attempted, the resolver's reason the application log line ends with from 6.3.7, and what each reason means, among them the one every signed domain whose MX answer came compressed was given until 6.3.7; where the Control Panel has the timeouts; and which queries
DNSQueryTimeoutbounds. The additions this copy makes are listed at the end. The document is the same at the tag v6.3.8 (3a2fc29b7), the release, tagged and published on 2 November 2026, read again on 2 November 2026 (DiagnosingStalledMail.md at v6.3.8), and its links to the repository's other documents point at them as that tag has them, or at this wiki's copy where there is one.
This guide covers the case where mail is not moving and the server appears healthy: no crash, no error, the service is running, and the logs seem to stop mid-transaction. It exists because that exact shape of problem took three releases to track down (GitHub discussion 18, on the project's former home), almost entirely because the server used to be silent about it.
It is no longer silent. Read this before opening an issue — the answer is usually in one line of the log.
The two halves fail differently and have different causes. Work out which one you have before anything else.
Accepting — the sending server connects, sends the message, and then waits.
From its side you will see a timeout after the message body was transmitted.
Postfix reports this as timed out while sending end of data. The message never
appears in your queue.
Delivering — the message is accepted (the sender got a 250), is visible in
the delivery queue, and never leaves.
Everything below is split along that line.
The whole of a message's journey, with the two halves marked, so you can see where the answer will be:
flowchart TD
subgraph ACC["ACCEPTING - between 354 and 250"]
A1["DATA received"] --> A2["Spam protection"]
A2 --> A3["Message modifications"]
A3 --> A4["OnAcceptMessage script and database save"]
A4 --> A5["250 OK, and a row in hm_messages"]
end
subgraph DEL["DELIVERING - the queue"]
A5 --> D1["Row waits: messagetype 1, unlocked, next-try time reached"]
D1 --> D2["Delivery manager locks it and queues a delivery task"]
D2 --> D3["Virus scan, global rules, mirroring"]
D3 --> D4["Local delivery"]
D4 --> D5["External delivery"]
D5 --> D6{"Outcome"}
D6 -->|"all recipients done"| D7["Row deleted"]
D6 -->|"temporary failure"| D8["Rescheduled and unlocked, try count up"]
D6 -->|"out of retries"| D9["Bounce queued to the sender, row deleted"]
D8 --> D1
end
If your message never reaches A5, you are in the accepting half. If it reaches
D1 and stays there, you are in the delivering half. Monitoring &
troubleshooting → Delivery queue answers that question in one look, and so does
hmailserver_delivery_queue_messages on the metrics endpoint (see
Monitoring and Health).
Control Panel → Logging → tick Debug messages, and make sure logging is enabled. Reproduce the problem once. The lines this guide refers to are written at debug level; the slow ones are also written at application level, so if you only have application logging you will still see the important ones.
Remember to turn debug logging off afterwards on a busy server.
Between the 354 and the 250 the server runs the accept pipeline: spam tests,
message modifications, archiving, the OnAcceptMessage script, and the database
save. All of it happens on a bounded pool of threads, and the reply is only sent
when it finishes.
Drawn as the conversation the sender sees, with the three stages the log names and the dependencies each one can hang on:
sequenceDiagram
participant P as Sending server
participant S as SMTP session
participant Q as Asynchronous task queue
participant X as Scanners, scripts, database
P->>S: DATA
S-->>P: 354 start mail input
P->>S: the message, then a lone dot
S->>Q: hand the message to a worker
Note over Q: the line about waiting N seconds for a thread appears here if none is free
Q->>X: spam-protection - DNSBL, SURBL, SPF, SpamAssassin
X-->>Q: scores, each with its own Time value in ms
Q->>X: message-modifications - headers, signature, rewrite on disk
X-->>Q: done
Q->>X: script/save - OnAcceptMessage, then the insert
X-->>Q: done
Q-->>S: finished
S-->>P: 250 OK
The sender is waiting for that last arrow for the whole of the pipeline. If the
pipeline exceeds FinalizationTimeout the server answers 451 instead, so the
sender retries rather than sitting on a socket for ever.
The log times each stage:
SMTPConnection - accept: start spam-protection.
Spam test: SpamTestDNSBlackLists, Score: 0, Time: 12 ms
Spam test: SpamTestSpamAssassin, Score: 0, Time: 120000 ms
SMTPConnection - accept: done spam-protection in 120047 ms (session 5).
SMTPConnection - accept: done message-modifications in 0 ms (session 5).
SMTPConnection - accept: start script/save.
SMTPConnection - accept: done script/save in 3 ms (session 5).
How to read it. Read it as a sequence and look for the last line written —
the stage that is stuck is the one after it. Two stages announce themselves with
a start line (spam-protection and script/save), so for those a start with
no matching done is the whole diagnosis. The middle stage does not have a
start line: message modifications run between done spam-protection and
start script/save, so a stall there shows up as done spam-protection followed
by silence. If every stage completes but one took a long time, the Time: or
done ... in value names the culprit directly. A stage taking ten seconds or
more is logged at application level even without debug logging, and so is any
individual spam test that takes ten seconds or more.
Common causes, in the order they actually occur:
| What the log shows | Cause |
|---|---|
SpamTestSpamAssassin with a large Time:
|
spamd is unreachable, overloaded, or accepting connections without answering |
SpamTestDNSBlackLists, SpamTestSURBL or SpamTestSPF slow |
the resolver is not answering. Look in the TCP/IP log for DNS - Query timed out and DNS - Query failure. A DNS blacklist or SURBL lookup asks the A record alone, and asks once more when that query fails (below), so one that times out waits for its query timeout twice - DNSQueryTimeout on Windows; on Linux, which does not read that setting, five seconds for each DNS server the query is sent to - as long as its A and AAAA queries waited before 6.3.7; and SURBL, which stopped at the first lookup that failed, goes on to the message's other names until its ten seconds for beginning lookups are up, the ones begun then waited for. Do not reach for DNSServer as the fix — see the warning below |
done spam-protection, then silence |
message modifications: the spam headers, the signature, the List-* headers, or the write of the modified message back to disk |
script/save slow |
an OnAcceptMessage event script, or the database |
Nothing between 354 and silence |
you are on a version older than 6.2.17; upgrade, because that is the version that added these lines |
DNSServerwas broken in 6.2.17 and 6.2.18. It was fixed in 6.2.19 (GitHub issue 25). On those two versions every lookup through a configured custom server failed: AAAA and CNAME queries returnedERROR_TIMEOUTin the same millisecond they were issued, and the A query returned a status the resolver treated as "no records". SpamAssassin was the visible symptom because it reports a failed lookup, but DNSBL, SURBL, SPF, DKIM and MX lookups failed the same way in silence — a server in that state had quietly stopped most of its spam filtering and was still accepting mail. If you are on 6.2.17 or 6.2.18, upgrade, or leaveDNSServerempty so the system resolvers are used.The cause is worth knowing, because the fix looks like a bug. Moving to the asynchronous
DnsQueryExreplaced aPIP4_ARRAY, which has no port field, with aDNS_ADDR, which carries a fullSOCKADDR— and a destination port of zero is what that structure wants, because the DNS client supplies the port itself. Filling in 53 looks like the obvious correction and breaks every lookup: measured against a real server, port 53 returns status 87 and no records for all three record types, while port 0 returns the A record and a correctDNS_INFO_NO_RECORDSfor a host with no AAAA.DNSResolverWinApi.cppsays do not "fix" this line directly above it, for this reason.One caveat survives the fix: a custom server list is only honoured together with
DNS_QUERY_BYPASS_CACHE, so settingDNSServerstill means every lookup pays full price. Leave it empty unless you need a specific resolver.
SPF did not honour
DNSServerat all until 15 September 2026, and could not be evaluated on Linux. The SPF evaluator was a vendored library that resolved names itself, through the Windows DNS client, rather than through this server's resolver. So on Windows an SPF check went to the system resolvers whateverDNSServersaid — a server pointed at an internal resolver was asking the outside world about its senders — and on Linux there was no Windows DNS client to ask, so every SPF check returned a temporary error and said so in the error log once per process (HM6406). BecauseSpamTestDMARCevaluates SPF, DMARC on that platform lost its SPF half with it: a domain that authenticates by SPF alone could not align. The RFC 7208 evaluator that replaced the library resolves through the sameDNSResolveras every other lookup, soDNSServer, the DNS cache setting, the query timeout and DNSSEC validation all apply to SPF now as they always did to everything else. If you run 6.3.3 or earlier on Linux, an SPF or DMARC verdict from it is not a verdict.
A spam test that cannot complete a DNS lookup lets the message through without its verdict: it does not score, refuse or reject for a lookup that failed. From 6.3.7 an address lookup has failed when it found nothing and its A or its AAAA query failed - a SERVFAIL, a timeout, no DNS server answering - and not only when both did (upstream PR 640); a record lookup has, as before, when its query failed. An address lookup asks A and AAAA (AAAA where IPv6 is available, which is everywhere in practice), and the name's CNAME when neither found anything; a record lookup - a DKIM key, an SPF record, a DMARC policy, the MX-record test's MX - asks the CNAME when it found nothing. Neither asks it of a name that does not exist - NXDOMAIN, for an address lookup from A and AAAA both - because such a name is no alias, and after NODATA a failure of that query changes nothing: the name holds nothing of the type, as before. The CNAME query decides the lookup on Windows, where the DNS client answers the query of an alias with the CNAME and the records of the name it is an alias of, under that name - or with the CNAME alone when that name holds none - which the lookup does not take, so that the CNAME query is its way to the records: when that query fails, the lookup has failed. A DKIM selector that is a CNAME, as Microsoft 365's are for a customer's domain, is such an alias. On Linux the server's resolver follows an alias within the answer, so there a CNAME query that fails never fails a lookup. The DNS blacklists and SURBL ask the A record alone: a list answers with A records, for an IPv6 address as for an IPv4 one (RFC 5782), and until 6.3.7 they asked AAAA as well. Before 6.3.7 an A query that failed beside an AAAA query answered "no AAAA records" - the answer of every IPv4-only host, and of every address a DNS blacklist does not list - was read as "this name has no addresses". NXDOMAIN and NODATA are answers, and still mean "no addresses".
| Test | When its lookup fails | Before 6.3.7 |
|---|---|---|
The HELO host (CheckHostInHelo) |
not scored | scored when one of A and AAAA failed and the other was answered without an address, or, on Windows, when the name is an alias whose CNAME query failed |
PTR (CheckPTR) |
not scored, whether the PTR lookup or the address lookup of the name it gives failed | a failed address lookup of the reverse name was scored |
MX records (UseMXChecks) |
not scored | scored when, on Windows, the domain is an alias whose CNAME query failed |
| A DNS blacklist | asked for the A record alone, and once more, past the DNS cache, when that query fails; when it fails twice, the client is not checked against that list, and the log says so | asked once, for A and AAAA; a lookup that failed was "not listed", logged as 0 addresses found like a list's answer |
| SURBL | asked for the A record alone, and once more, past the DNS cache, when that query fails; when it fails twice, the name is passed over and the message's other names are asked | asked once, for A and AAAA; the first lookup that failed ended the list for the message, so a listed link after it was never asked about |
| DKIM keys, DMARC policies, SPF records | a temporary error, as for any lookup that fails: DKIM TempFail, DMARC and SPF temperror. A name that does not exist (NXDOMAIN), or holds nothing of the type (NODATA), has nothing published, as before, whatever its CNAME query meets: for a DKIM key a PermFail, no key for signature (RFC 6376 section 6.1.2), and for SPF none
|
on Windows, the lookup of an alias - a DKIM selector that is a CNAME is one - whose CNAME query failed read as "not published": for a DKIM key, a PermFail
|
The second attempt of a DNS blacklist or SURBL lookup bypasses the Windows DNS
cache, which would otherwise answer it from the first; on Linux the server keeps
no cache, and the second attempt is asked of the DNS server again. Setting
UseDNSCache=0 or a DNSServer bypasses the cache for every query already.
A lookup that answered - with addresses or without - is not asked again.
Delivery makes the same address lookup of each of a recipient's mail servers,
and of a route's host or the SMTP relay server, and retries a message whose
servers it cannot reach either way. When the lookup failed for some of them
only, the message goes to the others, and an address two of them share is
tried once, as when every one answered. When none of them has an address
and the lookup of one of them failed - through a route that names several
hosts, the lookup of the last of them, which is the one the notice follows -
the notice to the sender now says
Unable to find the recipient's email server. The DNS query has failed.
(4.4.4); before 6.3.7 a failed A query beside an AAAA query answered without
an address made it No mail servers appear to exists for the recipient's address (4.1.2), which asks the sender to check the address - or, through a
route or the relay server, The host specified as SMTP relay server could not be found (4.3.5). The Control Panel's MX query asks the same way, and for
such a domain now says The DNS lookup failed, so where mail for this domain would go could not be determined. where it said that the server found no mail
servers.
What the log says:
| Line | Log | When |
|---|---|---|
DNS lookup failed: <address reversed>.<list>. The client could not be checked against this blacklist. |
TCP/IP | A DNS blacklist lookup failed twice. In place of the DNS lookup: ..., 0 addresses found: (none), Match: False line an answered lookup writes |
DNSBL: The lookup of <name> did not complete, asked twice. The client could not be checked against <list>, and its messages are accepted without that list's verdict. |
application | The same, at most once every 15 minutes |
SURBL: The lookup of <name> did not complete[, nor did <n> other lookups of the same message]. The message's other names were asked about, and it is accepted without the list's verdict on the names whose lookups did not complete. |
application | A SURBL lookup failed twice and no other name of the message was listed, at most once every 15 minutes |
SURBL: The lookup of <name> did not complete[, nor did <n> other lookups of the same message], and the ten seconds a message is given ran out before its other names were all asked about. It is accepted without the list's verdict on the names whose lookups did not complete or that were not asked about. - or and a lookup in <list> ended in an error before the message's other names were all asked about
|
application | The same, when SURBL's ten seconds for beginning lookups ran out - a list whose every lookup times out does that - or a lookup ended in an error before every name was asked |
DNS - The lookup of <name> did not complete. Asking once more, past the DNS cache. |
debug | A DNS blacklist or SURBL lookup is asked a second time |
SpamTestHeloHost: The lookup of <host> did not complete. The HELO host is not scored. and SpamTestPTR: The lookup of <name>, the reverse name of <address>, did not complete. The address is not scored.
|
debug | The HELO or PTR test passed over a lookup that failed |
Since 6.2.17 acceptance is also bounded: if it exceeds FinalizationTimeout
(240 seconds by default) the server answers 451 and the sender retries, rather
than leaving it waiting for a reply that never comes. If you see 451 4.3.1
responses, the accompanying error entry names the deadline that was hit, and the
stage timings above it name what consumed the time.
Look for this:
Task SMTP-accept session=42 ip=203.0.113.9 waited 8 seconds for a thread in
work queue Asynchronous task queue. 14 task(s) are still queued.
and, when it is severe:
All 15 threads in work queue Asynchronous task queue have been busy for at
least 120 seconds, so no further task on this queue can start. 14 task(s) are
queued. Running: SMTP-accept session=42 ip=203.0.113.9 (thread 4120, 168s), ...
That means every worker is occupied and messages are queuing behind them. One slow dependency does this to the whole server, which is why a single wedged scanner used to look like "the server stopped responding". The task names tell you which sessions are stuck; the stage timings tell you what they are stuck on.
There is no in-memory queue to inspect. The delivery queue is the set of rows
in hm_messages whose type is delivering, and the delivery manager is a loop
that polls for them:
stateDiagram-v2
[*] --> Queued: accepted, or created by a rule or a forward
Queued --> Locked: manager selects it and locks it
Locked --> Delivering: a delivery thread picks up the task
Delivering --> Deleted: every recipient succeeded
Delivering --> Queued: temporary failure - unlocked, try count up, next-try time set
Delivering --> Bounced: retry budget spent - an NDR is queued to the sender
Delivering --> Held: the only route matched is an ETRN route
Held --> Queued: ETRN received, or Deliver now in the Control Panel
Bounced --> Deleted
Deleted --> [*]
Queued --> Queued: startup unlocks everything
Three properties of that loop explain most of what a stuck queue looks like:
-
The selection is
where messagetype = 1 and messagelocked = 0 and messagenexttrytime <= now, ordered by size, then try count, then id. Small and new goes first, deliberately: large messages tie up a delivery thread, and a message with many failed tries is unlikely to go anywhere, so neither is allowed to stand in front of the mail that will move. -
The pool is separate and smaller than the accept pool —
maxdelivertythreads, 10 by default, on the work queue namedSMTP delivery queue. It is not theAsynchronous task queuethat finalises inbound mail, so a saturated delivery pool does not stop mail being accepted; it just stops it leaving. - The manager wakes on demand, or once a minute otherwise. A message whose next-try time has just passed is picked up within a minute, not instantly.
At startup every message in the queue is unlocked — update hm_messages set messagelocked = 0 — because locks held by a server that crashed would otherwise
strand those messages for ever. That is also the reason exactly one hMailServer
service may run against one database; see Warm Standby.
| Setting | Where | Default | Effect |
|---|---|---|---|
| Number of retries |
Mail flow & delivery → Delivery of e-mail (smtpnoofretries) |
4 |
How many further attempts after the first |
| Minutes between every retry | same page (smtpminutesbetweenretries) |
60 |
The gap between them |
QuickRetries |
hMailServer.INI |
0 (off) |
The first N retries use the short gap below instead — for greylisting |
QuickRetriesMinutes |
hMailServer.INI |
6 |
That short gap |
QueueRandomnessMinutes |
hMailServer.INI |
0 (off) |
Adds a random 1..N minutes to every gap, so a burst does not retry in lockstep |
| Route number of tries / minutes between tries | Mail flow & delivery → Routes | — | Overrides both, per matching route. Where several routes match, the largest of each wins |
With the shipped defaults that is five attempts an hour apart: a destination that
is refusing temporarily holds a message for roughly four hours and then bounces
it. The try count in the Control Panel's queue is the same
messagecurnooftries the arithmetic uses, so a message showing 3 has one attempt
left.
Two things do not advance that counter, and both are deliberate: Deliver now in the Control Panel resets the next-try time without touching the count, and a message held because the virus scanner could not examine it is held on its own budget (below).
Work down this list; each row is a different failure and looks different in the queue.
| What the queue looks like | What it means | Where to look next |
|---|---|---|
| Depth climbing, oldest message seconds old | A busy server draining normally. Not a fault | — |
| Depth flat, oldest message hours old, try counts climbing | The far end is deferring you | SMTP log for that destination; check your IP's reputation and PTR |
| Every stuck message shares one recipient domain | That destination, not your server | Route settings for it, and its MX |
| Try counts stuck at 0, next-try times in the past, nothing moving | Delivery threads are not running the tasks |
hmailserver_workqueue_depth{queue="SMTP delivery queue"}, and the saturation report above |
| Messages appear and vanish without arriving | They are being delivered — to the wrong place, or discarded by a rule |
Mail flow & delivery → Rules; enable MessageTraceEnabled and read the trace |
Try count 1 and a next-try time of 1901-01-01
|
An ETRN hold: the one route these recipients match is an ETRN route, so the mail waits for the far end to ask for it |
Mail flow & delivery → Routes; the log line is SMTPDeliverer - Route Message: HOLD for later delivery..
|
Queue empty, but the sender got a 250 and nobody received it |
Delivered locally into a folder you are not looking at, or the row was removed | The account's folders; the message trace |
Message N could not be delivered since the data file does not exist |
The row and the store disagree — a restore that missed files, or a store on a share that vanished | Turn on MessageStoreConsistencyCheck and read the report (Monitoring and Health §16.2) |
With AVFailAction=1 a message the scanner could not examine is not delivered.
It is held on its own schedule rather than on the SMTP retry budget:
AVFailRetryMinutes apart (15 by default), at most AVFailMaxHolds times (16), and
then returned to the sender. The log says exactly where in that budget each hold is:
SMTPDeliverer - Message 4711: The virus scanner could not examine this message, and
AVFailAction is 1, so it has NOT been delivered. Holding it for another attempt in
15 minute(s); this was hold 3 of 16 before it is returned to the sender.
Each hold counts as a deferral in hmailserver_messages_deferred_total, so a
scanner that has stopped answering shows up as a deferral rate with no matching
delivery rate. The hold count is kept in memory, not on the message, because
advancing the message's own try counter would make the pass that finally scanned it
skip DKIM signing.
When the queue is not simply waiting out a retry, these are what hold a delivery thread:
-
A virus scanner that stops responding. ClamAV is contacted after the message is accepted, so a wedged clamd shows up as "accepted but never delivered". Each socket operation is bounded by
ClamMinTimeout/ClamMaxTimeoutand a clamd that never answers is reported rather than held. Be precise about what that bound is, though: it is a deadline on one read or write, armed fresh each time, and the message is streamed to clamd in chunks — so a clamd that answers each chunk just before the deadline can make a single large message take considerably longer thanClamMaxTimeoutin total. It cannot hold the thread forever; it can hold it for a while. -
A remote server that answers extremely slowly. The idle timeout is re-armed on every byte received, so a host that sends one byte occasionally used to hold a delivery thread indefinitely. Since 6.2.18 there is an absolute ceiling (
ClientSessionCeiling, 30 minutes by default), armed once and never re-armed. -
A custom virus scanner or external tool that hangs — bounded by
ExternalProcessTimeout. -
A message held for a sandbox's verdict (since 6.3.4). With
SandboxEnabledon, a message none of your own signed-in users submitted, whose attachment is of a submitted type, waits for the sandbox you named to open the file — up toSandboxTimeoutMinutes, 15 by default — and while it waits it is in no mailbox, not in the quarantine and not the delivery queue's to retry: its Retry and Delete answer 409, naming the sandbox's routes. It is listed byGET /api/v1/sandbox/held, byGET /api/v1/queuemarkedheld: sandbox(theheldcolumn ofhmctl queue list), and in the Deck's queue page under Held for a sandbox, where it can be delivered without the verdict or discarded. The application log says when it was submitted and what the sandbox answered. See Sandbox.md. -
A deferral that names DNSSEC, with no host attempted. With
DaneEnforcementEnabledandDnssecValidationEnabledon (both are by default), the server validates the recipient domain's MX RRset, and each host's TLSA records, before it connects. A host whose lookup is bogus, or indeterminate - it did not complete, or a signed zone answered without its signatures or its proof of absence - is skipped (RFC 7672), and with every host skipped the delivery is deferred:Delivery to example.com deferred: ... No host was attempted.The application log line before it names the lookup, and since 6.3.7 ends with the resolver's reason in brackets:SMTPDeliverer - Message 123: the MX RRset for example.com failed DNSSEC validation (the MX RRset of example.com does not validate: the RRSIG by example.com with key tag 44331 (algorithm 13) expired at 2026-10-08 04:18:05 UTC, and this server's clock says 2026-10-09 09:12:40 UTC). Skipping mx.example.com (RFC 7672 section 2.2).The debug log has the same reason for every bogus or indeterminate MX, TLSA or TXT lookup -
DNSSEC: the MX lookup for example.com is bogus: ...- and a line for a zone whose keys do not validate,DNSSEC: the keys of example.com are bogus: .... What the reasons mean:-
does not match the records as received - the signature is over other
records than the ones that came. Either something on the way changed them
or the zone is broken; ask a validating resolver (
delv, ordig +dnssecagainst 1.1.1.1 and look for theadflag) or DNSViz. Until 6.3.7 this was also what every signed domain whose MX answer came with its exchange compressed got - nearly every signed domain - through no fault of its own (work item 80). On 6.2.19 to 6.3.6, a signed recipient domain whose mail is deferred this way while a validating resolver says it validates is that defect;DaneEnforcementEnabled=0delivers without DANE until the server is updated. -
expired at ... / is not valid until ..., and this server's clock says ...
- check the clock first; if it is right, the zone's signatures have lapsed.
- names no key ... publishes or no DNSKEY of ... matches its DS records - a key rollover gone wrong in the recipient's zone, or at its parent.
-
came without its signatures or came without an RRSIG: the DNS server did
not return the DNSSEC records asked for - the resolver this server asks
(
DNSServer, or the system's) strips DNSSEC records or does not support them. PointDNSServerat one that does; a validating resolver's verdict is not trusted here, its signatures are needed (the server asks with the DO and CD bits). - did not complete - no server answered, it answered SERVFAIL, or the reply did not parse: the message is retried, as for any temporary failure.
-
does not match the records as received - the signature is over other
records than the ones that came. Either something on the way changed them
or the zone is broken; ask a validating resolver (
-
The database. Check for errors mentioning the connection pool.
Check the delivery queue in the Control Panel, and the SMTP log for the destination in question.
Is it accepting, or is it delivering? Ask the metrics endpoint, which needs no credential on a loopback bind (see Monitoring and Health):
(Invoke-RestMethod http://127.0.0.1:9100/metrics) -split "`n" |
Select-String '^hmailserver_delivery_queue_(messages|oldest_message_age_seconds) |^hmailserver_workqueue_depth|^hmailserver_messages_(delivered|deferred|bounced)_total 'A rising oldest_message_age_seconds with a flat delivered_total is the
delivering half. A rising hmailserver_workqueue_depth{queue="Asynchronous task queue"} is the accepting half.
Read the queue itself, which is what the Control Panel's page shows:
$app = New-Object -ComObject hMailServer.Application
$app.Authenticate("Administrator", "<password>") | Out-Null
# One line per message, tab-separated:
# id, created, from, recipients, next try, file, locked, tries
$app.Status.UndeliveredMessages -split "`r`n" | Where-Object { $_ } | ForEach-Object {
$f = $_ -split "`t"
[pscustomobject]@{
Id = $f[0]; Created = $f[1]; From = $f[2]; To = $f[3]
NextTry = $f[4]; Locked = $f[6]; Tries = $f[7]
}
} | Sort-Object Created | Format-Table -AutoSizeThat listing includes ETRN holds as well as ordinary queued mail — they are the rows
whose next-try time is 1901-01-01.
Push one message to the front of the queue (resets its next-try time, leaves the try count alone, and releases an ETRN hold):
$q = $app.GlobalObjects.DeliveryQueue
$q.ResetDeliveryTime(4711)
$q.StartDelivery()Wake the delivery manager without touching any message:
$app.GlobalObjects.DeliveryQueue.StartDelivery()Remove one message from the queue — it is locked first, so a message a thread already has is not pulled from under it:
$app.GlobalObjects.DeliveryQueue.Remove(4711)Follow the log while you reproduce it. The stage lines are debug lines; the ten-second escalations are not:
$log = "C:\Program Files\hMailServer\Logs\hmailserver_" + (Get-Date -Format "yyyy-MM-dd") + ".log"
Get-Content $log -Tail 0 -Wait |
Select-String "accept: |Spam test:|waited .* for a thread|have been busy|SMTPDeliverer"Test the far end yourself, from the mail server, with the same TLS the server would use:
Test-NetConnection mx.example.net -Port 25Turn the message trace on (MessageTraceEnabled=1, then restart the service) and
read one message's whole story, which is the fastest way to answer "did we ever have
it, and what did we do with it":
$t = $app.GlobalObjects.MessageTrace
$t.Search("user@example.com")
for ($i = 0; $i -lt $t.Count; $i++) {
$e = $t.Item($i)
"{0} {1,-12} {2} -> {3} {4} {5}" -f $e.OccurredTime, $e.EventName, $e.Sender, $e.Recipient, $e.StatusCode, $e.Detail
}On 6.3.3 and earlier all are in hMailServer.ini under [Settings], and all are in seconds.
Since 6.3.4 all are stored in the database: change one in the Control
Panel, over the REST API at /api/v1/settings/ini/<name>, or with hmctl; the
save applies it, because each is read where it is used (SettingsApply.md).
They also appear in the [Settings]
section of hMailServer.ini, which since schema 6042 is a copy the server keeps
current rather than the place they are set — editing one there is named in
hMailServer_ERROR.log and put back at the next start. Defaults are chosen to be
well inside a typical sending server's timeout.
In the Control Panel, seven of them are the Timeouts and queue bounds card of the
Server limits & expert settings page; SAMaxTimeout and ClamMaxTimeout are on
the Anti-spam settings and Anti-virus settings pages. The same Server limits
page's Windows service account card - which account the service logs on as, and
the folders it is granted - is The Service Account.
| Setting | Default | Bounds |
0 means |
|---|---|---|---|
FinalizationTimeout |
240 | The whole accept pipeline, after which the sender gets a 451
|
no bound |
SAMaxTimeout |
90 | SpamAssassin: idle timeout, and a session ceiling of this plus 30s | not "no bound" — see below |
ClamMaxTimeout |
90 | ClamAV: idle timeout per socket operation | not "no bound" — see below |
DNSQueryTimeout |
10 | A single DNS query made through the Windows DNS client - every query of the DNS blacklist, SURBL, HELO, PTR and MX-record tests and of delivery's MX and address lookups, among others. The server's own resolver does not read it: that resolver makes every query on Linux, and on Windows the DNSSEC-validating ones - the TXT lookups (SPF's, DKIM's, DMARC's and the others but the reputation check's) while DnssecValidationEnabled is on, DANE's MX and TLSA lookups (both on by default), and Diagnostics.DnssecChainStatus - and each query it makes waits up to five seconds for each DNS server it is sent to, two at most. A TXT lookup it neither proves signed nor finds forged is then asked of the Windows DNS client as well |
no bound |
ScriptTimeout |
60 | One event script invocation | no bound |
ExternalProcessTimeout |
300 | An external scanner process | no bound |
ClientSessionCeiling |
1800 | An entire outbound delivery session | no bound |
AsyncQueueStallThreshold |
120 | How long every worker may be busy before the saturation report | reporting off |
DBConnectionAcquireTimeout |
60 | Waiting for a pooled database connection | no bound |
The two exceptions matter, because 0 tightens them instead of removing them.
SAMaxTimeout and ClamMaxTimeout are not passed straight through: they go to
TimeoutCalculator::Calculate(min, max), which returns the minimum whenever the
maximum is lower than it. So SAMaxTimeout=0 gives a 30-second idle timeout
(SAMinTimeout) and a 30-second session ceiling — a shorter bound than the
default, not an absent one — and ClamMaxTimeout=0 gives 15 seconds
(ClamMinTimeout). To lengthen either, raise the value; to lengthen it a long way,
raise the matching ...MinTimeout too, or the calculator will pull it back under
load. There is no way to make either unbounded.
When the pool deadline expires, a recipient lookup answers 451 4.3.2 rather than
550: the server can tell "the database did not answer" from "no such address",
so a database locked by a backup defers the mail instead of bouncing it.
If the above does not identify it, open an issue with:
- The log from the
354(or from the delivery attempt) onwards, including the stage timing lines. - The
ERROR_hmailserver_<date>.logfor the same window. - Which scanners and event scripts are enabled.
- Whether the message eventually arrives, arrives twice, or never arrives.
See SUPPORT.md.
Every log line quoted here was copied from the source rather than from a running
server, and every default was read from the code that reads the ini file. Checked
13 August 2026 against: SMTPConnection::LogFinalizationStage_ and
FinalizationDeadlineExceeded_ (the stage lines, the ten-second escalation, the
451 4.3.1 and error 5525); SpamTestRunner::RunSpamTest (the Spam test: line
and its own ten-second escalation); WorkQueue::ExecuteTask and
WorkQueue::ReportStalledTasks, plus Application's "Asynchronous task queue"
(the two saturation lines, the second of which is error 5526);
IniFileSettings::LoadSettings (every default in the table);
TimeoutCalculator::Calculate (the 0 exceptions); SpamAssassinClient's
SetSessionCeiling(GetSAMaxTimeout() + 30); SMTPConnection::ProtocolRCPT_'s two
DatabaseUnavailableMarker::Scope blocks (451 4.3.2 rather than 550);
DNSResolverWinApi::Query (the DNSServer regression and the cache bypass);
SPFDnsResolver and DNSResolver::GetRecordsOfType (SPF reaching the configured
resolver at all); and Logger's ERROR_hmailserver_%s.log. The DNSSEC deferral
lines were checked on 7 October 2026 against ExternalDelivery::DeliverToSingleDomain_
(the application log lines and their bracketed reason) and DnssecResolver.cpp
(QueryValidatedRrset_, ValidateRrset, ValidateZoneKeys, FetchDsRecords,
WhereUnproven and DescribeCheck, which word every reason). The lookups a spam
test cannot complete were checked on 12 October 2026, and again on 13 October
for a name that does not exist, for a name answered NODATA, for an alias on
Windows and for the timeouts, against DNSResolver::GetIpAddressesRecursive_,
GetDnsListAnswers, GetRecordsOfTypeRecursive_ and GetMXRecordsRecursive_
(what counts as a failed lookup, the CNAME query not made of a name answered
NXDOMAIN, and the second attempt), DNSResolverWinApi::RunQuery_ (a record is
kept only under the name asked, so on Windows every alias goes to the CNAME
query; what the Windows DNS client answers for an alias was measured with
DnsQuery on 13 October),
DNSResolverWinApi::Query (the cache bypass, and DNSQueryTimeout, read on the
Windows DNS client's path alone), DnssecResolver.cpp's RunQuery,
DnsTimeoutMilliseconds, GetDnsServers and ChooseNameServers (the five
seconds for each DNS server, at most two, on both platforms),
DNSResolver::TryValidatedTxtRecords_, ExternalDelivery::DeliverToSingleDomain_,
TlsPolicy::GetTlsaRecords and InterfaceDiagnostics::DnssecChainStatus (the
queries that resolver makes on Windows), ReputationWatch::Lookup_ (the
reputation check's TXT queries, which go to the Windows DNS client),
BLCheck::ClientExistsInDNSBL, SURBL::Run, SpamTestHeloHost::CheckHostInHelo_,
SpamTestPTR::CheckPTR_, SpamTestMXRecords::HasAnyMXRecords_, DKIM.cpp's key
lookup, DMARC.cpp and SPFEvaluator (the table and every line quoted),
DNSResolver::GetEmailServersRecursive_, ExternalDelivery::ResolveRecipientServers_
(a route's host and the relay server), ExternalDelivery::HandleNoRecipientServers_
(the delivery notice) and InterfaceUtilities::ResolveMXRecords (the MX query), and
AntiSpamDiagnostics (the fifteen minutes). If you change one of
those, this page is the second place to look.
The queue mechanics, the diagrams and the commands were added to this wiki copy on
8 September 2026 against 6.2.28, and come from: SMTPDeliveryManager::DoWork,
LoadPendingMessageList_ and GetNextMessage_ (the poll, the selection and its
ordering, the one-minute wake, the startup PersistentMessage::UnlockAll);
SMTPDeliverer::DeliverMessage (the outcome branches, and the two errors 5006 and
5007 for a missing data file and no remaining recipients);
ExternalDelivery::RescheduleDelivery_ and GetRetryOptions_ (the retry
arithmetic, the quick-retry and randomness settings, the per-route override taking
the largest of each, and the ETRN hold that writes messagetype = 3,
messagecurnooftries = 1 and messagenexttrytime = '1901-01-01 00:00:01');
PersistentMessage::SetNextTryTime (the counter increment, and the reset back to
messagetype = 1 that releases a hold); DeliveryQueue::ResetDeliveryTime,
StartDelivery and Remove; SMTPDeliverer::HandleUnscannableMessage_ (the AV
hold budget and its log line); Configuration::GetMaxNoOfDeliveryThreads with the
maxdelivertythreads default of 10 and MaxNumberOfAsynchronousTasks of 15 in the
create scripts; the smtpnoofretries (4) and smtpminutesbetweenretries (60)
defaults from the same scripts; and ServerStatus::GetUnsortedMessageStatus for the
field order of Status.UndeliveredMessages. The wiki copy of this page differs from
hmailserver/docs/DiagnosingStalledMail.md
only in these additions, in its links, which point at GitLab, and in the labels that say
what is new in 6.3.4. The repository text was re-copied from master 92b8f9f63 on 26 September 2026,
and is the same at the tag v6.3.4 (51e2dbda0), the release.
hMailServer 6.3.8, the latest release (2 November 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.8 is the latest release (2 November 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