Skip to content

hMailServer 6.2.23 Alpha 1

Pre-release
Pre-release

Choose a tag to compare

@chrisholloway5 chrisholloway5 released this 21 Aug 19:29
· 263 commits to master since this release
Immutable release. Only release title and notes can be modified.

hMailServer 6.2.23 Alpha 1

Pre-release. Not for production. 83 commits, 345 files, roughly +27,000 / −4,100 lines since v6.2.22-pre6. The database schema moves from 6022 to 6025 in three steps — and if you are coming from 6.2.21, the last stable release, it moves from 6011 to 6025 in fourteen, picking up everything the 6.2.22 pre-release line introduced (app passwords, TOTP, the quarantine, password history, message trace, per-domain relay) on the way. One of the new steps rewrites a column on the largest table in the database.


What this release is

A large feature release cut as an alpha, staying in the 6.2 line — the alpha marker carries the caution, not the version number. It adds reverse-proxy client-address handling in front of SMTP, an external HTTP filtering hook, shared/delegated IMAP mailboxes, a full-text search index, RFC 3464 machine-readable bounces, RFC 3030 BINARYMIME, per-account spam thresholds, a sender blacklist, domain-wide out-of-office, distribution-list moderation, Windows Event Log integration, two more OTLP signals, IPv6 on the management listeners, a disk-space floor, and a substantial Control Panel overhaul. It also fixes one path that was silently losing mail.

What "alpha" means here, in plain terms

  • Do not put this on a production mail server. Seventy-two of the commits in this range landed on a single day, and the adversarial pre-release review of that work found six defects — one that filed delegated mail under the wrong mailbox and, on MOVE, destroyed the only readable copy; a COM interface layout that would have silently broken early-bound clients; a checked-in file that broke the build on any machine but the one it came from; and an unattended upgrade that would have hung forever at an invisible password dialog. All six are fixed and each fix carries a test that fails against the code before it — but a feature found to be broken hours before a cut has not had time to be wrong in a second way.
  • One feature is known to be untested on the backends it was built for. Statement timeouts were built for PostgreSQL and MySQL and have only been exercised against SQL Server Compact, the bench backend. This is written down in Roadmap.md rather than glossed.
  • If you install it, install it on a test system with mail you can afford to lose, and read Known limitations first.

The database upgrade is one-way

Upgrading.md states this as the one genuine sharp edge in the process, and it applies here with more force than usual because at least three schema steps land at once:

There is no downgrade path... The schema upgrade is one-way: an older server will refuse to run against a newer dbversion rather than misinterpret it, which is the correct behaviour but means "just reinstall the old version" does not work on its own. To roll back you need the pre-upgrade database backup.

So: take a database backup and a data-directory backup before you install, with your backend's own tools. Rollback is only as good as the snapshot you took first, and if you restore an older database against a newer data directory, any mail that arrived in between will exist on disk with no metadata row and will be invisible.


Breaking changes and upgrade notes

1. Schema 6022 → 6025, in three registered steps

REQUIRED_DB_VERSION is now 6025. The chain is 76 registered steps for MySQL/MariaDB and MS SQL Server (reaching back to schema 0), and 49 for PostgreSQL and the internal SQL Server Compact database (reaching back to 5001). Fresh installations are stamped 6025 by the CreateTables scripts. An installation on 6.2.21 (schema 6011) upgrades through every intermediate step automatically; DBUpdater resumes from whatever version the database reports, so a retry after a partial upgrade continues rather than restarting.

Step What it does
6022 → 6023 hm_messageindexterms (full-text posting list) and hm_messageindexstate (backfill cursor). Both empty until full-text search is enabled.
6023 → 6024 Six hm_domains columns for the domain-wide out-of-office reply; new hm_blocked_senders table.
6024 → 6025 hm_messages.messageflags widened tinyintsmallint; three hm_accounts columns for per-account spam settings; two hm_distributionlists columns for moderation and per-list bounce address.

2. The 6024 → 6025 step alters the largest table you have

ALTER TABLE hm_messages ALTER COLUMN messageflags smallint (MS SQL, SQL CE) and alter table hm_messages modify column messageflags smallint unsigned (MySQL/MariaDB) are table-rewriting operations on the message metadata table. On an installation with millions of message rows this can take a long time and will hold locks for the duration. Plan a maintenance window sized to your hm_messages row count, and do not interrupt it.

PostgreSQL is unaffected by this one — its messageflags was already smallint, so the 6024 → 6025 script for PostgreSQL adds columns only.

The widening exists because FlagSeen through FlagSpam had taken all eight bits a tinyint can hold, and RFC 3030 BINARYMIME needs a ninth.

3. Unattended upgrades used to be able to hang forever; from this release they cannot

If you script installs with /VERYSILENT, read this one. DBSetupQuick forwarded the administrator password to DBUpdater on the create path but never on the upgrade path, and DBUpdater authenticates before it can move the schema — so on any installation with a non-empty administrator password, a silent upgrade fell through to a modal password dialog nobody was there to answer, and the installer waited on it indefinitely. The defect was latent because DBUpdater exits before authenticating when the schema does not move; a release that moves the schema is exactly what arms it, and this is the first release since the fix's own introduction that moves it for every existing installation.

The password is now forwarded on both paths, and the shared authenticator fails instead of prompting under /silent, so the worst case is an exit code the installer reports out loud rather than a wait with no end. Pass the password with /adminpassword=... as before.

4. COM vtable layout repaired — recompile anything early-bound against a 6.2.22 pre-release

Five COM properties added during the 6.2.22 pre-release line and this branch were declared mid-interface instead of appended. A dual interface's vtable follows declaration order, not id(), so four interfaces — AntiSpam, Account, Application, GlobalObjects — had every slot beneath the insertion silently shifted while their IIDs stayed the same. Late-bound scripts (VBScript, PowerShell, PHP COM — the way virtually everyone drives hMailServer) were never affected, because dispatch by id() was always correct.

This release moves all five declarations to the end of their interfaces, restoring exact binary compatibility with v6.2.21, the last stable release. The cost lands in one narrow place: a client early-bound against a 6.2.22 pre-release interop assembly was compiled against the shifted layout and must be recompiled against this release's type library. Since 6.2.22 shipped only as pre-releases, this is the last cheap moment the repair will ever have; the alternative was carrying the silent 6.2.21 break forever.

5. New default behaviour: mail is refused when the disk gets low

MinimumFreeDiskSpaceMB defaults to 100. Below that floor on the volume holding the message store, new mail is refused with a temporary failure — 452 4.3.1 at MAIL FROM, NO [UNAVAILABLE] at IMAP APPEND — and the external POP3 fetcher pauses its whole sweep rather than downloading onto a full disk. DiskSpaceWarningThresholdMB defaults to 1024 and writes one application-log line when the volume crosses into the warning band.

Set MinimumFreeDiskSpaceMB=0 to switch the floor off entirely and keep the previous behaviour. If you deliberately run a server with under 100 MB free, you must set this or mail will start being deferred on upgrade.

6. New default behaviour: hMailServer writes to the Windows Application event log

WindowsEventLogEnabled defaults to 1, at WindowsEventLogLevel=2 (Critical + High). Errors are forwarded from ErrorManager — the one chokepoint every error passes through — to the Windows Application log under the source name hMailServer.

