-
Notifications
You must be signed in to change notification settings - Fork 3
Regression Test Environment
The regression suite (hmailserver/test/RegressionTests, 2,127 tests at 6.2.28)
does not mock the server. It runs against a real hMailServer service on the
same machine, over SMTP, IMAP and POP3, and through the COM API, and it
creates and deletes domains, accounts and mail as it goes. That makes it worth
far more than a unit suite, and it makes the machine it runs on part of the
test. This document is how to build that machine.
build/preflight-tests.ps1 is the machine-readable version of everything
below. Every check in it corresponds to a failure that has actually cost a run,
and when a step here is missing the pre-flight names it. Run it before every
run; Pre-flight passed - safe to run the suite is the state this document
gets you to.
Never run the suite against a production installation. It authenticates as
Administrator, binds the live ports, and wipes data.
| Piece | What it is for |
|---|---|
| The service, running from the repository's Release build | The code under test. Not an installed copy. |
| SQL Server Compact 4.0 and the bench database | The default backend; the suite upgrades it in place, never recreates it. |
| A short data folder | Long account names overrun MAX_PATH under a deep checkout. |
| The test domain and the four standard ports | What every fixture assumes exists at start. |
ClamAV (clamd on 3310) |
The live-scanner tests. |
SpamAssassin as a Windows service named SpamAssassinJAM
|
The SpamAssassin tests, including the one that stops and restarts it. |
| The NUnit console runner and the built test assembly |
build/build-tests.ps1 produces both. |
Everything is on one machine, on loopback, and the test process is outside the server — it talks to it the way a mail client does, plus one COM channel for configuration.
flowchart LR
subgraph runner["The test process - unelevated"]
NU["nunit3-console.exe<br/>RegressionTests.dll, x64 Debug"]
SIM["Client simulators:<br/>SmtpClientSimulator,<br/>IMAPClientSimulator,<br/>POP3ClientSimulator"]
FAKE["Fakes it hosts itself:<br/>FakeDnsServer, FakeHttpEndpoint,<br/>FakeHttpProxy, FakeSigstore,<br/>FakeFilterEngine, SpamdSimulator,<br/>SMTPServerSimulator, Pop3ServerSimulator"]
NU --- SIM
NU --- FAKE
end
subgraph svc["The hMailServer service - the code under test"]
EXE["hMailServer.exe<br/>from the x64 Release output in the<br/>checkout - never an installed copy"]
COM["COM: hMailServer.Application<br/>Administrator / testar"]
end
subgraph deps["Real dependencies, really running"]
CLAM["clamd.exe<br/>TCP 3310, at the hard-coded path"]
SA["spamd.exe<br/>service SpamAssassinJAM, TCP 783"]
CE[("SQL Server Compact 4.0<br/>the bench database -<br/>upgraded, never recreated")]
end
DATA["The data directory<br/>short on purpose: long account<br/>names overrun MAX_PATH"]
SIM -->|"SMTP 25, 587"| EXE
SIM -->|"POP3 110"| EXE
SIM -->|"IMAP 143"| EXE
NU -->|"configure, restart, assert"| COM
COM --- EXE
EXE --> CLAM
EXE --> SA
EXE --> CE
EXE --> DATA
EXE -->|"DNSServer = 127.0.0.1"| FAKE
| Piece | Where it listens | Set by | If it is missing |
|---|---|---|---|
| SMTP | 25 and the submission port |
hm_tcpipports, seeded |
Pre-flight check 11 fails; otherwise connection-refused failures that look like protocol bugs |
| POP3 | 110 | the same | as above |
| IMAP | 143 | the same | as above |
clamd |
127.0.0.1:3310 |
clamd.conf; the path C:\clamav\clamd.exe is hard-coded in CustomAsserts
|
The anti-virus fixtures fail open — the server gets no verdict, so nothing is detected and nothing says why |
spamd |
127.0.0.1:783 | the SpamAssassinJAM service wrapper |
The SpamAssassin fixtures report inconclusive; the one that stops and restarts the service cannot find it |
FakeDnsServer |
127.0.0.1:53, inside the test process |
Shared/SuiteDns.cs, a [SetUpFixture]
|
Every DNS-dependent test resolves live names again — the failure mode that made this suite-wide |
| SQL CE | no port; a file |
Database.CreateInternalDatabase(), once |
The service refuses its database and nothing in the failure says "database" |
Since 5 September 2026 the whole suite serves its own zone. SuiteDns binds a
FakeDnsServer on 127.0.0.1:53 before the first fixture, points DNSServer at it,
restarts the service once, and puts the setting back at the end. NODATA is the
correct answer for almost everything the suite asks — "no such record" is what an
SPF, DMARC or MX lookup for a test domain should see — and a fixture that needs a
positive answer adds it and forgets it again in teardown. Two names are seeded for
everyone: the SURBL project's permanent test point, and localhost.
The reason it is suite-wide rather than per-fixture is worth knowing, because it generalises. Before, fourteen fixtures each bound their own resolver, restarted the service twice, and every test outside those fourteen still resolved live names. Five consecutive full runs on 19 August 2026 each exposed a fresh set of live-DNS failures: the population of tests that resolve something is larger than any single run reveals, and pinning them one at a time costs thirty-five minutes per four.
A test that must serve DNS itself takes the port for the duration of Suspend(). A
test that points the server at some other resolver must restore SuiteDns.Resolver
afterwards — not null, which would silently put the rest of the run back on the
system resolver.
sequenceDiagram
autonumber
participant D as Developer
participant P as preflight-tests.ps1
participant R as nunit3-console
participant S as hMailServer service
D->>P: build/preflight-tests.ps1
P->>S: check PathName, state, COM auth,<br/>domain count, port count, schema version
P->>P: check ERROR log, clamd, SpamAssassinJAM,<br/>listeners, VPN adapters, test assembly, orphan .cs
P-->>D: "Pre-flight passed - safe to run the suite"
D->>R: build/run-tests.ps1
R->>S: SuiteDns: bind 127.0.0.1:53,<br/>set DNSServer, restart the service
loop every fixture
R->>S: PerformBasicSetup - AssertNoReportedError
R->>S: create domain, accounts, ports as needed
R->>S: drive SMTP / IMAP / POP3 / COM
R->>S: teardown - remove what it added,<br/>clear any error it provoked
end
R->>S: SuiteDns: restore DNSServer
R-->>D: results, written only at the end of the run
NUnit writes its failure details only at the end, so a run that looks quiet for fifty minutes is normal. Do not stop it: an aborted run leaves the state listed under "When a run is interrupted" below, and the next run then fails in ways that point somewhere else entirely.
- The service runs from the repository build
Build the server (build/build.ps1 -Configuration Release), then run
build/post-build.ps1 -Configuration Release. It needs elevation and will ask
for it. It copies the runtime DLLs beside hMailServer.exe, registers the COM
server and the Windows service, and points the service at
hmailserver\source\Server\hMailServer\x64\Release\hMailServer.exe in your
checkout.
The pre-flight's first check reads the service's PathName and refuses anything
else. The failure it guards against is a stray installer: running the installer
on the development machine re-points the service at C:\Program Files\..., and
from then on every "green" run tests the installed binary, not the one you just
built. If that has happened, post-build.ps1 again, then re-apply the
permission grant in step 7, then delete HKLM\SOFTWARE\hMailServer in both
registry views (reg delete "HKLM\SOFTWARE\hMailServer" /reg:32 /f and the
same with /reg:64) - a leftover InstallLocation value redirects the server's
INI lookup and it comes up with empty configuration while still reporting
"Running". build/make-hmailserver-writable.ps1 deals with the file
permissions the build needs on the output directory.
Provision the output directory as the installed layout expects
(IMPLEMENTATION-NOTES.md, "Test-environment recipe", has the full list):
hMailServer.ini with a [Directories] section and an empty
AdministratorPassword, the Logs, Temp and Events folders, a copy of
DBScripts\ with CRLF line endings, Languages\, dh2048.pem and tlds.txt
from installation\Extras, and a Bin\ folder holding 7za.exe and a second
copy of hMailServer.ini.
- SQL Server Compact and the bench database
Install the 4.0 x64 package from hmailserver\installation\SQLCE (it is the
version SQLCEConnection binds to; the x86 package is neither needed nor
installable on x64). Create the database once, through COM:
Database.CreateInternalDatabase() followed by Reinitialize() on an
hMailServer.Application object authenticated as Administrator.
From then on the database is upgraded, never recreated. REQUIRED_DB_VERSION
in Server/Common/Application/Constants.h moves whenever a release adds a
column, and a bench that nobody upgraded fails in a way that points somewhere
else entirely: the service refuses the database connection, the first pre-flight
check says "could not start", the ERROR-log check finds a stale error, and
neither says the word "database". The pre-flight's fourth check exists to say it.
Move the bench forward with build/upgrade-test-database.ps1 before
building a server whose required version has moved past it.
Because the bench is always upgraded, no test here takes the fresh-install path
that CreateTablesMSSQL.sql provides. build/check-db-scripts.ps1 covers that
separately by creating a throwaway database from the script; RELEASE.md makes
it a release step for exactly that reason.
- A short data folder
Point the data directory at something short, C:\HMTest\Data is the
convention the pre-flight knows. The suite creates accounts with deliberately
long names, and under a deeply nested checkout path the resulting file names
exceed MAX_PATH. The symptom is not a clear error but Windows error 206
cascading into hundreds of clean-log assertion failures in tests that never
mention paths.
- The test domain and the standard ports
The suite expects exactly one domain and exactly four TCP/IP ports at start (SMTP 25, POP3 110, IMAP 143 and the SMTP submission port). The pre-flight reads both counts from the running server. A port count of sixteen is a signature, not a mystery: the TLS fixtures register twelve extra ports and remove them in teardown, so a run killed while one of them was in flight leaves them behind. Remove the non-standard ones in the Control Panel, or re-add the four if there are fewer.
The suite authenticates over COM as Administrator with the password
testar, falling back to a blank password. Set it to testar. It is a bench
credential on a machine that must never hold real mail, which is why it appears
in the scripts in plain text and why it is not a secret.
- ClamAV
Install ClamAV (winget install Cisco.ClamAV works) and copy the installation
to C:\clamav - the suite's CustomAsserts hard-codes C:\clamav\clamd.exe.
A minimal clamd.conf is enough: TCPSocket 3310, TCPAddr 127.0.0.1,
DatabaseDirectory C:\clamav\database. Run freshclam once, then start
clamd.exe. It runs as a bare process, not a Windows service, so the pre-flight
checks for a listener on 3310 rather than for a service.
Warm-up matters: clamd accepts connections before its signatures have
finished loading, and hMailServer fails open when the scanner gives no verdict.
Before trusting the anti-virus fixtures on a freshly started clamd, send one
EICAR message and confirm "Virus detected" in the log.
- SpamAssassin
The JAM Software x64 build works
(https://downloads.jam-software.de/spamassassin/SpamAssassinForWindows-x64.zip,
rules bundled under share\). Extract it to C:\SpamAssassin and wrap
spamd.exe -i 127.0.0.1 -A 127.0.0.1 -p 783 as a Windows service named
SpamAssassinJAM - WinSW does this well, and the child process keeps the
name spamd, which both the process-gate check and the fixture that stops and
restarts the service rely on.
- Let the unelevated test runner control the services
One fixture stops and restarts SpamAssassinJAM; rebuilding the server stops
and starts hMailServer. Neither should need an elevated console. Grant
Authenticated Users start/stop on both services with sc sdset (the exact
descriptor is in IMPLEMENTATION-NOTES.md). The grant is silently dropped when
a service is re-registered, which is why "Access denied" starting the service
after a post-build.ps1 is the pre-flight's own diagnosis for that failure.
- Build the tests
build/build-tests.ps1 restores the NuGet packages into hmailserver\test\packages
- where the project's hint paths point, which a bare
dotnet restoreon the project does not do - and buildsRegressionTests.dllfor x64. Always x64: an AnyCPU build of the same project also runs, against stale assemblies, and passes tests it never actually executed.
RegressionTests.csproj is a legacy project that lists every source file
explicitly. A new test file that is not added to it is not merely unrun, it is
invisible, and a green suite says nothing about it. The pre-flight compares the
directory against the project and names any orphan.
- Run it
build/preflight-tests.ps1, then build/run-tests.ps1. The runner is
nunit3-console; subsets take -Where "class =~ /RegressionTests.IMAP.Binary/".
NUnit writes its failure details only at the end of the run, so a run that
looks quiet for fifty minutes is normal.
Do not dotnet test this project. It exits 0 having run nothing.
Never build, run static analysis, or start other heavy work while the suite is running. A rebuild restarts the service under the tests, the fixtures' restart detector then fails every test that follows, and the run can neither convict nor exonerate the code - thirty minutes are simply thrown away.
RegressionTests.csproj is net481 with a COMReference to the service and runs
nowhere but a Windows bench. hmailserver/test/LinuxRegressionTests is the part
of the suite that can test the Linux server: a net10.0 project that compiles
52 of the fixture files in place from RegressionTests - the ones that drive
the server through SMTP, IMAP, POP3 and MIME rather than through COM - together
with the socket simulators, and stands a REST-backed fixture layer under them
(LinuxRegressionTests/Shims). The fixtures are not copied and not edited; a
test that passes here is the same test that passes on Windows.
What the fixture layer does instead of COM: the test domain example.test is
deleted and recreated before every test through POST /api/v1/domains (or, on a
server built before that route existed, emptied of its accounts and lists);
accounts, aliases and distribution lists go through their routes; a server
setting a fixture writes goes through PUT /api/v1/settings or its anti-spam
and logging groups, and the suite's own defaults are put back before every test
the way the Windows PerformBasicSetup puts them back through COM; the
localhost SMTP routes go through /api/v1/routes; the TLS listeners the SSL
fixtures need are made from the certificate and port routes and brought up with
POST /api/v1/server/reinitialize; the delivery queue is drained through
/api/v1/queue; and the ERROR log is read through /api/v1/logs, judged by
what was written during the test rather than by whether the file exists.
Everything a test makes is removed in its teardown.
What it cannot do, it says: a fixture that needs the suite's fake DNS zone
served to the server's resolver, or a COM-only call such as
Utilities.EvaluateSieveScript, is skipped with that reason, from the
shim, never passed with a weakened assertion. A shim that needs a route also
asks the server's own OpenAPI document whether it has it, so an older server
skips with the route named rather than failing. dotnet test
reports those as skipped; a failure is the server's. The REST API also refuses
a credential's 201st request in any ten-second window, and the fixture layer
keeps under that on its own.
The server it runs against is any hMailServer with the REST listener on, on any
host; the defaults are the PostgreSQL-backed tree the CI job lays out
(.github/workflows/linux-build.yml, job regression-on-linux), which is also
the recipe for a hand-made one: DBScripts from the checkout beside the binary,
tlds.txt and dh2048.pem from installation/Extras, an INI with
Type=PostgreSQL and RestApiPort=8045, --create-database, the listeners
moved off the privileged ports with an UPDATE of hm_tcpipports, the
auto-ban switched off with an UPDATE of hm_settings
(AutoBanOnLogonFailureEnabled to 0 - the suite fails logons on purpose, and
three of them ban the test host for an hour; the Windows setup switches it off
over COM), and --set-admin-password from a pipe. The tests are told where it
is through the environment, read once when the assembly loads:
| Variable | Default |
|---|---|
HMTEST_HOST |
127.0.0.1 |
HMTEST_SMTP_PORT |
2525 |
HMTEST_POP3_PORT |
1110 |
HMTEST_IMAP_PORT |
1143 |
HMTEST_REST_PORT |
8045 |
HMTEST_ADMIN_PASSWORD |
testar |
Then, from any machine with the .NET 10 SDK that can reach those ports:
dotnet test hmailserver/test/LinuxRegressionTests -c Release --logger trx
Unlike the Windows project, this one is meant for dotnet test. Windows and
Linux hosts both work - a WSL server is reachable from Windows through mirrored
networking - and the CI job runs it on Linux against the binary the same
workflow just built. Shared/TestPorts.cs is what lets the simulators be
pointed elsewhere; the Windows suite never sets it and sees the standard ports.
A run that is stopped part-way leaves state behind, and the next run fails in
ways that point at the wrong thing. RELEASE.md step 4 says never abort a run for
this reason; when it happens anyway, build/preflight-tests.ps1 -Clean removes
what it can and names the rest:
- A stale ERROR log. One fixture writes a deliberate scanner error and removes it in teardown. Left behind, it fails every fixture's setup in the next run - the observed shape is 100% of tests failing before doing anything.
-
Test-only settings left in
hMailServer.ini. The suite points the resolver at its own fake DNS server on 127.0.0.1 for the whole run (Shared/SuiteDns.cs, a[SetUpFixture]: NODATA for every name a fixture has not added, the SURBL test point and localhost seeded) and putsDNSServerback when the run ends; fixtures that enable the quarantine, set a POP3 login delay, a password policy, PROXY-protocol trust, or an OTLP endpoint put their key back in teardown. Any of them surviving fails tests that never mention the setting: every DNS lookup waits for the query timeout; every anti-spam refusal becomes an acceptance; every connection from the trusted address is dropped before the banner. The server caches the INI at start, so after-Cleanrestart the service. - Orphan domain directories in the data folder. Several persistence fixtures rename the test domain, and the rename moves its directory. Killed mid-rename, the directory survives with no domain row, and every later run of that fixture fails with "already exists" about a directory it never named.
- The twelve extra TLS ports described in step 4.
Each of these has produced a red run that looked like a code regression.
-
Anti-virus products that proxy localhost mail traffic in-process. ESET,
for one, answers an IMAP literal continuation itself with
+ just send itand rewrites raw SMTP framing. A failure whose text contains that string is the proxy, not the server, and it returns after a reboot. Disable mail and SSL protocol filtering for localhost. - VPN clients that rewrite loopback. ProtonVPN and WireGuard adapters have broken address selection in fixtures that bind a specific local address. The pre-flight warns when one is up.
-
Line endings. The repository checks out CRLF on purpose (
.gitattributes): the database scripts are split on\r\n\r\n, the DKIM-signed.emlresources have body hashes that change with the line ending, and raw-message tests send file content verbatim and are refused for bare LF. A tool that "normalises" the tree to LF breaks all three at once. - Mark-of-the-Web. Files downloaded rather than cloned carry a zone identifier that the service refuses to load. Strip it from the whole tree.
- "Passes alone, fails in a group." This points at shared state between fixtures - an account, a setting, a port left behind by the fixture that ran before - and not at the network, however network-shaped the failure message is. Look at what the preceding fixture in the run order changes.
- A build number that names a tree that no longer exists. Not an environment trap exactly, but the same lesson: the suite's verdict is about the binary the service is running, which is why the pre-flight checks the path and why RELEASE.md voids a gate the moment anything changes after it.
hMailServer 6.3.2 · AGPL-3.0-or-later · Repository · Report a documentation error
Hmail Server — full index
Start here
1. Install and run
- Before You Install
- Installing hMailServer
- Installing on Linux
- Running in a Container
- The Control Panel
- Your First Domain and Mailbox
- Connecting a Mail Client
- DNS for Your Domain
2. Secure it
3. Operate it
- Monitoring and Health
- Backup and Restore
- Troubleshooting
- Diagnosing Stalled Mail
- Relocating an Installation
- Upgrading hMailServer
- Upgrading Guide
- Migrating the Database Backend
- High Availability Runbook
- Warm Standby
- Runbooks Digest
4. Extend it
- Rules and Sieve
- Aliases Lists and Public Folders
- Routes and Relays
- The COM API and Scripting
- The REST API
- 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