Repository navigation
Architecture
Source of truth: ARCHITECTURE.md, kept in the repository and copied here. This copy was corrected against master 40ae9491d on 8 September 2026 (the optional-listener paragraphs, the
ExternalFetcher/andUtil/rows, the scheduled tasks) and extended on the same date with the diagrams, the thread-pool table and the pipeline walk-throughs, all drawn from the code cited beside them; the repository file has not yet had the same corrections. Items marked "new in 6.2.28" ship for the first time in 6.2.28, published on 8 September 2026. The repository text was re-copied from master 92b8f9f63 on 26 September 2026, and is the same at the tag v6.3.4 (51e2dbda0) and at the tag v6.3.6 (e3c448aa6), read again on 5 October 2026 (ARCHITECTURE.md at v6.3.6); it was 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 (ARCHITECTURE.md at v6.3.7), and the links point at the documents as that tag has them. What it describes that arrived after 6.3.3 - SPF as an RFC 7208 implementation, the Linux update helper, the settings store in the database and a save that publishes a setting, the rule that a new setting goes inhm_settings, the locked copies of shared collections, a COM change to an item of a shared collection written onto a copy untilSave(),MessageStoreas the one door to a message's file, and JavaScript event scripts through QuickJS on Linux - is new in 6.3.4. What 6.3.7 adds is that a transaction is its thread's:DatabaseTransactionis the scope under which every statement the persistence layer makes on the thread that opened it runs on one connection, run again whole on a deadlock, a serialisation failure or a lost connection, and holding SQL Server Compact's one connection while it is open.
A map of the codebase, for someone about to change it. It answers "where does this change go" rather than "how does mail work", and it records the constraints that are not obvious from reading any single file — the ones that have actually caused bugs here.
Pair it with Contributing for process and Release Process for the release gates.
Everything below is a detail of this diagram. Paths in the boxes are relative to
hmailserver/source/Server/. Dashed edges are outbound connections this server
makes as a client; solid edges are calls inside the process.
flowchart TB
subgraph peers["Outside"]
MTA["Remote MTAs"]
MUA["Mail clients<br/>Outlook, Thunderbird, phones"]
ADMIN["Control Panel, DBUpdater,<br/>the regression suite, VBScript"]
WEB["Browsers and scrapers<br/>portal, autoconfig, Prometheus"]
end
subgraph listeners["Listeners"]
direction TB
IOS["IOService + TCPServer<br/>Common/TCPIP<br/>SMTP 25/587, POP3 110, IMAP 143"]
HTTPS["HttpServer<br/>Common/Util/HttpServer<br/>own io_context, 4 workers"]
REST["RestApiServer<br/>/api/v1, /portal"]
WSVC["WebServicesServer<br/>MTA-STS policy, autoconfig,<br/>ACME http-01"]
METR["MetricsServer<br/>raw sockets + std::thread"]
MSIEVE["ManageSieveServer<br/>raw sockets + std::thread"]
end
subgraph sessions["Protocol sessions"]
SMTPC["SMTPConnection<br/>SMTP/"]
IMAPC["IMAPConnection<br/>IMAP/"]
POP3C["POP3Connection<br/>POP3/"]
end
subgraph pipeline["Delivery pipeline"]
DQ["SMTPDeliveryManager<br/>+ DeliveryTask<br/>10 threads"]
DELIV["SMTPDeliverer<br/>rules, AV, DKIM sign"]
LOCAL["LocalDelivery<br/>quota, rules, Sieve,<br/>forward, auto-reply"]
EXT["ExternalDelivery<br/>+ ServerTargetResolver<br/>+ TlsPolicy"]
CLIENT["SMTPClientConnection"]
end
subgraph core["Shared core - Common/"]
BO["BO/ business objects"]
CACHE["Cache/"]
PERS["Persistence/"]
SQLL["SQL/ + SQL/Macros"]
AS["AntiSpam/ SPF, DNSBL, SURBL,<br/>greylisting, DKIM, DMARC, ARC"]
AV["AntiVirus/ clamd, clamscan,<br/>external command"]
SIEVE["Sieve/ lexer, parser,<br/>evaluator, storage"]
SCRIPT["Scripting/ VBScript, JScript"]
MIME["Mime/"]
TRACK["Tracking/ change notifications"]
end
subgraph store["Persistent state"]
DB[("hm_* tables<br/>MySQL / MariaDB<br/>MS SQL Server<br/>PostgreSQL<br/>SQL CE")]
DISK["Data directory<br/>message files, Sieve scripts,<br/>logs, ACME certs"]
INI["hMailServer.ini<br/>IniFileSettings"]
end
subgraph sched["Background work - both long-lived tasks<br/>on the main server queue"]
SCH["Scheduler + ScheduledTask<br/>recurring tasks polled once a minute,<br/>RunOnce through the maintenance queue"]
FETCH["ExternalFetchManager<br/>ExternalFetcher/"]
end
COM["COM / IDispatch API<br/>COM/ - the management seam"]
MTA --> IOS
MUA --> IOS
WEB --> HTTPS
WEB --> METR
MUA --> MSIEVE
ADMIN --> COM
IOS --> SMTPC
IOS --> IMAPC
IOS --> POP3C
HTTPS --> REST
HTTPS --> WSVC
SMTPC --> DQ
DQ --> DELIV
DELIV --> LOCAL
DELIV --> EXT
EXT --> CLIENT
CLIENT -.->|"SMTP out: MX, route or smart host"| MTA
SMTPC --> AS
SMTPC --> BO
IMAPC --> BO
POP3C --> BO
DELIV --> AV
DELIV --> SCRIPT
LOCAL --> SIEVE
LOCAL --> BO
LOCAL --> TRACK
TRACK --> IMAPC
REST --> BO
METR --> BO
MSIEVE --> SIEVE
COM --> BO
BO --> CACHE
CACHE --> PERS
BO --> PERS
PERS --> SQLL
SQLL --> DB
LOCAL --> DISK
DELIV --> MIME
BO --> INI
FETCH -.->|"POP3 or IMAP in"| MTA
FETCH --> BO
SCH --> BO
Two things the picture is meant to make obvious. The COM API is not beside the
server — it is on top of the same BO/ objects every protocol uses, which is why
a configuration change made in the Control Panel is visible to the next SMTP
connection without a restart. The REST listener sits beside it rather than on top of
it: it reaches the same business objects directly (it includes no COM/ header), and
its authorisation goes through ACLManager, which build/check-authz-choke-point.py
keeps as the only place folder access is decided. And the delivery pipeline is not on the connection's
thread: SMTPConnection writes a queue row and lets go, and SMTPDeliveryManager
picks it up. Everything in the "Delivery pipeline" box happens after the sender has
been told 250.
hmailserver/
source/
Server/ C++ mail server - almost all feature work happens here
Tools/ C# administration tools and the Control Panel (.NET 10)
Addons/ Standalone sample add-ons
DBScripts/ Schema creation and the upgrade chain, per backend
Translations/ The legacy hMailServer Administrator's INI catalogues -
english.ini and swedish.ini. The Control Panel's own 17
languages are .resx files under
Tools/ControlPanel/Resources/
test/
RegressionTests/ NUnit suite driving a real running server
installation/ Inno Setup installer
docs/ Operator documentation
libraries/ Vendored third-party code
build/ Build, publish, preflight and test scripts
fuzz/ libFuzzer harnesses, dictionaries and the regression corpus
Entry point: source/Server/hMailServer/hMailServer.sln.
Server/
COM/ COM/IDispatch public API - the management seam
Common/ Shared infrastructure used by every protocol
ExternalFetcher/ POP3 and IMAP *clients*: fetch mail from remote accounts
(`ExternalFetchClientBase` turns a downloaded message into a
delivered one; `IMAPClientConnection` also mirrors whole
folders when `FetchAccount.MirrorFolders` is on - 6.2.25/6.2.27)
hMailServer/ Windows service shell (WinMain, service control)
hMailServer.Minidump/ Crash-dump helper
hMailServer.Updater/ The live-update apply helper: runs a verified installer,
waits for the service, rolls back (new in 6.2.28). Its Linux
counterpart is `platform/packaging/hmailserver-update`, a
shell script run as root by `hmailserver-update.path` and
`.service` when the server writes its request
zlib/ Vendored zlib 1.3.2 for IMAP COMPRESS=DEFLATE (new in 6.2.28)
IMAP/ IMAP
POP3/ POP3
SMTP/ SMTP, delivery queue, outbound transport security
platform/ The thin layer that keeps the tree compiling off Windows
Server/hMailServer/ is the service shell only — no protocol or business logic.
| Sub-folder | Purpose |
|---|---|
AntiSpam/ |
SPF, SURBL, DNS blacklists, greylisting, score-based filtering, DMARC/, and DKIM/ (RSA and Ed25519 signing/verification, ARC sealing in Arc.{h,cpp}) |
AntiVirus/ |
ClamAV (clamd and clamscan) and arbitrary command-line scanners |
Application/ |
Startup, configuration, scheduling, logging. IniFileSettings holds every hMailServer.ini setting; Application::StartServers starts the optional listeners |
BO/ |
Business objects — the domain model: domains, accounts, aliases, distribution lists, rules |
Cache/ |
In-memory caches in front of the BO layer, to keep hot paths off the database |
Diagnostics/ |
The checks behind the Control Panel's "Run diagnostics" |
LDAP/ |
The directory backend: LdapSettings, synchronisation, the AD account pickers' server side |
Mime/ |
MIME parsing and construction |
Persistence/ |
One class per business object, mapping it to database columns. MessageStore is the one door to a message's file - where it is, and every way it is read, sized, copied, moved and deleted; build/check-message-store-seam.py refuses a path to a stored message taken anywhere else |
Rules/ |
The global and account rule engine that RuleApplier drives |
Scripting/ |
Event hooks: VBScript/JScript through Windows Active Scripting (ScriptSite), JavaScript through the QuickJS engine the Linux builds embed (ScriptBinding, the engine under Server/quickjs) |
Sieve/ |
SieveLexer/SieveParser (AST), SieveEvaluator, SieveStorage (per-account scripts under {DataDir}\Sieve\), and the optional RFC 5804 ManageSieveServer. Evaluated during local delivery |
SQL/ |
Database abstraction: connections, pooling, parameterised queries |
TCPIP/ |
Boost.Asio networking, TLS, DNS. Also DnssecResolver (validating stub resolver), DaneVerifier (TLSA matching), ProxyProtocol and DeflateStreams (IMAP COMPRESS) |
Threading/ |
Thread pools and task queues |
Tracking/ |
Publish/subscribe bus between components |
Util/ |
Utilities, plus the optional listeners: MetricsServer, RestApiServer, WebServicesServer (the last two on HttpServer, the shared Boost.Asio HTTP/1.1 listener), AcmeClient, TlsRptStore, HttpsClient (the update feed and its downloads, JWKS, token introspection, and since 6.3.6 the ACME client, the outbound OAuth2 token request, the MTA-STS policy fetch, brand logos, Web Key Directory lookups, Web Push, one-click unsubscribe and inbound webhook routes held to public addresses - the place [Settings] HttpProxy is honoured; up to 6.3.5 AcmeClient itself, OutboundOAuth2TokenClient and the MTA-STS fetch in SMTP/TlsPolicy opened their own connections and went direct) and the OTLP exporters (Otel*), and the live update (UpdateChecker, SigstoreVerifier, UpdateDownloader, UpdateInstaller, UpdateWindow, UpdateApplyToken). HttpServer, the Update* files and the proxy support are new in 6.2.28; Application/ holds their tasks UpdateCheckTask and MetricsHistoryTask
|
The most complex module: reception, relay decisions, the disk-backed delivery queue,
bounces, DKIM signing, and outbound delivery. Outbound transport security lives here
too — TlsPolicy implements MTA-STS discovery/caching and DANE TLSA lookups,
ExternalDelivery applies the per-host requirements, TlsRptReporterTask sends the
daily RFC 8460 reports. SPF is SPF/, an implementation of RFC 7208 with a README of
its own; it reaches DNS only through SPFDnsLookup, which is what lets the conformance
suite beside it answer 203 cases without a network.
One command-handler class per IMAP command. Folder hierarchy and message flags are in
the database via Persistence/; message bodies are on disk. POP3 is much simpler and
reads the same storage.
This is the path a message actually takes, from the remote server's TCP SYN to the
last recipient's mailbox. It is drawn from SMTP/SMTPConnection.cpp,
SMTP/SMTPDeliveryManager.cpp, SMTP/SMTPDeliverer.cpp, SMTP/LocalDelivery.cpp and
SMTP/ExternalDelivery.cpp. Note where the thread changes: the 250 is sent from a
worker on the asynchronous task queue, and everything below the dividing line runs
later, on a delivery-queue thread.
sequenceDiagram
autonumber
participant P as Remote MTA
participant C as SMTPConnection<br/>IOCP thread
participant A as Async task queue<br/>15 threads
participant Q as Delivery queue<br/>10 threads
participant D as SMTPDeliverer
participant L as LocalDelivery
participant X as ExternalDelivery
participant R as Next-hop MTA
P->>C: TCP connect
C->>P: 220 banner
P->>C: EHLO peer.example
C->>P: 250 keyword list
Note over C: STARTTLS, AUTH and XCLIENT<br/>all re-shape what follows
P->>C: MAIL FROM
Note over C: SPF, DNSBL and HELO checks<br/>can run here or at RCPT
C->>P: 250 sender OK
P->>C: RCPT TO
Note over C: CheckDeliveryPossibility,<br/>relay permission, auth requirement,<br/>quota, greylisting
C->>P: 250 recipient OK
P->>C: DATA
C->>P: 354 send the message
P->>C: message octets, then CRLF.CRLF
C->>A: hand off finalization
Note over A: SpamAssassin, post-transmission<br/>spam tests, DKIM/DMARC/ARC verify,<br/>write the queue file, insert hm_messages
A->>P: 250 2.0.0 Queued (0.412 seconds)
Note over Q: --- the conversation is over ---
Q->>D: DeliveryTask picks the row up
D->>D: OnDeliveryStart, virus scan,<br/>global rules, OnDeliverMessage,<br/>DKIM sign (first attempt only)
D->>L: local recipients
L->>L: quota, account spam overrides,<br/>auto-reply, account rules, forward,<br/>trace headers, Sieve
L-->>D: delivered, or a DeliveryFailure
D->>X: remaining external recipients
X->>X: ServerTargetResolver picks a route,<br/>smart host or MX. TlsPolicy applies<br/>MTA-STS and DANE
X->>R: SMTP out
R-->>X: 250 / 4xx / 5xx
X-->>D: delivered, deferred or failed
D->>D: bounce report for failures,<br/>delete or reschedule the queue row
| Stage | Where | Can answer | Consequence of a wrong answer |
|---|---|---|---|
| Accept the TCP connection |
TCPServer, security ranges |
drop before the banner | A blocked range that should not be blocked looks to the peer like a dead host, with no log line at the sender's end |
Banner and EHLO
|
SMTPConnection::SendEHLOKeywords_ |
advertise or withhold an extension | Withholding STARTTLS makes every peer fall back to plaintext silently |
MAIL FROM |
ProtocolMAIL_, DoSpamProtection_
|
5xx, 4xx, or continue | An SPF -all refusal here rejects before the recipient is known, so the log names no mailbox |
RCPT TO |
ProtocolRCPT_ |
250, 451, 452, 530, 550, 554
|
The single most consequential decision in the server — see the flowchart below |
End of DATA
|
the finalization task |
250 or 451
|
A 451 here means the peer will resend the whole message; a 250 means this server now owns it |
| Preprocess | SMTPDeliverer::PreprocessMessage_ |
deliver, defer, or abort | An abort at this point silently destroys a message the sender was told was accepted |
| Local delivery | LocalDelivery::LocalDeliveryPreProcess_ |
file, redirect, reject, discard | A Sieve reject produces a DSN; a discard produces nothing at all |
| External delivery | ExternalDelivery::RescheduleDelivery_ |
retry or bounce | Bouncing a transient failure costs somebody their mail — see the standing rule below |
Every branch here is in SMTP/SMTPConnection.cpp ProtocolRCPT_, in the order the
code takes them. The order matters: the cheap refusals come first so a dictionary
attack costs the server as little as possible, and the tarpit is applied before
any answer so that refusals are held too.
flowchart TD
START["RCPT TO received"] --> TARPIT["TarpitRecipient_<br/>hold this answer"]
TARPIT --> TLS{"STARTTLS required<br/>and not yet done?"}
TLS -- yes --> R530A["530 Must issue STARTTLS first"]
TLS -- no --> SENDER{"MAIL FROM seen?"}
SENDER -- no --> R503["503 Must have sender first"]
SENDER -- yes --> SYNTAX{"Parses as<br/>RCPT TO:<addr>?"}
SYNTAX -- no --> R550A["550 Invalid syntax"]
SYNTAX -- yes --> EXT{"Only NOTIFY= and<br/>ORCPT= parameters?"}
EXT -- no --> R501["501 / unsupported extension"]
EXT -- yes --> VALID{"Valid address<br/>after default domain?"}
VALID -- no --> R550B["550 A valid address is required"]
VALID -- yes --> COUNT{"Under 50,000<br/>recipients?"}
COUNT -- no --> R550C["550 Too many recipients"]
COUNT -- yes --> SQ{"Account sending<br/>quota left?"}
SQ -- no --> RQUOTA["refused by quota"]
SQ -- yes --> DP["CheckDeliveryPossibility<br/>inside DatabaseUnavailableMarker::Scope"]
DP --> DBUP{"Database<br/>answered?"}
DBUP -- no --> R451["451 4.3.2 Unable to verify<br/>the recipient at the moment"]
DBUP -- yes --> FULL{"Mailbox full?"}
FULL -- yes --> R452["452 4.2.2 - temporary,<br/>so the sender's queue holds it"]
FULL -- no --> POSSIBLE{"Deliverable<br/>at all?"}
POSSIBLE -- no --> R550D["550 with the reason"]
POSSIBLE -- yes --> RELAY{"Security range allows<br/>this local/remote pairing?"}
RELAY -- no --> R550E["550 Delivery is not allowed<br/>to this address"]
RELAY -- yes --> AUTH{"Range requires auth<br/>for this pairing<br/>and none given?"}
AUTH -- yes --> R530B["530 SMTP authentication is required"]
AUTH -- no --> BIN{"BODY=BINARYMIME<br/>to a remote address<br/>without OutboundChunking?"}
BIN -- yes --> R554["554 5.6.3 Conversion required<br/>but not supported"]
BIN -- no --> SPAM["Pre-transmission spam protection,<br/>then greylisting"]
SPAM --> OK["250 recipient accepted"]
The 451/550 split in the middle of that chart is the DatabaseUnavailableMarker,
and it is the reason that class exists — see "Constraints learned the hard way".
LocalDelivery::Perform loops over the recipients whose LocalAccountID is
non-zero; this is what happens to each one, from LocalDelivery.cpp. The order is
load-bearing in three places, each marked.
flowchart TD
IN["Recipient with a local account"] --> QUOTA{"CheckAccountQuotas_"}
QUOTA -- over --> DSNQ["DeliveryFailure -<br/>a DSN unless NOTIFY excluded it"]
QUOTA -- ok --> COPY["CreateAccountLevelMessage_<br/>own file, or a hard link to<br/>the shared delivery template"]
COPY -- failed --> DSNC["4.3.1 mail system full<br/>+ HM5209 in the error log"]
COPY -- ok --> SPAMO["ApplyAccountSpamOverrides_<br/>FIRST, so everything below sees<br/>this account's own spam verdict"]
SPAMO -- "delete threshold hit" --> DROPS["dropped, and deliberately<br/>no bounce - it would be backscatter"]
SPAMO -- keep --> VAC["Account vacation message"]
VAC --> RULES["Account rules"]
RULES -- "delete action" --> DROPR["dropped"]
RULES --> FWD["SMTPForwarding"]
FWD -- "forward, no local copy" --> DONEF["forwarded"]
FWD --> TRACE["Trace headers<br/>skipped when the copy came<br/>from the traced template"]
TRACE --> SIEVE["EvaluateSieveScript_<br/>redirect, fileinto, flags,<br/>reject, discard, vacation"]
SIEVE --> DOM{"Neither account vacation<br/>nor Sieve vacation?"}
DOM -- "yes, silent" --> DOMREPLY["Domain-wide out-of-office<br/>- at most ONE auto-reply per message, ever"]
DOM -- no --> REJ
DOMREPLY --> REJ{"Sieve reject?"}
REJ -- yes --> DSNR["5.7.1 refusal DSN"]
REJ -- no --> DISC{"Sieve discard<br/>or redirect without keep?"}
DISC -- yes --> DROPD["no local copy, no report"]
DISC -- no --> FLAGS["ApplySieveFlags_<br/>BEFORE the save, or the flags<br/>are simply lost"]
FLAGS --> FOLDER["Folder selection:<br/>global rule, then account rule,<br/>then Sieve fileinto wins"]
FOLDER --> SAVE["SetState Delivered, recompute size,<br/>PersistentMessage::SaveObject"]
SAVE -- failed --> DSNS["file deleted, HM6081 reported,<br/>4.3.0 DSN - never silent loss"]
SAVE -- ok --> NOTIFY["SetFolderNeedsRefresh +<br/>NotificationMessageAdded<br/>so an idle IMAP session sees it"]
Three orderings worth knowing before you change this function:
- The account's own spam overrides run first, so the auto-replies, rules, forwarding and Sieve all see that account's view of the message rather than the shared verdict.
- The domain-wide auto-reply is decided after Sieve, because the Sieve half of "does this account already have a voice" is not known any earlier. One delivered message produces at most one auto-reply.
-
Sieve flags are applied before the save, and were not until 15 August 2026 —
setflagparsed, ran, reported success in the action summary and changed nothing.
ExternalDelivery::Perform groups recipients by resolved server, splits each group
into batches of MaxSMTPRecipientsInBatch, and gives every batch its own copy of the
ServerInfo — because DeliverToSingleDomain_ overwrites the host name with the MX
it selects. Sharing one object made every batch after the first look up the MTA-STS
policy of an MX host instead of the recipient domain, which silently stopped
enforcement.
The four ways a next hop can be chosen are tried in this order, and the order is
deliberate: a route is about where the mail is going and beats everything, a
per-domain relay is about which provider the sending domain bought, the global
smart host is the fallback for mail nothing else has an opinion about, and MX
is what the recipient's own DNS says (ServerTargetResolver.cpp:225-290).
flowchart LR
R["External recipients"] --> STR["ServerTargetResolver"]
STR --> ROUTE{"A route matches the<br/>RECIPIENT domain?<br/>wildcard match"}
ROUTE -- yes --> RT["Route target host and port,<br/>optional relayer credentials"]
ROUTE -- no --> PDR{"The SENDER's domain<br/>has its own relay host?"}
PDR -- yes --> PDRT["Per-domain relay<br/>port 25 if unset"]
PDR -- no --> SH{"Global SMTP relayer set?"}
SH -- yes --> SHT["Smart host<br/>port 25 if unset"]
SH -- no --> MX["MX lookup, preference order"]
RT --> BATCH
PDRT --> BATCH
SHT --> BATCH
MX --> BATCH["Batch by MaxSMTPRecipientsInBatch,<br/>one ServerInfo copy per batch"]
BATCH --> POL["TlsPolicy:<br/>MTA-STS policy, DANE TLSA,<br/>DNSSEC-validated"]
POL --> CONN["SMTPClientConnection"]
CONN --> RES{"Per-recipient result"}
RES -- "delivered" --> DEL["row deleted"]
RES -- "non-fatal 4xx" --> RETRY{"retries left?"}
RES -- "fatal 5xx" --> BOUNCE["DeliveryFailure -> DSN"]
RETRY -- "under QuickRetries" --> QR["SetNextTryTime<br/>QuickRetriesMinutes<br/>+ QueueRandomnessMinutes"]
RETRY -- "normal" --> NR["SetNextTryTime<br/>the configured interval<br/>+ QueueRandomnessMinutes"]
RETRY -- "exhausted" --> BOUNCE
QR --> UNLOCK["UnlockObject - a later<br/>delivery thread may take it"]
NR --> UNLOCK
QuickRetries (default 0) and QuickRetriesMinutes (default 6) exist for
greylisting: the first N failures are retried after a few minutes rather than after
the full interval (IniFileSettings.cpp:301-303). QueueRandomnessMinutes (default
0) adds jitter so a queue that failed together does not retry together.
There is no "one thread per connection" here, and no unbounded pool anywhere. Six pools exist, each created in one place, and every one of them is a ceiling that a stuck dependency will hit.
flowchart TB
subgraph created_in_InitInstance["Created in InitInstance"]
MQ["Maintenance queue<br/>5 threads<br/>Application.cpp:150"]
end
subgraph created_in_StartServers["Created in StartServers, before io_service_"]
AQ["Asynchronous task queue<br/>MaxNumberOfAsynchronousTasks, seeded 15<br/>Application.cpp:405"]
NQ["Name lookup queue<br/>same setting, seeded 15<br/>Application.cpp:413"]
end
subgraph created_after["Created with io_service_"]
IOCP["IOCPQueue<br/>tcpipthreads, seeded 15<br/>IOService.cpp:152"]
MAIN["Main server queue<br/>4 threads<br/>Application.cpp:471"]
DELQ["SMTP delivery queue<br/>maxdelivertythreads, seeded 10<br/>SMTPDeliveryManager.cpp:37"]
FETQ["External fetch queue<br/>MaxNumberOfExternalFetchThreads, 15<br/>ExternalFetchManager.cpp:39"]
end
subgraph own["Their own threads, outside the queues"]
HTTPW["HttpServer workers<br/>4, own io_context<br/>HttpServer.h worker_threads"]
RAW["MetricsServer and<br/>ManageSieveServer<br/>std::thread each"]
end
IOCP -->|"reads, writes, TLS handshakes"| AQ
IOCP --> NQ
MAIN -->|"hosts the long-lived tasks"| DELQ
MAIN --> FETQ
| Pool | Threads | Set by | What runs on it | What it looks like when it saturates |
|---|---|---|---|---|
| IOCPQueue |
tcpipthreads, seeded 15 |
Control Panel → Advanced → Performance | Every socket read, write and TLS handshake for SMTP, POP3 and IMAP | New connections are accepted but nothing progresses; clients time out mid-command |
| Asynchronous task queue |
MaxNumberOfAsynchronousTasks, seeded 15 |
same page | Message finalization — this is the thread that sends the final 250
|
Senders sit after CRLF.CRLF with no reply. This is the exact shape of GitHub discussion 18 |
| Name lookup queue | same setting, seeded 15 | same page | Blocking reverse-DNS on behalf of a live session | Reverse names degrade to "Unknown" — deliberately, so a dead reverse zone cannot stall acknowledgements |
| SMTP delivery queue |
maxdelivertythreads, seeded 10 |
Control Panel → Protocols → SMTP → Delivery of e-mail |
DeliveryTask → SMTPDeliverer for one queued message |
The queue grows and mail is late; the sender was already told 250, so nothing is lost |
| External fetch queue |
[Settings] MaxNumberOfExternalFetchThreads, default 15 |
hMailServer.ini |
One ExternalFetchClientBase run per fetch account |
Fetch accounts fall behind their schedule |
| Maintenance queue | 5, fixed | not configurable |
RunOnce scheduled tasks, submitted immediately |
A slow sweep delays other one-shot sweeps |
| Main server queue | 4, fixed | not configurable | The four long-lived tasks: Scheduler, IOService, SMTPDeliveryManager, ExternalFetchManager
|
It cannot saturate in normal operation — each task occupies one slot for the life of the server |
HttpServer workers |
4, HttpLimits::worker_threads
|
not configurable | One REST or web-services request each, and a handler may block | Requests queue; SMTP is unaffected, which is the reason for the separate io_context
|
Only the asynchronous task queue and the name lookup queue are opted into stall
monitoring (WorkQueue::SetMonitorForStalls). The other four are deliberately not:
their tasks are long-lived by design, and monitoring them would report the healthy
state of a stock installation once a minute.
Application::CreateSessionWorkQueues_ carries the longest comment in the file, and
it is worth reading before moving either call. A task on those two queues holds a
shared_ptr to a session, and that session owns a socket built on io_service_'s
io_context. When the queues were created in InitInstance and removed in
ExitInstance, they straddled the io_context rather than nesting inside it: on a
Reinitialize, a reverse-DNS prefetch finished 1.9 seconds after "Destructing IOCP",
dropped the last reference to its SMTPConnection, and faulted inside
~basic_stream_socket writing through a destroyed context. Creating them in
StartServers before io_service_ exists, and removing them in StopServers before
it is reset, makes the ordering an invariant of those two functions instead of
something every new queue has to remember.
An SMTP session is a state machine, and the states are literal: ConnectionState in
SMTP/SMTPConnection.h:289-303. Anything that reads a line has to know which state it
is in, because in DATA and BDATDATA the bytes are message content, and in the four
SASL states they are base64 continuations.
stateDiagram-v2
[*] --> INITIAL: accept, banner sent
INITIAL --> STARTTLS: STARTTLS
STARTTLS --> INITIAL: handshake complete,<br/>state reset, re-EHLO required
INITIAL --> SMTPUSERNAME: AUTH LOGIN
SMTPUSERNAME --> SMTPUPASSWORD: username accepted
SMTPUPASSWORD --> INITIAL: authenticated or refused
INITIAL --> SMTPSCRAMFIRST: AUTH SCRAM-SHA-256
SMTPSCRAMFIRST --> SMTPSCRAMFINAL: server-first sent
SMTPSCRAMFINAL --> SMTPSCRAMACK: server-final sent
SMTPSCRAMACK --> INITIAL: empty ack
INITIAL --> SMTPBEARERRESPONSE: AUTH XOAUTH2 / OAUTHBEARER
SMTPBEARERRESPONSE --> INITIAL: token checked
INITIAL --> SMTPEXTERNALRESPONSE: AUTH EXTERNAL
SMTPEXTERNALRESPONSE --> INITIAL: client certificate identity checked
INITIAL --> DATA: DATA, 354 sent
DATA --> INITIAL: CRLF.CRLF,<br/>ResumeCommandModeAfterData_
INITIAL --> BDATDATA: BDAT n
BDATDATA --> INITIAL: n octets read
INITIAL --> [*]: QUIT, timeout,<br/>session ceiling, or excessive data
Two rules the state machine enforces that are easy to break:
-
Leaving
DATAmust go throughResumeCommandModeAfterData_. It hands back the bytes a pipelining client sent behind the end-of-data marker so they are parsed as the commands they are.SetReceiveBinary(false)plusEnqueueRead()loses a pipelinedQUITor the nextMAIL FROMsilently. -
STARTTLSresets the session. A client that skips the secondEHLOis answered503; the keyword list it was given before the handshake is not the one that applies after it.
IMAP and POP3 have the same shape with different states; IMAP additionally has
COMPRESS=DEFLATE (new in 6.2.28), which inserts a DeflateStreams layer under the
connection and is one-way — once compression is on there is no going back for the life
of the session.
flowchart LR
subgraph callers["Callers"]
PROTO["Protocol handlers"]
COMI["COM interfaces"]
RESTI["REST handlers"]
end
CACHE["Cache/<br/>AccountCache, MessageCache,<br/>InboxIDCache, AccountSizeCache<br/>CacheContainer"]
BOX["BO/<br/>Account, Domain, Message,<br/>Rule, DistributionList, ..."]
PER["Persistence/<br/>PersistentAccount, PersistentMessage,<br/>one class per business object"]
CMD["SQLCommand + SQLParameter<br/>parameterised, always"]
MAC["SQL/Macros<br/>MSSQL, MySQL, PGSQL, SQLCE<br/>expanders"]
POOL["DatabaseConnectionManager<br/>pool + DatabaseUnavailableMarker"]
DAL["DALConnectionFactory"]
MSSQL[("MS SQL Server<br/>ADOConnection")]
MYSQL[("MySQL / MariaDB<br/>MySQLConnection")]
PG[("PostgreSQL<br/>PGConnection")]
CE[("SQL Server Compact 4.0<br/>SQLCEConnection")]
PROTO --> CACHE
PROTO --> BOX
COMI --> BOX
RESTI --> BOX
CACHE --> PER
BOX --> PER
PER --> CMD
CMD --> POOL
MAC -.->|"DDL only"| DAL
POOL --> DAL
DAL --> MSSQL
DAL --> MYSQL
DAL --> PG
DAL --> CE
The four backends are selected by DatabaseSettings::Type* in
SQL/DALConnectionFactory.cpp:53-64. They are not interchangeable in every respect:
| Backend | Connection class | Typical use | Known constraint |
|---|---|---|---|
| MS SQL Server |
ADOConnection (msado28) |
Larger installations already running SQL Server | The .tlh/.tli import files are committed; a rebuilt one must not carry an absolute path |
| MySQL / MariaDB |
MySQLConnection via libmysql.dll
|
The most common backend upstream | Connector/C is copied beside the exe by post-build.bat, plugins included |
| PostgreSQL |
PGConnection via libpq
|
Preferred on new installations | libpq is built from source by libraries/build-pgsql.ps1
|
| SQL Server Compact 4.0 | SQLCEConnection |
The embedded default — no server to install | Row-size and expression limits are real: CASE WHEN EXISTS in a DBUpdater probe crashed the provider and took the service down (GitHub issue 114, fixed in 6.2.26) |
The COM API is the seam. All configuration and management goes through
Server/COM/. The Control Panel, the regression suite and every third-party script
use it. A new configurable feature normally needs a COM property or method — and
because the test suite drives COM, that is also how the feature becomes testable.
Persistence is layered. BO/ → Persistence/ → SQL/, with Cache/ in front
for frequently-read objects. A feature that persists new data touches all of the
first three, and Cache/ if it is on a hot path.
Four database backends, one abstraction. MySQL/MariaDB, MS SQL Server,
PostgreSQL and the embedded SQL CE. Use parameterised queries exclusively — never
build SQL by string concatenation. Backend-specific DDL goes through the macro
expanders in SQL/Macros/, which recognise a deliberately small vocabulary; if you
need something they do not express, that is a design conversation, not a place to
special-case.
A transaction is its thread's. DatabaseConnectionManager::Execute and
OpenRecordset borrow a pooled connection for each statement, so a transaction begun
on one connection covers nothing the persistence layer does. DatabaseTransaction
(Common/SQL/DatabaseTransaction.h) is the scope that does: while it is open, every
Execute and OpenRecordset made through the same manager on the thread that opened
it runs on its connection, so an existing save joins it unchanged, and a scope opened
inside another joins that one. No statement inside it is run again after a reconnect -
on a new session it would commit outside the transaction - except on a MySQL database
that is not transactional (a table that is not InnoDB), where a scope runs its
statements as the pool does; so the first failure fails the scope, and
DatabaseTransaction::Run runs the whole unit of work again on a deadlock, a
serialisation failure or a lost connection. A failure a caller asks to ignore with
DALErrorInSQL is ignored inside a scope only when it is a duplicate key, never
another integrity violation such as a foreign key's. On SQL Server Compact the pool
is the server's one connection, so an open scope holds it and every other thread waits
for it: keep a unit short. Take any lock of the process
before opening a scope, never inside one, and keep the unit to database work: it may
run more than once, and an event script run while it is open (OnError) runs outside it.
The schema is pinned, one way. REQUIRED_DB_VERSION in
Common/Application/Constants.h (6037 on 12 September 2026) must equal
hm_dbversion.value; the server refuses to start on an older or newer database
(error 5011; 5010 when the version cannot be read). The refused database stays open
for hMailServer.Database, which is how DBUpdater brings it up to date; every other
object hMailServer.Application hands out - Settings, Domains, Rules, Links,
BackupManager, GlobalObjects, Diagnostics - is refused with the 5011 text until
a Reinitialize or a restart succeeds, because the configuration they read was never
loaded (Application::IsInitialized; before 12 September 2026 a call into Settings
on such a server was an access violation inside the COM call). A schema change is four
DBScripts/Upgrade<from>to<to><backend>.sql files, a new UpgradeScript(from, to) row
in Tools/DBUpdater/formMain.cs, a probe statement DBUpdater runs after the step, and
the bump in Constants.h; build/check-schema-versions.ps1 reconciles them and
build/check-db-scripts.ps1 builds a database from the create script and executes
every probe through the SQL Server Compact provider, with a negative control — a probe
the provider could not run took the service down with it once (GitHub issue 114, 6.2.26). One rule
of the scripts' text: SQLScriptParser splits a script into commands on a blank line
and sends each command as one query, and MySQL refuses a command holding two
statements, so every statement in a MySQL script is followed by a blank line -
build/check-sql-separators.py enforces it on every push, after two CREATE INDEX
lines written together left the MySQL create script unable to run from 12 September
2026's tenth webmail wave until the Linux build's database gate said so the same day.
Four files, one row, one probe, one constant — and a check for each:
flowchart LR
subgraph edits["What a schema change touches"]
S1["Upgrade6030to6031MSSQL.sql"]
S2["...MSSQLCE.sql"]
S3["...MySQL.sql"]
S4["...PGSQL.sql"]
ROW["new UpgradeScript(6030, 6031)<br/>Tools/DBUpdater/formMain.cs"]
PROBE["A probe statement<br/>SchemaVerification.cs"]
CONST["REQUIRED_DB_VERSION<br/>Common/Application/Constants.h"]
CREATE["CreateTables*.sql<br/>fresh installs stamp the new number"]
end
subgraph checks["What proves it"]
C1["build/check-schema-versions.ps1<br/>chain contiguous, forward-only,<br/>all four dialects present"]
C2["build/check-db-scripts.ps1<br/>build a real SQL CE database,<br/>run every probe, negative control"]
end
S1 --> C1
S2 --> C1
S3 --> C1
S4 --> C1
ROW --> C1
CONST --> C1
PROBE --> C2
CREATE --> C2
S2 --> C2
The database is the settings store; hMailServer.ini is the bootstrap. Since
schema 6042 the 241 keys the [Settings] section used to hold are read from
hm_inisettings and written to it, and the section in the file is a CACHE the server
keeps current - because a handful of readers cannot go to the database, and one of
them never can: hMailServer.exe /Register reads ServiceAccountName before there
is a database to read. A value edited into the file is named in the error log and put
back at the next start; a key in a [SettingsOverride] section IS honoured, over
everything, and announced at every start, which is the door for a database that is
unreachable or holds a value the server will not start on. IniSettingStore is where
all of that lives and its header is the reference. The file keeps [Directories],
[Database], [Security] and [GUILanguages]: how the database is reached, how an
administrator is let in without one, and the language list that is read before it is
open.
A save of a setting publishes it. Since 22 September 2026 the values those keys
are read into are one immutable block (IniFileSettings::Values) swapped atomically,
and a write to the store reloads and publishes it: a key marked now in
build/ini-settings-baseline.txt is in effect when the save returns, one marked
reinitialize (a listener, a TLS context, a work queue's size) waits for a
reinitialise, one marked restart for the service to start again, and the server
lists what waits (GET /api/v1/server/settings/pending). A subsystem that copies a
value when the servers start subscribes and re-latches it
(IniFileSettings::Subscribe). A new getter returns a copy, never a reference into
the block. docs/SettingsApply.md has the marks, the inventory and the reasons.
A new server-wide setting goes in hm_settings, not in the file. That is the
rule from 15 September 2026, written in Contributing and held by
build/check-ini-coverage.py against the committed baseline
build/ini-settings-baseline.txt: a key that appears in [Settings] and is not in
the baseline fails the check. The existing 241 keep the shape they have - a getter in
IniFileSettings.h, the default on the member declaration, a ReadIniSetting*_ call
in IniFileSettings.cpp and a control in the Control Panel's FeatureSettingsView -
they are simply read from the database now. Per-account and per-domain settings go in
the database as they always did.
Two of the optional listeners are now Boost.Asio, two are not. RestApiServer and
WebServicesServer are hosted on Common/Util/HttpServer — an HTTP/1.1 server on
Boost.Asio with its own io_context (separate from the mail listeners', so a request
storm cannot starve SMTP accept), a bounded worker pool (4 threads), a connection cap
(64) and absolute per-request (30 s) and per-connection (300 s) deadlines; a handler
runs on a worker and may block. This is new in 6.2.28; before it, both were raw
sockets and std::thread like the other two. MetricsServer and ManageSieveServer remain raw sockets and std::thread,
outside the TCPIP/ stack. All four are started from Application::StartServers
only when their port is non-zero. Two things to know, both of which have bitten the
raw-socket pair:
-
A thread of their own has no exception barrier by default. An exception
escaping the top of a raw listener thread is
std::terminate— the whole mail server dies. Anything that can throw, including any database call, needs atry/catchinside the thread. (HttpServercatches around each handler and answers 500, and again at the top of each worker.) -
They share the mail listeners' TLS configuration. Each builds a
boost::asio::ssl::contextand hands it toSslContextInitializer::InitServer(MetricsServer.cpp:730, RestApiServer.cpp:618, WebServicesServer.cpp:472, ManageSieveServer.cpp:497), so cipher lists, key-exchange groups and protocol floors applied there reach all four; there is no secondSSL_CTXconfiguration to keep in step. The REST listener alone adds a TLS 1.2 floor of its own afterInitServer, which can only tighten what the shared configuration allows.
From Common/Util/HttpServer.h, struct HttpLimits. Every one is absolute — a
deadline from a fixed moment — rather than an idle timeout a client can keep alive by
sending one byte at a time.
| Limit | Default | Measured from | What a client sees when it trips |
|---|---|---|---|
max_request_bytes |
64 KiB | head + body together | 413, refused before the body is read when Content-Length would exceed it |
max_request_bytes_large / request_seconds_large
|
0 / 0 (off) | granted per request by the large-request filter | A larger body, with a longer clock, only for the method+target the filter approves |
request_seconds |
30 s | the moment the server started waiting for the request — on a kept-alive connection, when the previous response was written. Handler time counts | The connection is closed mid-request |
connection_seconds |
300 s | accept | The connection is closed whatever it is doing |
max_requests_per_connection |
1000 | per connection | Closed after the thousandth response, so a client that never disconnects still pays an accept occasionally |
max_connections |
64 | server-wide | Closed at accept, before any TLS handshake, so a flood costs an accept and a close each |
worker_threads |
4 | — | Requests queue behind the four |
Scheduled work uses BO/ScheduledTask and the Scheduler. RunOnce tasks go
through the maintenance work queue immediately; recurring tasks are polled once a
minute. Application::CreateScheduledTasks_ registers, in order:
| Task | Cadence | Registered only when | Source |
|---|---|---|---|
GreyListCleanerTask |
every GreylistingExpirationInterval minutes |
SMTP is on | Application.cpp |
RemoveExpiredRecords |
every minute | always | expired IP ranges |
TlsRptReporterTask |
hourly | always registered; sends only when TlsRptFromAddress is set |
RFC 8460 |
DmarcRptReporterTask |
hourly | always registered; sends only when its *RptFromAddress is set |
DMARC rua
|
LogRetentionTask |
at start, then every 6 h | always | |
ArchiveRetentionTask |
start + 12 h | always | |
MailboxRetentionTask |
start + 6 h | always | message retention, 6.2.25 |
MetricsHistoryTask |
start + every minute | always | 6.2.25 |
UpdateCheckTask |
start + every 15 min | always registered; a no-op until UpdateCheckEnabled=1
|
new in 6.2.28 |
IMAPExpungeRetentionTask |
start + 12 h | always | |
MessageStoreConsistencyTask |
start + hourly | always | |
DiskSpaceMonitorTask |
start + hourly | always | |
WorkQueueHealthTask |
every minute | always | the stall monitor's reporter |
BackupScheduleTask |
a one-minute tick against the wall clock | only when a schedule is set | |
DirectorySyncScheduleTask |
start + every [LDAP] SyncScheduleMinutes
|
only when that is set | |
AcmeRenewalTask |
start + hourly | only when AcmeEnabled=1
|
A startup-plus-periodic pair is the shape for a sweep that must also run on a server
that is only ever restarted (source: Common/Application/Application.cpp:600-860).
These are the ones that cost real releases. They are not stylistic.
Every wait on a pooled thread needs a ceiling. The server runs work on bounded
pools — a 15-thread async queue that also sends the SMTP 250, a 10-thread delivery
queue. A dependency that stops responding consumes threads until none are left, and
then the server accepts mail and never replies. That was
GitHub discussion 18, on the project's former GitHub home, and
once found the same shape turned up in DNS, virus scanning, event scripts, external
processes and outbound delivery. Every one of those now has a bound, and a new one
must arrive with one.
An idle timeout is not a ceiling. Idle timeouts here re-arm on every byte
received, so a peer that dribbles one byte at a time is never idle and holds the
connection — and anything waiting on it — indefinitely. Absolute session ceilings are
a separate mechanism (ClientSessionCeiling, default 1800 seconds,
IniFileSettings.cpp:378) for exactly this reason.
Distinguish "the answer is no" from "there was no answer." A recipient lookup
that fails because the database did not respond used to be indistinguishable from one
that found nothing, so a database locked by a backup told the sender a valid mailbox
did not exist and the mail was bounced. The fix is the thread-local
DatabaseUnavailableMarker with its RAII Scope, read at both RCPT TO decision
points to answer 451 instead of 550. Two non-obvious constraints if you extend
it: set the marker after releasing the pool lock and before ReportError,
because the error path can run an OnError script that re-enters the pool on the
same thread.
shared_from_this() is invalid in a constructor. It throws bad_weak_ptr. Timers
and anything else needing a shared_ptr to the object must be armed from Start(),
not the constructor.
A new diagnostic must not fire on the shipped default configuration. Reporting a
default as an ErrorManager error puts a Medium entry in every stock install's ERROR
log — and fails the regression fixtures, which assert a clean log. If it describes a
default, it is LOG_APPLICATION.
Prefer deferral to bouncing, always. A temporary failure costs a retry. A
permanent one costs someone their mail. The whole 451/452/4.x.x vocabulary in
the flowcharts above exists to serve this one rule; when in doubt about a new failure
path, look at what class the code already assigns to the nearest neighbour and match
its reasoning, not its number.
There are 503 ErrorManager::ReportError call sites in the server - 500 of them with a
literal code, which is what the census below counts - and choosing the
severity and the number for a new one is not a formality: the severity decides what an
administrator sees, and a wrong choice fails the regression fixtures, which assert a
clean ERROR log.
flowchart TB
SITE["ReportError(severity, HM number, source, description)"] --> NORM["Source and description normalised -<br/>control characters out, so a peer's banner<br/>or a FormatMessage string cannot forge a log line"]
NORM --> LINE["ERROR log line:<br/>Severity: n (name), Code: HMnnnn,<br/>Source: ..., Description: ..."]
LINE --> EVT["Windows Event Log, when<br/>WindowsEventLogEnabled is on"]
LINE --> READING{"Reported while this thread<br/>reads UseScriptServer?"}
READING -- "yes (6.3.6)" --> QUIET["Logged; no event.<br/>Until 6.3.6 a missing UseScriptServer row<br/>recursed here until the stack ran out"]
READING -- no --> SWITCH{"Scripting on?<br/>(UseScriptServer; a switch it<br/>cannot read answers off)"}
SWITCH -- no --> OFF["No event"]
SWITCH -- yes --> SCRIPT{"Already inside OnError<br/>on this thread?"}
SCRIPT -- no --> FIRE["OnError(severity, number, source, description)"]
SCRIPT -- yes --> GUARD["NOT fired again, and a line says so.<br/>Recursing here would exhaust the stack"]
LINE --> METRIC["Counted; the ERROR log is what the<br/>regression suite's AssertNoReportedError<br/>and AssertReportedError read"]
| Severity | Value | Means | Where it should be used |
|---|---|---|---|
Critical |
1 | The server cannot continue doing something it is supposed to do | 48 sites. Includes HM6364, the violated HM_ASSERT on an -Asserts build |
High |
2 | A real failure with a consequence for mail | 144 sites. HM6081 — a message could not be saved during local delivery — is the shape |
Medium |
3 | Something went wrong and was handled | 299 sites, the majority |
Low |
4 | Worth recording, no consequence | 9 sites |
Number bands, from the same census: 4xxx is inherited from upstream, 5xxx is the
main body, and 6xxx is where this fork's own codes live — 129 call sites so far. A new
code takes the next free number in the 6xxx band, and Common/Application/Errors.txt
is the historical list rather than a live registry (it stops at 126 lines and does not
track the newer codes).
Three rules that are not style preferences:
-
A new diagnostic must not fire on the shipped default configuration. Reporting a
default as an
ErrorManagererror puts a Medium entry in every stock install's ERROR log and fails the fixtures. If it describes a default, it isLOG_APPLICATION. - Do not report on a per-attempt path. An error reported once per failed authentication is a log an attacker can fill at will. Where a condition is a property of stored state rather than of the attempt, report it once per row per server start — the app-password path does exactly this.
-
Do not report on a successful-authentication path either. An unwritable database
during a password-hash upgrade would otherwise report on every login by every affected
account, and
DALConnectionhas already reported the underlying SQL failure, so the ERROR log would carry two entries per login for one root cause.
A collection another thread can change is iterated as a locked copy.
Collection<T,P> (Common/BO/Collection.h) keeps its items in one vector behind a
mutex that GetCount, GetItem, AddItem, the deletes and the refresh all take.
GetVector() and GetConstVector() hand out the vector itself and take nothing, so a
range-for over them - or a copy of them - while another thread refreshes the
collection (a swap, and the old buffer freed), adds to it (a reallocating
push_back) or erases from it is a read of freed memory. That was the
heap-corruption abort of 15, 22 and 23 September 2026: the IMAP special-use pass
walking an account's shared folder tree while other sessions created and deleted
folders. An index walk - GetCount() and GetItem(i) - over such a collection is
memory-safe, since each call locks, but a refresh between two GetItem calls
re-indexes the collection under it and an item is skipped or visited twice, so it
counts too. Anything another thread can reach - a cache, a singleton, a
configuration object, a folder tree - is iterated through GetSnapshot(), the locked
copy, taken once, or by a member of the collection that holds the mutex. The one
deliberate exception is DomainAliases::ApplyAliasesOnAddress, which walks
ObjectCache's domain aliases unlocked: that object is built and refreshed under
ObjectCache's mutex before it is published, and a reload replaces it rather than
changing it, so its readers read a vector nobody writes - on the path every RCPT TO
and every logon takes, where a copy per call is a cost for nothing. A collection
loaded by the function, a message's own recipients, and a collection a getter builds
new for every caller may keep GetVector(), with // owned by this call and the
reason as the comment on the call's line or as the whole of the line before: one
marker to one call, none inside a #define, and a marker on a forwarding accessor -
a function that hands GetVector() on to its callers - is refused in review, since
it would exempt every caller. build/check-collection-iteration.py fails on any call
of either that breaks this; it does not see index walks.
A locked copy protects the vector, not its elements. A session holding a route or a
relay out of a snapshot reads its fields while whoever changes it writes them, and a
string assigned under a reader frees the buffer the reader is copying - the same
use-after-free, one level down. So a change to an element of a shared configuration
collection is written onto a new object, which is saved, and the collection is then
refreshed, swapping in new elements under its lock while readers keep the ones they
hold. REST does that for a route (PUT /api/v1/routes/{id}), a DNS black list or
SURBL server, an incoming relay, a remote domain policy, a server message, a blocked
attachment and a group, and COM does it for every item of such a collection (below).
An element's own
children are loaded before it is published - PersistentRule::ReadObject loads a
rule's criteria and actions, and PersistentRoute::ReadObject a route's addresses,
inside the refresh that builds the collection - and Route::GetAddresses' lazy path,
left for a route built in memory, is locked. A reader that takes the mutex waits for
a concurrent refresh to finish its database read, since DBLoad_ holds the mutex
across it; the relay check and the route lookup now do, as GetItemByName always has.
Stress/SharedCollections.cs races two of these from outside for thirty seconds
each, in the Windows suite (the Linux project lists its stress sources by name and
does not carry it): ObjectCache's domain and account rules, created and deleted over
REST while three accounts submit through the webmail's send route and delivery
applies them, and SMTPConfiguration's routes, appended to, refreshed and erased from
over REST while the send route resolves an unhosted address past forty standing
routes. Both are survival smoke tests, not seen to catch the old code: against a build
with HasRejectAction and WouldReject put back to GetVector() and ApplyRules to
its index walk, the rules race passed three times in three, and the routes race has
not been run against it. The relay lock, the route-address lock and the alias snapshot
have no test and are proven by reading.
Swept on 24 September 2026: every call of GetVector() and GetConstVector() - on
master there were 87, and two classes of their own that declare an accessor of the
name, and three comments that mention it - and, after the review, the two index walks
over ObjectCache's rules. Index walks elsewhere were not swept; among those seen are
IMAP SELECT/EXAMINE collecting the keywords of an account's cached messages and
the REST listing and the alert check walking the shared SSL certificates.
| File | Sites | Verdict | Why |
|---|---|---|---|
SMTP/RuleApplier.cpp |
HasRejectAction, WouldReject
|
shared: GetSnapshot()
|
ObjectCache's global and domain rules, which RejectedBySubmissionRules hands them for every message accepted over SMTP and every webmail send; a domain's are refreshed in place by GetDomainRules on whichever thread asks first after a save, the global ones by a restore |
ApplyRules (an index walk) |
shared: GetSnapshot(), taken once |
the account, domain and global rules delivery applies; an account's and a domain's are refreshed in place by GetAccountRules/GetDomainRules
|
|
SendPolicyNotice_, RejectedBySubmissionRules, ApplyAction_Copy, the recipient-list criterion |
owned | the recipients of the message the delivery or the submission holds | |
Common/Util/RestApiServer.cpp |
HandleListRules_ (an index walk) |
shared: GetSnapshot()
|
the same cached rules, listed |
Common/Util/ImpersonationCheck.cpp |
HostedDomains_ |
cached, to a snapshot defensively | ObjectCache's domain aliases, replaced on reload and never changed, so nothing writes them today; off the hot path, unlike ApplyAliasesOnAddress
|
Common/BO/IncomingRelays.cpp |
IsIncomingRelay (a member, on vecObjects) |
shared: now locked | SMTPConfiguration's relays, asked by every session and changed in place by COM and REST |
Common/BO/Routes.cpp |
GetItemByNameWithWildcardMatch (member) |
shared: now locked | SMTPConfiguration's routes; InterfaceRoutes holds this very object |
Common/BO/RouteAddresses.cpp |
DeleteByAddress (member) |
shared: now locked | a shared route's addresses, erased by COM while sessions ask GetItemByName
|
SMTP/SMTPConnection.cpp |
12 | owned | the session's own message's recipients (two take the count before and after the parser appends to them) |
SMTP/SMTPDeliverer.cpp, LocalDelivery.cpp, BounceTracker.cpp, ComplaintTracker.cpp, DistributionListSender.cpp, ServerTargetResolver.cpp, MirrorMessage.cpp
|
4, 2, 3, 2, 2, 2, 3 | owned | the recipients of the message the delivery holds (LocalDelivery::Perform and BounceTracker::ApplyAtDelivery erase through the reference on purpose; one MirrorMessage site is the copy it made) |
SMTP/RecipientParser.cpp |
5 | owned | three the recipient list the parser is building (two read after a recursion appended to it), two DistributionList::GetMembers(), which loads a new collection for every caller |
SMTP/SMTPMessageHeaderCreator.cpp |
1 | owned | the session's message |
SMTP/SMTPVacationMessageCreator.cpp, DeliveryQueue.cpp
|
1, 1 | owned | a local collection loaded above |
COM/InterfaceRecipients.cpp |
2 | owned | the COM message's recipients; the object is apartment-threaded |
Common/AntiSpam/BlockedSenderCache.cpp, GreyListingBypass.cpp, SenderListCache.cpp, WhiteListCache.cpp
|
1 each | owned | copied FROM the local collection just loaded; the cache's own vector is guarded by the cache's shared_mutex
|
Common/AntiSpam/QuarantineStore.cpp, SpamTestFilterHook.cpp, SpamTestSpamAssassin.cpp, DKIM/DKIMSigner.cpp
|
1, 1, 1, 2 | owned | the recipients of the message under test or being signed |
Common/AntiSpam/DKIM/DkimRotation.cpp |
2 | owned | local collections |
Common/AntiVirus/VirusScanner.cpp |
1 | owned | the attachments this scan parsed; Attachments::GetVector returns a copy |
Common/Application/ACLManager.cpp |
1 | owned |
GetPermissionsSetOnFolder loads the permissions new for every question |
Common/Application/DomainSamples.cpp, SampleDomain.cpp, ScheduledMailTask.cpp
|
1 each | owned | the caller's message; a message the loop read; the message the task built |
Common/Diagnostics/TestIPRanges.cpp |
1 | owned | a local collection |
Common/Persistence/PersistentDomain.cpp |
1 | owned | the caller's own Domain (a REST local, or a COM object on its apartment), never the cache's |
Common/Persistence/PersistentMessage.cpp |
1 | owned | the message its owner is saving |
Common/Sieve/ManageSieveServer.cpp, Common/Util/AutoBanFirewall.cpp (ListeningPorts), RestApiServer.cpp (HandleSrv_), WebServicesServer.cpp
|
1 each | owned |
Configuration::GetTCPIPPorts() loads a new collection for every caller - the configuration keeps none |
Common/Util/AutoBanFirewall.cpp (ActiveBans_), SetupChecklist.cpp, EmailAllUsers.cpp, PasswordValidator.cpp, RestApiAppPasswords.cpp
|
1, 2, 1, 1, 1 | owned | local collections |
Common/Util/DistributionListMembership.cpp |
2 | owned |
GetMembers() loads new |
Common/Util/CompromiseDetector.cpp, ContentPolicy.cpp, DisclaimerAdder.cpp, DomainReports.cpp, Journaling.cpp, MailerDaemonAddressDeterminer.cpp, MessageRecall.cpp, SignatureAdder.cpp
|
1 each | owned | a message's own recipients |
ExternalFetcher/ExternalFetchManager.cpp |
1 | owned |
fetch_accounts_ is made, refreshed and walked by the manager's thread alone; tasks get accounts, not the list |
Common/BO/MessageRecipients.h, Attachments.h
|
declarations | not calls | classes of their own with no lock, each held by one message or one MessageData
|
Common/BO/Messages.h, IMAP/IMAPSpecialUse.cpp, IMAP/MessagesContainer.cpp
|
comments | not calls |
The members of the Collection subclasses that touch vecObjects themselves:
IMAPFolders and Messages (the shared folder tree and message cache) take the
mutex in every one; IncomingRelays, Routes and RouteAddresses did not and now
do. ACLPermissions, AppPasswords, DistributionLists, Domains,
FetchAccountUIDs, GroupMembers, RuleActions, Rules (the move and sort-order
members, which COM calls on its own collections, never ObjectCache's) and
TCPIPPorts are only ever used by the call or the COM object that loaded them, and
DomainAliases::ApplyAliasesOnAddress is the exception above. Nothing outside a
collection reaches into its vecObjects.
A COM change to an item of a shared collection is written onto a copy. COM hands
out the running server's own collections for the SMTP configuration's routes,
incoming relays and remote domain policies, the anti-spam configuration's DNS black
lists and SURBL servers, the server messages, the blocked attachments, the SSL
certificates, the IMAP groups and a running route's addresses, and each item it
hands out of one is the object every session reads. COMCollectionItem
(COM/COMCollection.h) keeps the rule for all of them: a collection that is one of
these attaches its items with AttachSharedParent, and the first put_* on such an
item copies it - from the collection's item with its id as the running server has it
now, so a refresh since the item was handed out is not undone - after which the COM
object holds and writes the copy and its get_* read it. Save() saves the copy and
reads the collection again (PublishSaved_), which swaps in new items under the
collection's lock while a session keeps the one it holds. A Save() with nothing put
before it copies first as well, so the object a persister is handed is never the
running item - PreSaveLimitationsCheck trims a remote domain policy's key on the
object it saves - and an object handed out before a refresh replaced its item saves
the running item's values rather than writing back the ones it was handed. A new
item - Add() - is nobody else's until its Save() appends it, and is written directly
until then; after that it is an item of the collection like any other. A route cannot
be copied by its copy constructor (it holds the lock over its lazily loaded
addresses), so Route::CopyFields makes the copy for COM and for REST's route update
alike. InterfaceRoute::get_Addresses hands out the running route's list whether or
not the route object holds a copy, so an address added or deleted is in effect at once,
as it always was; an address changed in place is copied like any other item. A route's
Addresses read before a refresh of the routes replaced the route - the route's own
COM Save(), Routes.Refresh(), a REST update - is the retired route's list; an
address saved or deleted through it is saved or deleted in the database and then
InterfaceRouteAddresses::ReachRunningRoute reads the running route's list again, so
it is in effect as it would have been through the running list. The retired list itself
goes on reading as it was, with its own change; Addresses read again is the running
list.
A read of the collection that fails after a successful save leaves the change in the
database and not yet in effect, and COM reports it as error 7390; its Save() still
answers success, since the save did. Until the list is next read, a later change to the
same item starts from the running item, which still has the old values, and its save
writes them back over the first change - the error text says so. REST's updates log a
failed re-read in the application log and answer as the save did; they do not report
7390.
What a COM caller sees, which is why this was a row of its own: a put_* is not in
effect until Save() - the running server, and any other COM object read from the
collection before the save, keep the values they had - and a put_* never followed
by a Save() changes nothing at all. After the save, an object read from the
collection again has the saved values, and one read before it keeps what it read.
A Save() the database refuses leaves the running item as it was; the object keeps
the refused values to be corrected and saved again, except a remote domain policy,
which is read back as the database holds it, as it was before the copy existed. Two
places in the suite relied on the old behaviour and now save: the basic setup's
switching off of multi.surbl.org (a put_* with no Save, which had switched it
off in memory while the database kept it on) and SMTPClientTests's relay
credentials. REST's server message update wrote the running item before saving it,
and its blocked attachment and group updates wrote the saved values back onto the
running item; all three now save a copy and read their collection again.
build/check-com-shared-copies.py holds the ten item classes to this: no put_*
writes the item, Save() copies before it saves and publishes after, and every place
that hands one of them out attaches the shared collection; its --self-test proves
each is reported, and against the COM sources before the row it reports all 59 puts
and all 38 hand-outs.
Swept on 24 September 2026, every COM class that wraps an item of a collection:
| Class | Verdict | Why |
|---|---|---|
InterfaceRoute, InterfaceIncomingRelay, InterfaceDNSBlackList, InterfaceSURBLServer
|
fixed: onto a copy | the row's four; SMTPConfiguration's and the anti-spam configuration's shared collections, read by every session |
InterfaceRouteAddress |
fixed | a running route's addresses, asked by every session through GetItemByName
|
InterfaceRemoteDomainPolicy |
fixed | SMTPConfiguration's policies, read by every delivery; its refused save used to put the running record back by reading it in place |
InterfaceServerMessage |
fixed |
Configuration's messages, copied by every bounce and virus notice; it was attached with no parent at all |
InterfaceBlockedAttachment |
fixed |
Configuration's list, compared with every attachment's name by the virus scanner |
InterfaceSSLCertificate |
fixed |
Configuration's certificates, read by the listeners at start, the alert check, the metrics scrape and REST |
InterfaceGroup |
fixed | IMAPConfiguration's groups, looked up by name by IMAP sessions (GETACL, SETACL) - and handed out by InterfaceIMAPFolderPermission::get_Group as well, with no parent before |
InterfaceTCPIPPort, InterfaceSecurityRange, InterfaceWhiteListAddress, InterfaceGreyListingWhiteAddress, InterfaceGreyListingBypassEntry, InterfaceBlockedSender, InterfaceSenderListEntry
|
safe | the collection is loaded new for the COM object (GetTCPIPPorts, SecurityRanges::Refresh, the anti-spam configuration's getters); sessions read caches that copy under a lock of their own |
InterfaceDomain, InterfaceAccount, InterfaceAlias, InterfaceDistributionList, InterfaceDistributionListRecipient, InterfaceDomainAlias, InterfaceRule, InterfaceRuleCriteria, InterfaceRuleAction, InterfaceFetchAccount, InterfaceAppPassword, InterfaceGroupMember, InterfaceIMAPFolderPermission
|
safe | loaded by COM for itself; the caches hold objects of their own (const domains and accounts, ObjectCache's rules and aliases), and group members and permissions are loaded new for every question |
InterfaceIMAPFolder, InterfaceMessage
|
left | an account's or the public folders' cached tree and messages, which the IMAP server itself writes in place (a RENAME sets the folder's name, a STORE its flags) and REST's mailbox rename too: a copy on the COM side would not make the folder safe, and the folder's own fields want a lock of their own, which is another row |
API/ComWritesOntoCopies.cs proves the caller's side for each of the ten classes: a
put_* read back from its own object, the running item unchanged before Save(), the
saved values after it, and an object read before the save keeping what it read - each
of those tests fails against the code before the row, which is traced in the fixture's
summary (the group's reads go through one Groups object, since Settings.Groups
reads the groups from the database every time and would show the old name either
way). Beside them: a refused route save leaving the running route alone, an address
added through a route object that holds a copy going into the running route's list, an
address added and deleted through a list held across the route's save reaching the
running route, a Save() with nothing put not writing back what the object was handed,
and a save whose list cannot be read again (the database fault injector failing the
DNS black lists' read alone) succeeding, reporting HM7390 and not in effect until the
next read. REST's blocked attachment and server message updates are held to it by
RestApiParityGaps.BlockedAttachmentsRoundTrip and
RestApiSettings.ServerMessagesRoundTripThroughCom, each reading a COM object before the
PUT and finding it unchanged after; the group update is proven by reading only, since
the REST groups route reads the groups again before every request and no object from
outside can hold a group across it.
Stress/SharedCollectionsOverCom.cs (REST port 10170, in the Windows suite) flips a
route, a DNS black list, a SURBL server, an incoming relay, a remote domain policy, a
group, a blocked attachment and a server message between two whole states over COM for
thirty seconds a race, pausing after each put, while the send route resolves past the
routes and the REST listings read every field - the listings are the only readers in
the two races without routes - and asserts every listing shows each item whole in one
state or the other, and the process, the crash oracle and the error log clean. Against
the old code, where each put wrote the running item, the pauses make a mix visible for
most of every write of the six multi-field items; the group and the message have one
field each and are survival tests only. Not run against the old code.
test/RegressionTests/ is NUnit driving a real running server over real sockets,
with live SpamAssassin and ClamAV, SPF, DKIM and DMARC evaluated against the suite's
own DNS zone (Shared/SuiteDns.cs — one fake resolver the server is pointed at for
the whole run), and real TLS handshakes. Nothing but DNS is mocked. Two things to
know before adding a test:
-
RegressionTests.csprojlists every source file explicitly — there is no glob. A test file nobody adds to it is not merely unrun, it is invisible, and a green suite says nothing about it.build/preflight-tests.ps1now fails on orphans; that check exists because a committed test file went uncompiled for months. -
A test that deliberately provokes a reported error must clear the ERROR log, via
CustomAsserts.AssertReportedError(...)which asserts and deletes.PerformBasicSetupcallsAssertNoReportedError, so a provoked error left behind fails whichever unrelated fixture runs next.
A fix does not ship without a test that fails against the build before it. A test that passes both ways proves nothing — build the pre-fix binary and check.
Setting the bench up is Regression Test Environment.
| Artifact | How |
|---|---|
| Server |
build/build.ps1 -Configuration Release (VS 2026, toolset v145, x64) |
| Server, assertion build |
build/build.ps1 -Configuration Release -Asserts — keeps HM_ASSERT, reports a violation as Critical HM6364 instead of compiling it out. Never the binary that ships |
| Admin tools |
build/build-tools.ps1 (source/Tools/hMailServer Tools.sln) |
| Control Panel |
source/Tools/ControlPanel.sln, published separately into its publish/ folder |
| Tests | build/build-tests.ps1 |
| Installer | ISCC on installation/hMailServer64.iss — never on the development machine |
| Fuzzers |
fuzz/build-fuzz.ps1 (clang-cl, a separate tree — see Fuzzing) |
.NET targets. Everything shipped is net10.0-windows, pinned via global.json.
The regression suite and its companions are .NET Framework 4.8.1 and stay there:
4.8.1 has no end-of-support date, and the suite drives the server over COM interop.
Dormant upstream dev tools under tools/ and the unused performance/stress projects
target 4.8.1 or 3.5 and are built by nothing — do not assume they compile.
The SDK is pinned in global.json. There is one build tree and the service holds the
output binary, so stop the service before linking — and never build while a
regression run is in progress.
| Change | Start at | Also touches |
|---|---|---|
| A new SMTP behaviour |
SMTP/SMTPConnection.cpp (reception) or SMTP/ExternalDelivery.cpp (sending) |
SendEHLOKeywords_ if it is advertised |
| A new IMAP command | a new IMAP/IMAPCommand*.{h,cpp} plus the dispatch table and IMAPCommandCapability.cpp
|
RegressionTests.csproj for the test file |
| A server-wide setting |
hm_settings, never a new [Settings] key: a PROPERTY_* name in Common/Application/Constants.h, its default row from Configuration::EnsureSettingDefaults_ or an upgrade script, and a Control Panel editor (CONTRIBUTING.md, "A new setting goes in the database"). The 241 keys already in Common/Application/IniFileSettings.{h,cpp} are frozen |
build/check-ini-coverage.py fails a new [Settings] key that is not in the baseline |
| A per-account or per-domain setting |
Common/BO/, Common/Persistence/, the COM interface, the schema, and the upgrade chain |
four SQL files, DBUpdater, a probe, Constants.h
|
| A new anti-spam test |
Common/AntiSpam/ and the score pipeline |
the per-account override path in LocalDelivery
|
| Something exposed to scripts |
Common/Scripting/ and the COM layer |
the seven places in the COM/IDL checklist |
| A new REST route or portal page |
Common/Util/RestApiServer.cpp (dispatch, authorisation through RoleManager - ACLManager decides folder access only - and the served openapi.json); the transport is Common/Util/HttpServer — new in 6.2.28 |
build/check-authz-choke-point.py keeps folder-access decisions in ACLManager
|
| A new periodic sweep | a ScheduledTask subclass registered in Application::CreateScheduledTasks_, startup-plus-periodic |
|
| A new Control Panel caption | the view, plus L("_Caption") marking |
check-mnemonics.py, check-localisation.py --write, and all 17 catalogues |
If a change spans more than about three of those rows, it is worth discussing in an issue before writing it.
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