A healthy server writes zero events. Curated ids give the conditions operators alert on their own number: 2010 database unavailable, 2011 listener would not start, 2012 crash, 2013 backup failed, 2014 brute-force protection impaired, 2015 disk floor reached. Unrecognised codes fall into a severity catch-all (20002003). A throttle of five per id per ten minutes bounds a flood, and the first event after a throttled window says how many it swallowed. Event id 0 remains reserved for the pre-existing log-write-failure event.

Two operational notes. First, if you have log-collection tooling that alerts on anything new in the Application log, it will now see hMailServer; set WindowsEventLogEnabled=0 to keep the previous silence. Second, registering the event source is a privileged HKLM write no prior version made. The service performs it under LocalSystem, where it succeeds; a service reconfigured to run under a lesser account will fail the registration silently and events simply do not appear.

Message-file caveat, stated rather than discovered: hMailServer has no message DLL. The sink best-effort registers .NET's EventLogMessages.dll as its EventMessageFile. Registered, Event Viewer shows the text cleanly; unregistered, the entry carries the "description cannot be found" boilerplate with the full text underneath — ugly, but complete.

7. New default behaviour: database statements now time out on PostgreSQL and MySQL

DatabaseStatementTimeout defaults to 30 seconds. PostgreSQL and MySQL previously had no statement timeout at all, so a statement blocked on a lock held its pooled connection until the network gave up, and the pool is fixed size.

The precise reach of the setting is worth stating exactly, because it is uneven. MS SQL and SQL CE do not read it at connect time — they keep ADO's own 30-second default, which happens to match. But the script runner applies the configured value to any pooled connection a maintenance script has passed through, so a non-default value does take effect on those two backends after a DBUpdater step or a maintenance script has run on that connection. On PostgreSQL and MySQL it is applied at connect and governs everything.

Raising it is the safe direction. Lowering it below the time a legitimate maintenance statement needs — a domain removal, a backup sweep — makes that statement fail rather than merely be slow. 0 means no limit.

Untested on this bench for MySQL and PostgreSQL — the two backends it was built for. The MySQL half in particular is fiddly: the variable is max_statement_time in seconds on MariaDB 10.1.1+, max_execution_time in milliseconds on MySQL 5.7.8+, and max_statement_time in milliseconds on MySQL 5.7.4–5.7.7. The fork is read from VERSION() and the variable is proved to exist by setting it to 0 before anything relies on it; a failure there never fails the connection. But this is an alpha and this specific code has not been run against a real MySQL or PostgreSQL server.

8. New default behaviour: QRESYNC tombstones are pruned — with one bulk delete at first start

IMAPExpungeRetentionRecords defaults to 5000 per mailbox. hm_imapexpunged previously held one row per message ever expunged and nothing removed them except deleting the folder, so the table grew for the life of the installation.

Capping it is safe because of the RFC 7162 §3.2.6 fallback that had to be built first: a client asking about a mod-sequence older than the oldest record still held is told about every UID in the requested set the mailbox no longer holds, rather than about nothing. So the cost of a low value is a larger VANISHED response for a client that has been away a long time — never a client left believing a deleted message is still there. 0 keeps every record.

On a long-lived installation, the first prune is a bulk delete. A run-once pass fires at the first service start after the upgrade, alongside the recurring 12-hourly one — in the same window the schema move just used. If you want to separate the two, set IMAPExpungeRetentionRecords=0 before the first start and pick a number later.

9. Shared mailboxes become reachable wherever IMAP ACL is enabled — which is the default

enableimapacl ships as 1 on a fresh database and has for many versions. With it on, this release makes another account's folders reachable at #Users.owner@domain.folder when, and only when, the owner's folder carries an RFC 4314 ACL granting the caller rights.

The practical consequence for an existing installation: after upgrading, any account holder whose IMAP client speaks SETACL can grant another local account access to their own folders, with no administrator step. Nothing is shared until someone grants it, and there is no way to grant access to a folder you do not own.

There is deliberately no separate switch. Turning IMAP ACL enforcement off removes the #Users namespace entirely (NAMESPACE answers NIL, #Users... paths do not resolve) — but it also restores the historical meaning of "ACL disabled", which is that public folders are open to everyone. Those are the only two positions available; weigh them before flipping the switch as a way to disable delegation.

A small per-command cost lands on every installation: with the Other Users namespace enabled — which it is by default — every IMAP LIST now runs one additional uncached query to discover folders shared to the caller. On a default install with no shares that query returns nothing, but it runs.

10. Existing out-of-office replies change behaviour on upgrade

The new RFC 3834 suppression checks apply to auto-replies administrators already had configured, not only to the new domain-wide reply — the checks live at the top of the shared path every vacation message goes through. After the upgrade, an existing per-account vacation message no longer answers bounces, list traffic, or anything carrying Auto-Submitted, Precedence: bulk/list/junk, List-* headers, X-Auto-Response-Suppress, or a mailer-daemon/postmaster sender. There is no switch; the suppressed reply is recorded at LOG_DEBUG only. This is almost certainly the behaviour you always wanted — an auto-reply answering a mailing list is how mail loops start — but it is a change to a configured feature, so it is stated here rather than filed under new features.

11. Bounces change shape

Every non-delivery report is now multipart/report; report-type=delivery-status with the three parts RFC 3464 describes. The human-readable server message is unchanged and still first, so a mail client that has never heard of multipart/report shows what it always showed. If you have monitoring, log parsing or a mailing-list manager that matches on the whole bounce body rather than its first part, re-check those patterns.

12. PreferredHashAlgorithm below SHA256 is now refused

Of the setting's six legal values, 0 is no hashing, 1 is reversible and 2 is MD5 — and two of the five places that read it hand the value straight to Crypt as the scheme to store an account password or app password under. Anything below 3 (or above 5) is now refused at the point the setting is read, 4 (PBKDF2) is used instead, and the substitution is reported as HM5528 rather than silently applied.

The default is unchanged at 4, so this affects only installations that explicitly set 0, 1 or 2. Existing stored hashes are untouched; this governs only what a new secret is stored under.

13. New COM interfaces — re-registration is required

Schema 6024/6025 came with new IDL: two BlockedSender interfaces with their coclasses, BlockedSenders on the anti-spam interface, six Domain properties, three Account properties and two DistributionList properties. The installer runs hMailServer.exe /RegisterTypeLib and handles this. A hand-copied or developer install must re-register with elevation, because an out-of-process COM server marshals by IID and both sides read HKEY_CLASSES_ROOT\Interface under HKLM — a per-user registration is not sufficient, and the symptom is REGDB_E_IIDNOTREG rather than anything that names the cause.

14. Fourteen new settings are absent from README.md's settings reference

Nine new keys are INI-only — no Control Panel surface, no entry in the settings search index, no README entry: IndexerFullText and its three companions, MinimumFreeDiskSpaceMB, DiskSpaceWarningThresholdMB, DatabaseStatementTimeout, IMAPExpungeRetentionRecords and DmarcRptSchemaVersion. Five more — the FilterHook* keys — have Control Panel surfaces but no README entry either. The full table is in New settings below; for the nine, edit hMailServer.INI directly. (Documentation coverage is being reviewed separately; noted here so administrators can find the keys.)

15. If you are coming from 6.2.18 or earlier — the .NET runtime is now load-bearing on upgrade

