Skip to content

Diagnosing Stalled Mail

Christopher Holloway edited this page Nov 2, 2026 · 12 revisions

Diagnosing slow or 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 DNSQueryTimeout bounds. 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.

First: which half is stuck?

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
Loading

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

Turn on debug logging first

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.

Accepting: the sender times out after sending the message

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
Loading

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

DNSServer was 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 returned ERROR_TIMEOUT in 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 leave DNSServer empty so the system resolvers are used.

The cause is worth knowing, because the fix looks like a bug. Moving to the asynchronous DnsQueryEx replaced a PIP4_ARRAY, which has no port field, with a DNS_ADDR, which carries a full SOCKADDR — 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 correct DNS_INFO_NO_RECORDS for a host with no AAAA. DNSResolverWinApi.cpp says 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 setting DNSServer still means every lookup pays full price. Leave it empty unless you need a specific resolver.

SPF did not honour DNSServer at 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 whatever DNSServer said — 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). Because SpamTestDMARC evaluates 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 same DNSResolver as every other lookup, so DNSServer, 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.

When a spam test cannot look a name up

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.

If every message stalls, not just one

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.

Delivering: the message is accepted but never leaves

How the queue actually works

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
Loading

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 named SMTP delivery queue. It is not the Asynchronous task queue that 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.

The retry schedule, and when a message gives up

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

Reading a stuck queue

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)

When the virus scanner cannot examine a message

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.

The usual causes of a genuinely stuck delivery

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 / ClamMaxTimeout and 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 than ClamMaxTimeout in 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 SandboxEnabled on, 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 to SandboxTimeoutMinutes, 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 by GET /api/v1/sandbox/held, by GET /api/v1/queue marked held: sandbox (the held column of hmctl 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 DaneEnforcementEnabled and DnssecValidationEnabled on (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, or dig +dnssec against 1.1.1.1 and look for the ad flag) 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=0 delivers 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. Point DNSServer at 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.
  • 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.

Commands

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

That 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 25

Turn 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
}

Settings that bound each stage

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.

What to include in a report

If the above does not identify it, open an issue with:

  1. The log from the 354 (or from the delivery attempt) onwards, including the stage timing lines.
  2. The ERROR_hmailserver_<date>.log for the same window.
  3. Which scanners and event scripts are enabled.
  4. Whether the message eventually arrives, arrives twice, or never arrives.

See SUPPORT.md.

Verified against the code

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.

Home — full index

Start here

1. Install and run

2. Secure it

3. Operate it

4. Extend it

5. Contribute to it

Look it up — from any journey


About this wiki

Clone this wiki locally