The Control Panel and the database setup tools require the .NET 10 Desktop Runtime, which the installer bundles and installs when missing. The server itself is native code with no .NET dependency — but DBSetup, DBSetupQuick and DBUpdater are .NET applications, this release must move your schema, and the server refuses to start against an old schema. So on an upgrade, a failed runtime install does not leave you with "a working mail server minus the GUI"; it leaves you with no running mail server until the schema is moved. If the runtime install fails, install it by hand from https://dotnet.microsoft.com/download/dotnet/10.0 (Desktop Runtime, x64), then run DBUpdater.exe from the installation's Bin folder and start the service.


New features

Each entry says what it does and what it does not do.

Mail filtering and anti-spam

External HTTP filtering hook. FilterHookUrl names an engine — rspamd, most likely — and the message is POSTed to it, with the envelope, connecting address and HELO travelling as request headers in the names rspamd's own check endpoint already reads. The verdict arrives as a score, which lands alongside SPF, DKIM and DMARC so your existing thresholds decide what happens; "reject" is worth FilterHookRejectScore (default 100) rather than being the one verdict that ignores thresholds. Everything is bounded because it runs while a client waits for its answer to DATA: one deadline covers connect, write and read (FilterHookTimeoutSeconds, default 10), and a message above FilterHookMaxMessageSizeKB (default 10240) is passed without being sent at all. It does not speak HTTPS to the engine, so the engine belongs on this machine or a trusted network; and it cannot express a hard 5xx at DATA independent of thresholds, which a score-based framework does not have a way to say. FilterHookFailClosed is off by default: a filter outage that lets spam through is recoverable, one that defers every message while nobody is watching ends in bounces. Empty URL disables it, which is the default.

Sender/domain blacklist. BlockedSenders expresses "I never want mail from this address" through the existing spam-test pipeline, so thresholds, quarantine fallback, headers and logging all apply unchanged. An entry with an @ is an exact address; without one it is a domain and covers subdomains, anchored at a label boundary — example.com matches mail.example.com and never notexample.com or example.com.attacker.net. No wildcards (deliberately narrower than the white list: a deny list has to be predictable). Each entry carries its own score, defaulting to 100, which crosses the shipped delete threshold. The null sender is never blocked, so bounces stay deliverable. It matches the SMTP envelope sender, which the sender controls completely — it stops a correspondent who keeps using one address and stops nothing that rotates. It is not an anti-spoofing tool. Managed from a Blocked senders page beside the white list. It registers as spam check number one because it genuinely runs first — which means the existing checks each move down one position in the spam overview and in the Spam test: log lines, and every message now logs one more such line. Re-check log parsers that match on test position rather than name.

Per-account spam settings. An account can override the mark and delete thresholds, and can opt out of having its own copy marked. The opt-out strips spam classification from the account's copy at delivery — the SMTP conversation is unaffected: the message was already accepted or refused by the server-wide checks before per-account settings exist, so "opt out" cannot admit mail the server would have refused. -1 means no override on either threshold; a delete threshold of 0 additionally means "never delete". The overrides act only on a spam score this server itself recorded, which means the anti-spam "add reason to header" setting must be on — it is on by default. With no recorded score, neither override acts and the account keeps server-wide behaviour. This is not a hedge: inbound mail is not stripped of X-hMailServer-* fields, so with that setting off the header is whatever the sender put there, and a spammer attaching X-hMailServer-Reason-Score: 1 could otherwise steer a recipient's own override into un-marking their spam. The earlier "the flag proves the score reached the global mark threshold" fallback was withdrawn entirely, because that threshold is read at delivery while the message was judged at reception.

Distribution lists

Moderation. A list can name a ModeratorAddress. A sender the list's mode would refuse is no longer bounced — the posting is accepted and forwarded to the moderator, stamped with an X-hMailServer-Moderation header naming the list, and the moderator approves it by resending it to the list from an authenticated session. The authentication requirement is unconditional and does not follow the list's own RequireAuth, because MAIL FROM is free text. A forged unauthenticated approval does not merely fail: it lands, labelled, in front of the real moderator. Empty moderator address means moderation is off and behaviour is byte-for-byte what it was.

Per-list bounce address. BounceAddress is the envelope sender given to every copy the list sends. Empty keeps the previous behaviour, where distributed copies carried the original poster's envelope sender and a dead subscriber's bounces hammered whoever happened to post last. Setting it also makes receivers evaluate SPF against the list's domain rather than the author's.

Self-subscribe is not included — it needs a confirmation round-trip and a token store, and is tracked as its own item.

Out-of-office

A domain-wide reply, which answers for accounts that have no vacation message of their own. Optional separate text for senders whose address belongs to this server, and a VacationExternalOverride that sends the domain's generic text to outsiders even when the account has its own message. The account's own voice always wins over the domain's, and a suppressed account reply does not fall through to the domain. One stated exception to "one delivered message, one reply": an account with both a stored vacation message and a Sieve vacation action sends two replies, because the stored responder and the Sieve responder run at different points of delivery and do not consult each other — see Known limitations.

"Internal" means the envelope sender resolves to a local account or active alias. That is the sender naming a local mailbox, not the message having come from its owner, because an envelope sender is forgeable — the queued message carries no authentication state, so authentication cannot factor in without a schema change. Installations that require SMTP AUTH for local sender addresses close the hole at the door; elsewhere the internal text is a courtesy gradient and never access control. The classifier errs toward external.

Loop prevention was checked rather than assumed. Already correct: null return-path refusal, Auto-Submitted: auto-replied on the reply, the reply's own null envelope sender, and the once-per-sender rate limit. Added: incoming Auto-Submitted other than no, Precedence: bulk/list/junk, RFC 2369/2919 List-* headers, X-Auto-Response-Suppress, and mailer-daemon/postmaster senders — all checked before the rate-limit slot is spent, and applying to pre-existing per-account replies as well (upgrade note 10). There is no per-account internal/external split; that needs hm_accounts columns and is deferred.

IMAP

Shared and delegated mailboxes (RFC 2342 Other Users namespace). Another account's folders appear under #Users.owner@domain.folder when the owner's folder carries an ACL granting the caller rights. One access decision in one place: path resolution refuses to return a delegated folder without the lookup right, so SELECT, EXAMINE, STATUS, APPEND and the rest answer exactly "Folder could not be found" for a folder you may not know exists — the commands cannot be used to probe which accounts or mailboxes are real. Per-right enforcement (r read, i append, s \Seen, t/e deletion and expunge) comes through the existing permission checks. LIST enumerates only what you hold the l right on. Quota charges the destination folder's owner.

\Seen in a shared mailbox is shared state, not per-user. Flags live on the message row; per-user \Seen needs a per-account flag table joined on every FETCH/STORE/STATUS/SELECT — a schema change and a message-store rework, deliberately not smuggled in. The s right arbitrates: without it a delegate can neither STORE \Seen nor set it implicitly via FETCH BODY[], and SELECT's PERMANENTFLAGS advertises exactly what the delegate's rights allow. The owner cannot be locked out — their rights are implicit and therefore not removable state.

Full-text search index. SEARCH BODY/TEXT used to load and MIME-parse every message in the mailbox. A term index now answers the only thing a term index can soundly say about a substring search: this message cannot contain that text, skip reading it. Every message the index cannot vouch for is loaded and scanned by the same code as before, so results are identical with the index on and off — including a substring inside a word, which an index-only implementation silently gets wrong. Only the cost changes. Off by default, behind two settings that both ship off, and those defaults are load-bearing: IndexerFullText=1 in the INI turns the term index on, and the message indexer itself must also be enabled — that one has a Control Panel checkbox ("Enable message indexing") and is Settings.MessageIndexing over COM. IndexerFullText=1 alone indexes nothing. Enabling both costs a few kilobytes of terms per message and starts a backfill over the whole store, so it is an administrator's decision and never an upgrade's. The backfill is bounded and resumable, with a persisted high-water mark and an hourly reconciliation pass. Errors report as HM6260.

QRESYNC tombstone retention — see upgrade note 8.

SMTP

BINARYMIME (RFC 3030). BODY=BINARYMIME is advertised and accepted; DATA in such a transaction is refused 503 per the RFC, since binary content is not line-oriented and only BDAT may carry it. A payload containing bare CR, bare LF, NUL and all 256 octet values is delivered byte-for-byte — proven over a raw-socket IMAP BODY[] literal, because POP3 is line-oriented and cannot prove fidelity.

Relay of a binary message is refused, not converted. This server's delivery client speaks DATA only, so a binary message with a recipient requiring onward relay is refused at RCPT with the RFC's own 554 5.6.3 (conversion required but not supported), and a delivery-time backstop catches the indirect paths — a distribution list's external member, a forward, a rule — generating a DSN the sender can read plus an HM6340 for the administrator. Locally delivered binary mail, which is the actual use case, just works.

Two findings from building it: the bare-LF rejection fired on binary content, refusing the exact content the extension exists to carry, and now exempts negotiated-binary transactions only; and the binary mark is now persisted as flag bit 256, which is why messageflags had to be widened in schema 6025.

Reverse-proxy client addresses: PROXY protocol v1/v2 and XCLIENT. Put HAProxy, a TLS terminator or a Postfix relay in front of port 25 and every connection previously appeared to come from it — DNSBL, SPF, greylisting, auto-ban and the IP-range rules all quietly evaluated the wrong address, all still reported success, and the Received header recorded the lie. Both mechanisms now exist: PROXY protocol consumed before the TLS handshake and before the greeting, and Postfix XCLIENT answered with a fresh 220 so the upstream re-issues EHLO. The rewrite lands before anything reads the address, and the security range is re-read for the asserted client, so an auto-ban range still bites through the proxy.

The security rule is the feature. Both ship off, both trusted lists ship empty, and every trust decision is made against the real TCP peer — never against an address a header just supplied. A list entry that does not parse matches nothing rather than everything. XCLIENT is not advertised at all to an untrusted peer. A PROXY header from an untrusted peer drops the connection rather than being ignored.

Two consequences stated rather than hidden: trust is per-source, not per-port, so a listed proxy must send the header on every connection to this server; and the server cannot check that your upstream is configured to send one — HAProxy needs send-proxy, a Postfix relay needs XCLIENT enabled towards this host. Listing an address makes the header required from it, so a proxy that does not send one will have its connections dropped.

Machine-readable bounces (RFC 3464). See upgrade note 11. The distinctions are the point: a full mailbox is 5.2.2 and not 5.1.1, because the address is good and must not be struck off a list; a local database write failure is 4.3.0 and the disk floor is 4.3.1, both class 4, because "permanent" would be a false statement about a recipient whose mailbox was never the problem; a Sieve reject is 5.7.1; a remote refusal uses the remote's own enhanced code when it sent one. Diagnostic-Code and Remote-MTA are filled only from what a remote server actually said and the host that said it. Arrival-Date and Original-Recipient are omitted rather than invented — one is stored as a database timestamp with no converter, the other needs an ORCPT this server validates but does not retain.

Operations, monitoring and the API

Windows Event Log integration — see upgrade note 6. Deliberately absent, with reasons: service start/stop (the SCM already records every transition in the System log, and the interesting gap — service up, servers dead — is covered by the listener and database ids), database restored (no unconditional signal exists for it), certificate expiry (nothing currently detects approaching expiry as an event), and all protocol chatter (a mail server logging every session to the Application log gets itself uninstalled).

OTLP metrics and logs signals, each gated by its own endpoint URL and empty — therefore off — by default. The metrics are the same counters /metrics already serves under the same names, deliberately, because two independent tallies of one event drift and then two dashboards disagree with no way to tell which is lying. The push exporter carries the event counters only — nine gauges on /metrics are not exported: queue depth and age, the database probe, certificate expiry, work-queue depth and blocked tasks, connection-pool size, the up/down state, build_info and start_time_seconds. Most are computed by the metrics listener from database and file reads it owns; a few are in-process values that simply have not been wired into the push path yet. Either way the operational answer is the same: scrape /metrics for gauges; the OTLP signal is the event stream. Logs come from the single Logger chokepoint, with trace and span ids attached where a span was active, and the exporter's own thread is excluded from its own feed so a dead collector cannot feed itself.

W3C trace context ingestion. Inbound traceparent is read from HTTP headers and — the interesting half — from message headers on SMTP, where W3C context travels as a header field. Reception prepends its own the way Received is prepended, so the first occurrence is authoritative; the original is not deleted, because a sender's DKIM signature may cover it and prepending is signature-safe where deletion is not. Validation is treated as a security boundary: the version and format are exact, an all-zero id is rejected, tracestate is confined to printable ASCII so a CR/LF cannot ride into a message header, and a malformed value is never propagated. It never refuses anything — observability must not be able to reject mail — and the inbound sampled bit feeds no decision in either direction, so it can neither force expensive sampling on nor be used as an attacker's opt-out from the audit trail.

IPv6 for the REST, metrics and web-services listeners. All three previously built AF_INET sockets and parsed the bind address with inet_pton(AF_INET), so an IPv6 literal was rejected as invalid. All three now accept both families. A bind to :: is made dual-stack (IPV6_V6ONLY cleared), because each listener has exactly one bind-address setting and :: is the only way to serve both; every specific address binds exactly what it names. Loopback-keyed security checks follow: ::1 satisfies the REST API's TLS-mandatory-unless-loopback gate and the metrics credential gate, while :: is not loopback and still demands a credential. On dual-stack accepts a v4-mapped peer is unmapped before it reaches the auto-ban exclusion, AllowedFrom matching and the security ranges.

Five new REST routes plus a self-description. GET /api/v1/quarantine (newest thousand, through the same store call the administration surface uses), POST /api/v1/quarantine/{id}/release, DELETE /api/v1/quarantine/{id} — both mutating verbs 404 for an unknown id rather than reporting success for a typo — GET /api/v1/domains/{domain}/aliases, and GET /api/v1/srv, which emits RFC 6186/8314 client-discovery SRV records per active domain, built from the ports actually configured and enabled (no record for a disabled service, a loopback-bound port, or port 25 as submission). The SRV route generates records; it publishes nothing. Someone still has to put them in DNS — the route exists so what you publish matches what the server actually listens on. GET /api/v1/openapi.json describes every route from a document that lives beside the router, and its test carries its own list of every path and fails the moment an endpoint exists the document does not mention. For domain-restricted keys the quarantine takes the queue's answer — refused — because one entry names a sender and recipients in any number of domains and releasing one delivers mail; release and delete are declared mutating by kind, so a read-only key is refused however the request is spelled.

DMARCbis / RFC 9990 aggregate reports, behind a setting that defaults old. DmarcRptSchemaVersion=2 emits the RFC 9990 form: the dmarc-2.0 namespace, pct gone, np/testing/discovery_method added, the DKIM selector mandatory, and a pass disposition that finally separates a message that passed DMARC from one that failed under p=none. The RFC 7489 form stays the default and stays byte-for-byte what it was, because a report the receiving domain cannot parse is indistinguishable from no report, and essentially nothing in the field parses 9990 yet. An invalid value is refused at the INI read, reported as HM6210, and treated as 1. (The version element stays 1.0; the 2.0 is the namespace's.)

Privacy and data-subject tooling

Two COM methods, deliberately two operations, both server-admin only and both COM-only — there is no Control Panel surface for either.

Account.ExportMessages walks the folder tree and copies every message to .eml files mirroring the structure. It fails rather than reporting a partial export as success, because a partial export handed to a person exercising a data-access right is exactly the wrong artefact to produce. Export is all-or-nothing; there is no selective export.

Utilities.EraseAddressTraces removes what account deletion deliberately leaves behind: quarantine entries via the store so the file goes with the row, message-trace rows, greylisting triplets, distribution-list memberships, aliases naming the address as either half, and optionally the per-user archive tree. It takes an address, not an account, because the request usually arrives after the mailbox is already gone — and it is not part of deleting an account, because an archive can be under a legal hold that outranks a routine deletion. Sweeps continue past a failed store and report what survived (HM6250).

Documentation

docs/WarmStandby.md is new, with every claim verified against the code first. Three constraints decide its whole shape, and none is a preference: exactly one service may run against the database (both UnlockAll calls at startup release everything a live twin is holding mid-flight — a second server is actively destructive, not redundant, and delivers every in-flight message twice); DPAPI secrets do not travel (CRYPTPROTECT_LOCAL_MACHINE means route, fetch and per-domain-relay passwords fail to decrypt on the standby and must be re-entered after every failover, in both directions); and the message store is replicated rather than shared. The roadmap row stays underway rather than shipped, because nothing has rehearsed this on a real two-machine pair.

docs/DiagnosingStalledMail.md was corrected: it told administrators that the custom DNS-server setting was broken and unfixed, citing issue #25. That was true in 6.2.17 and 6.2.18 and has not been true since 6.2.19, so a document written to get somebody out of trouble was telling them to switch off a working feature.

Control Panel

Beyond the fixes listed below, the Control Panel got a substantial visual and accessibility pass: the type ramp re-based onto the Fluent 12/14/20 scale (214 C# literal sites and 69 XAML literals converted to tokens), Segoe UI Variable requested for the first time (no FontFamily had ever been set anywhere, so the whole app rendered classic Segoe UI), 168 Win32 MessageBox call sites replaced with a themed equivalent that exposes the same static overloads so each file moved by one using-alias line, eighteen dialogs re-based onto a common Fluent window so they stop opening with a stock Win32 title bar, sidebar group glyphs, and roughly 55 previously-anonymous form fields given accessible names so they no longer announce to a screen reader as "edit". New pages for Blocked senders, an Out of office tab on the domain dialog, a Windows Event Log card, and a PROXY protocol / XCLIENT card that leads with the trust list rather than the on/off switch.


Fixes

Only items with a reproduction or a negative-control test are called fixed. Everything else is under Hardening and diagnostics.

Data loss and correctness

Local delivery could lose a message silently. When the account-level copy could not be written, LocalDelivery reported HM5209 and returned — while Perform() deleted the recipient row regardless and DeliverMessage, seeing no error messages and nothing rescheduled, deleted the queued message. The mail was gone, the sender had been told 250, and the recipient never heard of it. The sender is now told, as the save failure below it already did. Evidence: identified by audit of the disk-full paths and corrected in code; the disk-full precondition around it has four regression tests, but this specific loss path does not have its own negative control. Treat it as the most important single change in this release and the one most worth exercising on your test system.

Two database backends could hang forever. DALConnection::SetTimeout was an empty {} on PostgreSQL and MySQL. Fixed by DatabaseStatementTimeout — see upgrade note 7, including its untested-backend caveat.

Unattended upgrades could hang forever at an invisible dialog. See upgrade note 3. Negative-controlled: a source-shape test asserts both halves — the password forwarding and the fail-instead-of-prompt guard — and fails against the code before either.

The white list cache never cached, and deleting an entry never invalidated it. WhiteListCache::Refresh() never cleared its own dirty flag, so every message re-read hm_whitelist from the database — exactly the per-message round trip the cache exists to avoid. That accidental reload was masking a second bug: deleting a white list entry never invalidated the cache while saving one did, and Whitelisting.TestEnabled only passed because of the masking. Both are fixed, and they had to be fixed together — either alone breaks the other.

Path traversal in the new archive eraser, and silent message loss in the new exporter. Found by an adversarial re-check of the privacy tooling after it was written and after the gate was green. An address of "..@.." resolved to <ArchiveDir>\..\.. and a recursive delete took two levels above the archive with it; any segment that is empty, all dots, or carrying a separator is now refused before a path is built (HM6251). Separately, SanitizeFolderNameForExport mapped every character Windows forbids to the same underscore, so sibling folders Team:A and Team|A both became Team_A — and since UIDs are unique per folder but repeat across folders, the second folder's 1.eml overwrote the first's while the returned count cheerfully counted both. Colliding siblings now get a (2) suffix.

Defects found and fixed inside this release's own new code

Listed separately because these never shipped — but they are the best available evidence of how new this code is.

Delegated APPEND, COPY and MOVE filed mail in the wrong account. Opening the #Users namespace corrected the read paths; the write paths were missed. IMAPCommandAppend stamped the row with the folder owner's account id and then built the file path from the caller's. CopyToIMAPFolder took one source account and used it for both ends. So a delegate's APPEND answered OK, the row landed in the owner's folder, and the bytes went under the delegate's mailbox — no read path could resolve them, the owner's included. MOVE was worse: the copy stranded, then the source was expunged, destroying the only readable copy. Fixed structurally rather than at the three call sites — CopyToIMAPFolder now derives both accounts from the objects it already has, so a mismatch is not representable — and quota now charges the destination owner. Negative-controlled: two tests fetch the body back as both the delegate and the owner and assert the placeholder is absent; against the pre-fix binary both fail, and the failure names the exact directory the server looked in. The green gate had not caught it because the only delegated-write test asserted A01 OK and a folder row count, both of which were true with the file in the wrong directory.

COM interface members were inserted rather than appended — see upgrade note 4. Verified by a declaration-order comparison against the v6.2.21 IDL: four interfaces shifted before the repair, zero after.

A delegate was reading their own directory for somebody else's mail. Seven call sites turned a Message into a file path using the logged-in account, which was correct for as long as the only folders a session could open were its own. FETCH (three sites), SEARCH, SORT and THREAD now all ask GetAccountOwningCurrentFolder, which answers nothing at all when the owner cannot be loaded, since falling back to the reader would build a path under the wrong mailbox. Caught by the shared-mailbox fixture on its first run.

RENAME, DELETE, SETACL, DELETEACL, GETACL and LISTRIGHTS were unsafe against delegated folders — a delegate holding the delete right could rename an owner's folder into their own tree; DELETE looked the parent up in the caller's tree and dereferenced the result unchecked (a null dereference for a delegated subfolder, a silently-pruned wrong tree for a root-level one); both INBOX guards compared path text against the word INBOX and walked straight past #Users.owner@domain.INBOX, so RENAME could rename an inbox away from the account every message is delivered into and DELETE would empty it of every message and subfolder; SETACL read through a deliberately unloaded collection and inserted a duplicate hm_acl row every time, for the same reason DELETEACL answered OK while deleting nothing; and LISTRIGHTS had no rights check at all. All closed before the grant surface was opened. Eight tests, including a blind probe: seven commands against a real folder, a missing folder and a missing account, asserting byte-identical answers each time, then proving the probes destroyed nothing.

A recursive folder walk that went 29 levels deep. IMAPFolders::Refresh loads the account's entire tree into whichever collection it is called on, so calling it inside the export recursion turned every subfolder into a second copy of the whole tree. Caught by its own test on the first run.

Generated #import output was committed, with one machine's absolute path inside it. The generated .tlh includes its own .tli by full path, so a developer's home directory shipped inside the AGPL source archive, and a build on any machine without that exact path failed with C1083. The three tracked copies are gone and *.tlh/*.tli are ignored, with a test pinning the ignore rules. This is a source-tree fix; no shipped binary was affected.

Protocol correctness

VANISHED (EARLIER) was sent after the FETCH responses, not before. RFC 7162 §3.2.6 makes the order a MUST and says why: the client shrinks its model by the vanished set first, so a FETCH arriving before it renumbers a mailbox the client still believes is larger. The new fixture builds a mailbox where both responses occur and asserts their order, which the old code fails by construction.

Remote-MTA was omitted from the delivery-status report for a session that plainly happened, when a route or MX target is configured as an IP literal and therefore has no hostname. SMTPClientConnection now falls back to the address the connection actually went to. Found by the new DSN fixture on the gate's first run.

One WriteReported return value in the accept path was discarded. The failure was already reported; what was missing was the consequence, which is what an administrator would actually be diagnosing — the message is delivered without its spam-score headers, so a rule matching those headers silently does not fire.

Control Panel

Several of these were verified by re-rendering the running window and reading the pixels or the visual tree, not by reasoning about the layout — which is how one wrong fix (raising MaxWidth when the star fraction was what bound) got caught instead of shipped. The rest were verified by test or by walking the visual tree. Three defects in the sidebar came from exactly such a screenshot pass — a search button reading "SearchCtrl+K" as one word, a group label ending mid-word with no ellipsis, and a pane too narrow for its own longest label — none of which could ever fail a build, a test or a review.

  • The sign-in screen could stop responding. Connecting is three blocking COM calls and all three ran on the UI thread behind a single 50 ms yield. An unreachable host now fails in about two seconds with a sentence that names the firewall case, via an off-thread reachability probe that gives every address the name resolves to a turn (a host reachable over IPv4 but not IPv6 was otherwise reported as down). Negative control: point it at TEST-NET-1, which swallows packets rather than refusing them; without the timeout it sits there for RPC's own patience. This narrows the window rather than closing it — see Known limitations.
  • Four of AccountDialog's eleven tabs could not be clicked. Its headers extend 1321 pixels inside a 372-pixel viewport, focusable with arrow keys, unreachable with a pointer, with nothing on screen to suggest anything had been cut off. The wheel now scrolls the strip horizontally, chevrons appear only while there is somewhere to go and disable at their own end, and keyboard selection scrolls the selected tab into view. Fixed in the TabControl template, so DomainDialog, RouteDialog and IPRangeDialog are covered too.
  • The anti-spam page had two tabs called "General" and two called "SpamAssassin", and the only route to the quarantine settings was to notice that the second identically named tab was not the same as the first. Tab() is get-or-create now, pinned by a source-scanning test that asserts the helper looks for an existing tab.
  • Both chart cards on the dashboard rendered pure white in the dark theme, along with their titles, buttons, legend and summary line — a whole row of controls nobody could see, on the page an administrator looks at first. A Fluent control fill is an overlay (#0FFFFFFF), and the code was discarding the alpha and keeping the RGB. The alpha is now composited over the theme's own background. Five tests pin the arithmetic with measured numbers, including a negative control and the light-theme mirror image.
  • Light-theme accent buttons rendered near-black on blue — the forum thread's one complaint about the look. An app-level implicit TextBlock style also reaches the TextBlocks that ContentPresenter generates inside control templates, and a style setter outranks property inheritance, so it overrode WPF-UI's on-accent foreground (and the tab and sidebar hover/selected swaps). The default now arrives through inheritance, which templates can override.
  • The Quarantine grid was missing two of its five columns (SizeToCells measures the cells, so on an empty grid Held and Score collapsed to zero width and their headers vanished with them), and the Quarantine page loaded once in its constructor and was cached forever, showing a stale list on the one page whose list is the only copy of what it shows.
  • Three pages drew tables out of padded spaces — message trace, quarantine, app passwords — with no headers, no sorting, no per-field copy, and a screen reader reading each row as one long line with the alignment spaces in it. All three are DataGrids now.
  • Destructive confirmations were painted as the safe choice. The crash dialog's Restart got the danger appearance while the quarantine delete — the only copy of a message — got the benign accent.
  • Breadcrumb group segments silently did nothing after group headers became icon-plus-text panels while the reveal still matched Header as string.
  • The checked-in generated settings index had not compiled since a label containing an escaped quote landed — the generator's naive [^"]* capture stopped early and emitted a string ending in a lone backslash.

Hardening and diagnostics

Described as hardening because they were not reproduced as failures or do not have a negative control.

  • Custom virus-scanner timeouts were reported as "Unable to launch executable". ProcessLauncher::Launch returned false for two opposite reasons — CreateProcess failed, or the process ran past ExternalProcessTimeout and was terminated — and both scanner call sites reported either as a path problem, sending an operator whose scanner was merely slow to check an executable path that was perfectly correct. Both paths now name the timeout as a timeout and quote the command line and the setting to change.
  • PreferredHashAlgorithm floor — see upgrade note 12. Its test asserts the part that would be easy to get wrong: not that the value is refused, but that a password set while it was configured still authenticates afterwards. An audit of the remaining credential stores — route relayer passwords, fetch-account passwords, SSL private-key passwords, per-domain relay password — found them all going through Crypt::ProtectSecret, with no Blowfish and no plaintext remaining.
  • CodeQL sweep of the Control Panel (Services and Views layers), every change behaviour-preserving, with the loops that genuinely could not be converted left alone and the reason recorded: running-max/min accumulators, stateful dedup loops, and a late-bound COM call whose exception is already caught.
  • Open security alerts triaged from 50 to 5, by reading each one. Three missed-where findings fixed; three shown to be unfixable because their conditions depend on state the loop mutates; 27 useless-upcast dismissed as false positives on dynamic COM operands — and after the same pattern re-fired on an edited file, excluded durably in the CodeQL configuration with the reasoning recorded (an implicit conversion from dynamic makes the enclosing expression dynamic, moving overload resolution to runtime); 16 findings in generated WPF obj/ output excluded by a config filter, verified working before the residue was dismissed; and the test Dockerfile pinned by digest with the honest other half written down — a pinned digest does not pick up base-image security updates. The five that remain are repository-settings findings (OpenSSF Scorecard), not code.
  • The Code quality page's 1,482 findings were resolved at the source. That page was fed by a GitHub-managed buildless scan enabled alongside the repository's own CodeQL workflow — and it ignored the repository's analysis configuration entirely, re-reporting without type information the exact rule categories this project had excluded with measured reasoning (412 findings against the documented COM-exception policy, 311 of a rule earlier verified false-positive here, ~97 flags on the WPF bool? IsChecked == true idiom that is not simplifiable). Nineteen of its findings were genuine enough to triage properly: six produced code fixes or verified behaviour-identical simplifications in the Control Panel, one produced a defensive guard on an async void handler, and the rest were confirmed false positives with the reasoning recorded. The managed scan was then switched off in favour of the configured analysis — which was itself extended to build and scan every shipped .NET tool (DBUpdater, DBSetup, DBSetupQuick, DataDirectorySynchronizer, ImportTool, Shared), because the triage revealed those had only ever been scanned by the scanner being switched off. A second real find from the same triage: the config's obj/ exclusion never worked for traced builds — CodeQL path filters only restrict extraction for interpreted languages — so generated XAML-compiler output is now dropped from the SARIF between the analyse and upload steps, which is the documented mechanism.
  • A permanently-red CI check removed. prisma.yml had failed on every push since the day it was added and could never have done anything else: it is GitHub's sample Prisma Cloud workflow, wanting three secrets this repository does not have, still carrying the template's literal my-asset-name, scanning for CloudFormation templates in a Windows C++ mail server. A check that is always red is worse than no check, because it trains everyone to ignore the colour.
  • Roadmap and capability-matrix corrections. Two read-only audits found roughly fifty stale or false entries — including four rows citing schema 6006 over 57 upgrade steps against a tree then at 6022 over 73, four rows that opened by declaring a shipped feature "Not implemented", and one row whose supporting citation said the opposite of the row. Upgrading.md's backend-reach table had drifted a fourth time and now states which steps ship without transcribing counts that go stale.

Known limitations

Named explicitly, in the project's own house style.

Trust and forgery

  1. MAIL FROM is not bound to the authenticated account anywhere in this fork. Authentication proves possession of valid credentials on this server, not ownership of the address in the envelope. This bounds distribution-list moderation directly: any valid credential holder on this server could forge a moderator approval. That is the trust model the existing require-auth machinery has always had; the moderation bypass extends it rather than weakening it, and a forged unauthenticated approval lands in front of the real moderator rather than going out to the list.
  2. The out-of-office "internal sender" classification is by envelope sender, which is forgeable. The queued message carries no authentication state, so authentication cannot factor in without a schema change. Installations that require SMTP AUTH for local sender addresses close the hole at the door; elsewhere the internal text is a courtesy gradient and never access control.
  3. The sender blacklist matches the envelope sender, which the sender controls completely. It is not an anti-spoofing tool and must not be sold as one.
  4. Per-account spam thresholds do not act without a score this server recorded, which requires the anti-spam "add reason to header" setting to be on. With it off, no override acts and the account keeps server-wide behaviour.

Out-of-office

  1. An account with both a stored vacation message and a Sieve vacation action sends two replies to one message. The stored responder and the Sieve responder run at different points of delivery and do not consult each other. Each is individually loop-safe; the pair is redundant. Use one mechanism per account until they are reconciled — reconciling them properly needs delivery-order rework and was deliberately not rushed into this release.

Byte fidelity

  1. Relay of a binary message is refused, never converted554 5.6.3 at RCPT, plus a delivery-time backstop DSN for the indirect paths. There is no client-side CHUNKING in this server's delivery client to convert to.
  2. Forwarding is not byte-safe. SMTPForwarding round-trips every forwarded copy through MIME parse and re-serialise rather than copying bytes. SpamAssassin rewrites and signature appending are likewise not byte-safe. All three fire only in those configurations; the default external-inbound-to-local-mailbox path is fully byte-preserving.

Shared mailboxes

  1. \Seen in a shared mailbox is shared state, not per-user. Marking a message seen marks it seen for the owner and every other delegate.
  2. There is no separate switch for the #Users namespace — it is the ACL enforcement switch, and turning that off restores "public folders are open to everyone".
  3. There is no Control Panel surface for ACL grants. Delegation is granted from an IMAP client that speaks SETACL.

Coverage and reach

  1. Fourteen new settings have no README entry, nine of them no GUI either — see upgrade note 14.
  2. The privacy tooling is COM-only and server-admin only; export is all-or-nothing.
  3. OTLP metrics carry the event counters only. Nine gauges — queue depth and age, database probe, certificate expiry, work-queue depth and blocked tasks, connection pool, state, build_info, start_time_seconds — are only available by scraping /metrics.
  4. The full-text index never answers a search, it only narrows the scan. Enabling it changes cost, never results. It requires both IndexerFullText=1 and message indexing to be enabled — the latter has a Control Panel checkbox.
  5. The filtering hook speaks plain HTTP only, so the engine belongs on this machine or a trusted network; and it cannot express a hard 5xx at DATA independent of your thresholds. Messages between FilterHookMaxMessageSizeKB (default 10 MB) and the server's own size cap pass unfiltered, traced only at debug level.
  6. Proxy trust is per-source, not per-port, so a listed proxy must send its header on every connection; and the server cannot verify that your upstream is configured to send one.
  7. The Windows Event Log has no message file of its own. Without the best-effort EventLogMessages.dll registration, Event Viewer shows "description cannot be found" boilerplate above the full text.
  8. The Control Panel sign-in freeze is narrowed, not closed. A host that accepts TCP on port 135 and then stalls inside DCOM will still freeze the UI. The correct fix is a long-lived STA thread owning the session with every COM call marshalled onto it — a real change to how the whole application talks to the server, not something to smuggle in next to a progress ring.
  9. DatabaseStatementTimeout is untested on MySQL and PostgreSQL, which are the two backends it was built for. On MS SQL and SQL CE it takes effect only via the script runner's connections, not at connect time.
  10. Warm standby has never been rehearsed on a real two-machine pair. docs/WarmStandby.md is verified against the code, not against a failover.
  11. Distribution-list self-subscribe is not included.
  12. AUTH=SCRAM-SHA-256 remains unreachable over IMAP on a stock installEnableImapSASLPlain ships as 0 and gates the whole IMAP AUTHENTICATE command rather than the PLAIN mechanism its name implies. SMTP and POP3 advertise and accept SCRAM-SHA-256 (and -PLUS under TLS) whenever authentication is on. Pre-existing; recorded because the IMAP half is exactly the "exists but inert by default" shape this project's capability audit exists to catch.

New settings

All in [Settings] in hMailServer.INI. Bold = reachable from the Control Panel; the rest are INI-only.

Key Default What it does
IndexerFullText 0 Full-text term index behind IMAP SEARCH BODY/TEXT. Also requires message indexing (Control Panel checkbox / Settings.MessageIndexing).
IndexerFullTextBatchSize 250 Message-id range width per backfill pass.
IndexerFullTextMinTokenLength 3 Shortest search run the index may answer for. Lowering it later stays sound without a re-index.
IndexerFullTextMaxTokensPerMessage 2048 Distinct terms per message before it is marked always-scanned.
MinimumFreeDiskSpaceMB 100 Free-space floor; below it new mail is refused temporarily. 0 disables.
DiskSpaceWarningThresholdMB 1024 One application-log line when the volume crosses into the warning band. 0 disables.
DatabaseStatementTimeout 30 Seconds a single statement may run. 0 = no limit. Applied at connect on PostgreSQL/MySQL; reaches MS SQL/SQL CE only via maintenance-script connections.
IMAPExpungeRetentionRecords 5000 QRESYNC tombstones kept per mailbox. 0 keeps every one — and defers the first-start bulk prune.
DmarcRptSchemaVersion 1 1 = RFC 7489; 2 = RFC 9990. Invalid values reported as HM6210 and treated as 1.
FilterHookUrl (empty) External filtering engine. Empty disables.
FilterHookTimeoutSeconds 10 Whole exchange: connect, write and read.
FilterHookFailClosed 0 Off = a filter outage lets mail through.
FilterHookRejectScore 100 What the engine's reject is worth, in the same units as every other spam test.
FilterHookMaxMessageSizeKB 10240 Messages above this are passed without being sent. 0 = no ceiling.
SMTPProxyProtocolEnabled 0 HAProxy PROXY protocol v1/v2 on the SMTP listener.
SMTPProxyProtocolTrustedIPs (empty) Comma-separated addresses/CIDRs, matched against the real TCP peer. Listing an address makes the header required from it.
SMTPXClientEnabled 0 Postfix XCLIENT. Not advertised to untrusted peers.
SMTPXClientTrustedIPs (empty) As above.
WindowsEventLogEnabled 1 Forward ErrorManager errors to the Windows Application log.
WindowsEventLogLevel 2 1 Critical, 2 +High, 3 +Medium, 4 +Low.
OtelMetricsEndpoint (empty) OTLP metrics. Empty disables.
OtelLogsEndpoint (empty) OTLP logs. Empty disables.
OtelMetricsInterval 60 Seconds between metric pushes; clamped 5–3600.

New error codes

Twenty new codes report through ErrorManager, which matters more than usual because the Windows Event Log sink is on by default and forwards Critical and High severities. All twenty, so nothing reaches your Event Viewer or application log without a published meaning:

Code Where Meaning
HM5528 settings load PreferredHashAlgorithm below SHA256 refused; PBKDF2 substituted.
HM5540 filter hook FilterHookUrl is not an http:// URL; nothing can be sent to the engine.
HM5541 filter hook Engine did not answer and FilterHookFailClosed is on; mail is being refused until it responds.
HM6210 settings load Invalid DmarcRptSchemaVersion; treated as 1.
HM6230 disk monitor Free space crossed the warning band or the floor. High severity — this one reaches the event log.
HM6250 privacy tooling Address-trace erasure finished incomplete; the report lists what survived.
HM6251 privacy tooling Unsafe path segment refused before any filesystem path was built.
HM6252 shared mailboxes Enumerating folder shares failed — shared folders will be missing from LIST, which otherwise looks exactly like a permissions mistake.
HM6260 full-text indexer Backfill or indexing error; the index degrades to scanning, never to wrong results.
HM6280 PROXY protocol Invalid PROXY header from a trusted source; the connection is closed, as the specification requires.
HM6281 PROXY/XCLIENT Malformed trusted-list entry. It fails closed — trusts nobody — which from the administrator's chair looks like the feature not working, so it is reported once rather than per connection.
HM6300 shared mailboxes Delegated RENAME failed partway; the next refresh reads the true tree back from the database. High severity.
HM6320 out-of-office Clearing a domain's reply-rate-limit slots failed when the domain reply was turned off. High severity.
HM6340 BINARYMIME Binary message refused onward relay; a DSN went to the sender, this code goes to you.
HM6350 per-account spam The delete-threshold override could not act; the message was kept, because deleting on a failure would be silent loss.
HM6351 per-account spam Spam mark removed but the header rewrite failed; behaviour is correct, the copy's headers are stale.
HM6360 list moderation Forwarding to the moderator failed; the posting was refused rather than distributed unapproved.
HM6361 list moderation The moderator address itself does not resolve — moderation cannot work and would otherwise look like it was working.
HM6362 list bounce address The configured BounceAddress is invalid; refused loudly, envelope left as it arrived, because the misconfiguration invites a mail loop.
HM6363 list moderation Nested-list moderation depth refused once, with the reason, instead of failing at every level on the way back up.

Downloads and verification

Every asset on this release is signed, and the SBOMs describe what is inside:

  • hMailServer-6.2.23-alpha1-x64.exe — the installer.
  • hmailserver.spdx.json and hmailserver.cyclonedx.json — SBOMs (Syft, with the native C++ dependencies merged in, which dotnet-only scanners miss).
  • One *.cosign.bundle per asset — keyless Sigstore signatures tied to this repository's GitHub Actions identity.

Verify a download with:

cosign verify-blob \
  --bundle hMailServer-6.2.23-alpha1-x64.exe.cosign.bundle \
  --certificate-identity-regexp '^https://github\.com/Progressiverobot/hmailserver/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  hMailServer-6.2.23-alpha1-x64.exe

A release with no bundles, or a bundle that fails verification, is not a release this project published.


Verification status

  • Regression suite: 1831/1831 on the exact release tree — every test, nothing skipped. Two full runs on 21 August: one on the stamped tree, then — because quality fixes landed after it and a run is void the moment anything changes — a second complete run on this exact tree, also 1831/1831.
  • Control Panel suite: 645/645 on the stamped tree.
  • Schema chain: check-schema-versions.ps1 (76 steps, chain 0 → 6025, 54 probes) and check-db-scripts.ps1 (a real throwaway SQL CE database built from CreateTablesMSSQL.sql, 274 statements) both pass at the release tree. The reviewed concern that hm_domains' declared row size exceeds SQL CE's documented 8,060-byte limit was tested rather than argued: a row with every variable-length column filled to its declared maximum — 9,260 in-row bytes — inserts successfully. SQL CE does not enforce the documented limit against declared sizes of variable-length columns.
  • COM compatibility: declaration order of every dual interface compared against the v6.2.21 IDL — zero shifted interfaces after the repair, four before.
  • Version stamp: Version.h 6.2.23 / build 32; installer and all seven tool projects stamped; the release grep for the previous version returns only historical records.
  • Not run: the installer has not been executed on the development machine (a standing project rule — CI's installer smoke test covers install on a clean runner). The end-to-end upgrade path across 6011 → 6025 on a populated production-scale database has not been exercised anywhere; that is part of what "alpha" means.

If something goes wrong

The server refuses to run against a schema version it does not match, in either direction, with one of two messages worth searching for:

  • The database is too old for this version of hMailServer. Please run hMailServer Database updater (DBUpdater.exe) to upgrade it. — run DBUpdater.exe from the installation's Bin folder. It picks up from whichever version the database actually reports, so a retry after a partial upgrade resumes rather than restarting, and the log names the step it stopped on.
  • The database is too new for this version of hMailServer. Please upgrade hMailServer. — you are running an older server against a newer schema. Install the matching server rather than trying to downgrade the database.

Note that the installer reports a failed schema upgrade in a dialog and then carries on to start the service and finish the wizard. If you script installs, treat that dialog's appearance — or an hm_dbversion still on the old value afterwards — as the failure. Do not treat "the installer finished" as success.