hMailServer by Progressive Robot
hMailServer 6.3 Documentation
The complete guide to running your own mail server on Windows or Linux — from first install to hardened production. Written for people who have never run a mail server before, and thorough enough for people who have.
Getting started
1. What hMailServer is, and what it is not 2. How email actually works 3. Before you install: the checklist 4. Installing hMailServer 5. The Control Panel: your first look 6. Your first domain and mailbox 7. Connecting a mail client 8. Making the internet trust you: DNSRunning it properly
9. Encryption and certificates 10. Stopping spam 11. Stopping viruses 12. Sorting mail automatically: rules and Sieve 13. Aliases, distribution lists and public folders 14. Routes and relays 15. Backup and restore 16. Monitoring and health 17. Security hardeningMaintenance
18. Upgrading hMailServer 19. TroubleshootingReference
20. Every Control Panel page 21. Settings reference (hMailServer.INI) 22. Ports reference 23. The COM API and scripting 24. The REST API 25. Version history 26. How mail flows through the server 27. Rules in practice: a cookbook 28. Event scripts: the complete reference 29. COM API reference I: server, domains and accounts 30. COM API reference II: messages, folders and rules 31. COM API reference III: security, filtering and operations 32. Error messages explained 33. Operations cookbook 34. Standards, protocols and interoperability 35. Running hMailServer on Linux 36. The Control Deck and the webmail portal 37. GlossaryPart IGetting started
1. What hMailServer is, and what it is not
hMailServer is a mail server — the software that owns an email domain. When someone
sends a message to [email protected], their mail server looks up where
yourcompany.com receives mail and delivers it to whatever is listening there. If you
run hMailServer, that is your machine.
It does three jobs:
| Job | Protocol | What it means |
|---|---|---|
| Accept mail from the world | SMTP | Other mail servers connect and hand you messages for your users |
| Let your users read mail | IMAP and POP3 | Outlook, Thunderbird, Apple Mail and phones connect to fetch messages |
| Let your users send mail | SMTP submission | Your users hand you messages and you deliver them onward |
What hMailServer is not:
- It is not a full groupware suite, though it now carries the parts of one that ride on open standards. There is a browser inbox now — the self-service
portal at
/portal, served by the REST listener out of the binary itself (chapter 36), and since 6.3.0 it is a real webmail: folders, search, compose, drafts, flags and inline images. Since 6.3.3 it keeps the account’s address book, served over CardDAV, and since 6.3.4 its calendars and tasks, served over CalDAV. Most users will still read their mail in Outlook, Thunderbird, Apple Mail or a phone, and a separate webmail package such as Roundcube pointed at hMailServer’s IMAP remains a perfectly reasonable choice. - It is not a spam filter you can point at someone else’s mail. It filters mail it receives, for domains it hosts.
- It is not Exchange. There is no Teams integration, no ActiveSync and no MAPI. The calendars 6.3.4 added, shared calendars and rooms among them, are served over CalDAV and in the webmail, not over Exchange’s protocols. It is email — done properly — and what a mail server needs beside it.
Who should run it: a small business that wants its own mail on its own server; a developer who needs a real SMTP/IMAP endpoint for testing; a hosting provider running mail for many domains; anyone who would rather not pay per mailbox forever.
Who should not: if nobody in your organisation is comfortable editing DNS records and watching a log file occasionally, a hosted mailbox provider will serve you better. Running a mail server is not hard, but it is not zero-maintenance either.
About this fork
Progressive Robot Ltd forked hMailServer in 2026 and maintains the 6 line. The original project, Martin Knafve’s, has resumed development, and its forum at hmailserver.com is online. This fork brings the 6 line up to current standards:
- On Windows, built with Visual Studio 2026, OpenSSL 4.0.3 (since 6.3.6; 4.0.2 up to 6.3.5) and Boost 1.92 — 64-bit only
- Runs on Linux since 6.3.0 — x86-64 and AArch64 from the same source tree (from 6.3.6 it is packaged for both again, as the releases up to 6.3.3 were; 6.3.4 and 6.3.5 were packaged for x86-64 only), built with clang or GCC, taking OpenSSL and Boost from the distribution rather than from those pins (chapter 35)
- TLS 1.2/1.3, modern password hashing (PBKDF2-HMAC-SHA256, optional Argon2id or scrypt)
- MTA-STS, DANE with DNSSEC validation, ARC, DMARC, Ed25519 DKIM
- Automatic Let’s Encrypt certificates built in — no manual renewal
- A modern .NET 10 administration application replacing the old WinForms tool
- A REST API that writes as well as reads, a browser Control Deck, a built-in webmail portal and Prometheus metrics (chapters 24 and 36)
- Automated regression tests on every release — at 6.3.8, 4,569 of them, run in the release tag’s own pipeline in two halves on the project’s two Windows benches: 4,489 passed, 0 failed, 80 skipped — plus the Linux suite on one of the project’s Linux runners: 2,749 tests, 2,374 passed, 0 failed, 375 skipped
Almost everything new is additive and off by default, and an existing hMailServer
installation upgrades without behaving differently until you turn something on. The standing exception is outbound transport security: honouring recipients’ MTA-STS policies and DANE records, with DNSSEC validation, is on from the first start (MtaStsEnabled, DaneEnforcementEnabled and DnssecValidationEnabled all default to 1), and each can be switched off. 6.3.4 is the
larger exception: twenty-four things change on the upgrade without a switch — the settings move into the
database, SPF follows RFC 7208, the audit trail and alerts are on — and §18.5 lists the ones an
administrator meets first. A new installation of 6.3.4, unlike an upgrade, also starts secure: a self-signed certificate, STARTTLS offered on ports 25, 587, 110 and 143, TLS required before a password is taken from the Internet IP range (mail from other servers on port 25 is still taken with or without TLS), and on Windows the service running as NT SERVICE\hMailServer rather than LocalSystem unless the installer is told otherwise.
2. How email actually works
Skip this chapter if you already know it. If you do not, the next eight chapters will make far more sense with it.
Delivering a message
You send mail to [email protected]. Here is what happens:
Your mail client Your server Example.com's server
─────────────── ─────────── ────────────────────
│ │ │
│──── SMTP submission ─────────▶│ │
│ (port 587, you log in) │ │
│ │ │
│ DNS: "who receives │
│ mail for example.com?" │
│ Answer: the MX record │
│ │ │
│ │──── SMTP delivery ─────────▶│
│ │ (port 25, no login) │
│ │ │
│ │ stored in Alice's
│ │ mailbox
│ │ │
│ │ │◀── IMAP ─── Alice
Two facts do most of the explaining:
- Port 25 has no password. Any server on the internet may connect to your port 25 and offer you mail for your own domains. That is how email works — it has to be open, or nobody could write to you. This is also why spam exists, and why chapters 8, 10 and 17 matter.
- Your users use a different door. Port 587 (submission) requires a login. Never
let an unauthenticated stranger send mail through you to the outside world — that is
an open relay, and it will get your server blacklisted within hours. hMailServer
refuses this by default. On a new installation of 6.3.4 (not an upgrade) the login must also
wait for TLS: from another machine,
AUTHis not offered beforeSTARTTLSand is refused with530if it is sent, so set mail clients to STARTTLS (chapter 7). Mail from other servers on port 25 is taken with or without TLS, as before.
The three things that make mail arrive
A message you send is judged before it is delivered. Three DNS records decide whether it lands in the inbox or the spam folder — or is rejected outright:
- SPF — a list of the servers allowed to send mail for your domain.
- DKIM — a cryptographic signature proving the message really came from you and was not altered.
- DMARC — a policy saying what to do with a message that passes neither SPF nor DKIM for the domain in its From address, and where to send reports.
Chapter 8 sets all three up. Without them your mail will go to spam. This is the single most common reason a new mail server “doesn’t work”.
More: How email works, and what hMailServer is and is not on the forum.
3. Before you install: the checklist
Work through this before you download anything. Skipping it is the usual cause of a painful first day.
3.1 A machine to run it on
- Windows: Windows 10/11 (Windows 10 needs version 1607 or later), or
Windows Server 2016 or newer. 64-bit (x64) only: setup refuses to start on anything
older or on 32-bit Windows. For Windows on ARM, 6.3.8 publishes an installer of its own as a preview: it has
never run on ARM64 hardware, and SQL Server Compact has no ARM64 build, so a new installation there is set up
against SQL Server or PostgreSQL (§4.1). Since 6.3.4 a new
installation runs the service as the virtual account
NT SERVICE\hMailServer, which may write only in the program folder’s ownData,Logs,TempandDatabasefolders and in the folders you approve for it when the installer asks (or later, when Grant access on Maintenance → Server limits & expert settings, card Windows service account, asks). Any other folder on this machine that the service writes, a backup folder above all, or a data or log folder kept somewhere else, has to be a folder of its own on a fixed local drive (not a drive’s root), outside Program Files, the Windows folder and the user profiles folder: a folder anywhere else in the program folder is never granted, approved or not. If SQL Server on the same machine is to be reached with Windows authentication, giveNT SERVICE\hMailServera login there first (§17.3). - Linux: since 6.3.0, x86-64 or AArch64 with systemd — from the
.deb, the.rpm, the PKGBUILD or the AppImage. 64-bit only. 6.3.8 is published for both architectures: a.debor an.rpmof each Debian 12 and 13, Ubuntu 24.04, Fedora 44 and 43, Enterprise Linux 10 and 9, openSUSE Leap 16.0 and Tumbleweed release’s own, the generic.deband.rpm(the.debis Ubuntu 26.04’s build), and the two AppImages; the AArch64 files are cross-compiled and tested under emulation, since no AArch64 machine has run them. Install your release’s own package (§35.2). Chapter 35 covers it end to end, including what a Linux server does not have: no Control Panel, no COM, event scripts in JavaScript only (since 6.3.4; none before). Per-domain DKIM signing is set over the REST API or on the Control Deck (§24.15). - Minimum 2 GB RAM and 20 GB free disk. Mail accumulates; size the disk for how much mail you will keep, not how much you have today.
- The machine must be on all the time. A mail server that is asleep is a mail server that is losing mail.
3.2 A domain name
You need a domain you control — yourcompany.com — and the ability to edit its DNS
records. If your domain is with a registrar like Namecheap, Cloudflare, GoDaddy or
123-Reg, you have this.
3.3 A static public IP address — and port 25 open
This is the step that stops most home installations.
- Your server needs a static public IP. A dynamic home IP will work badly and be widely blacklisted.
- Port 25 inbound must reach your server. Most consumer ISPs block it. Business connections and cloud/VPS providers usually allow it, sometimes on request.
- Port 25 outbound must work too. Many cloud providers (notably Azure and AWS) block outbound 25 by default and require you to ask.
Check before you commit. If your ISP blocks port 25, you cannot receive mail directly. You can still run hMailServer and relay outbound mail through a smart host (chapter 14), but inbound mail will not reach you.
3.4 Reverse DNS (PTR)
Your IP address must resolve backwards to your mail server’s name. If mail.example.com
is 203.0.113.10, then 203.0.113.10 must resolve back to mail.example.com.
Only your ISP or hosting provider can set this — it is not in your domain’s DNS. Ask them. Many large providers (Google, Microsoft) reject mail from IPs with no matching PTR record, so this is not optional.
3.5 A database
hMailServer stores accounts, domains, message metadata and settings in a database; since 6.3.4
that includes the [Settings] keys, of which hMailServer.INI keeps only a copy.
The messages themselves are files on disk. You have four choices:
| Database | Good for | Notes |
|---|---|---|
| Built-in (SQL Server Compact) | Getting started; small installations | Installed automatically, nothing to configure. The default. |
| MySQL / MariaDB | Most production use | Client is bundled on Windows — works with MySQL 8 and MariaDB out of the box. Since 6.3.4 every table is utf8mb4, so the database server must be MySQL 5.7.9 or MariaDB 10.2.2 or later |
| Microsoft SQL Server | Windows shops that already run it | Including Express edition |
| PostgreSQL | Larger installations | libpq bundled |
On Windows, if in doubt, take the built-in database. You can migrate later, and for anything up to a few dozen mailboxes it is entirely adequate.
On Linux the choice is narrower — two of the four. PostgreSQL is reached through
libpq, which the package depends on. MySQL/MariaDB is reached through
libmariadb.so.3, loaded at run time, so the client package has to be installed
separately: libmariadb3 on Debian and Ubuntu, mariadb-connector-c on Fedora and
RHEL, mariadb-libs on Arch. Microsoft SQL Server and the built-in SQL Server Compact
database are refused by name, with an explanation in the error log, because both are
reached through Windows-only interfaces. See chapter 35.
More: Choosing a database, creating it, encrypting the link and leaving the built-in one on the forum.
3.6 Decide your host name
Pick the name your mail server will be known by — conventionally mail.yourcompany.com.
Write it down. It is used in your MX record, your PTR record, your TLS certificate and
your server’s SMTP greeting, and they should all agree.
Pre-flight checklist
- Windows machine (x64, build 14393 or newer) or Linux machine (x86-64 or AArch64), always on
- Domain name with DNS access
- Static public IP
- Port 25 open inbound and outbound
- PTR record requested from your provider
- Host name chosen (
mail.yourcompany.com) - Administrator access to the machine — an administrator account on Windows, root or sudo on Linux
- Database chosen (§3.5): on Linux, PostgreSQL or MySQL/MariaDB; a MySQL server must be 5.7.9 or later, a MariaDB server 10.2.2 or later
- On Windows, the service account planned for: a new installation runs as
NT SERVICE\hMailServer, so any folder it must write other than the program folder’s ownData,Logs,TempandDatabase(a backup folder above all), or SQL Server on the same machine reached with Windows authentication, is arranged first (§3.1) - Somewhere to keep the exported stored-secrets key (since 6.3.4), with the database backups but stored apart from them: without it, a database restored on another machine opens none of the second factors and secret settings the key seals
4. Installing hMailServer
4.1 Download
Get the Windows installer for the current release, hMailServer-6.3.8-x64.exe, at
updates.progressiverobot.com/download/hMailServer-x64.exe.
That link always leads to the installer of the newest stable release, never a pre-release’s, and
updates.progressiverobot.com/download/ leads to
that release’s page, with its notes and every file. For Windows on ARM,
hMailServer-6.3.8-arm64.exe is at
updates.progressiverobot.com/download/hMailServer-arm64.exe:
a preview, cross-compiled on the project’s x64 runner, Authenticode-signed like the x64 one, and never
run on ARM64 hardware. SQL Server Compact has no ARM64 build, so a new installation there is set up against SQL
Server or PostgreSQL, and a silent one is given its database on the command line. If you install it on an ARM64
machine, the project would like to hear how it went, in
an issue. Every release is on
the
project’s GitLab releases page.
Each release also carries SBOM files (hmailserver.spdx.json,
hmailserver.cyclonedx.json) — the source tree’s inventory of its components, made by Syft with the native libraries it cannot see added, for
anyone who needs to audit their supply chain. From 6.3.4 every installer and package has one of
its own, beside it and named after it (hMailServer-6.3.8-x64.exe.cdx.json, CycloneDX
1.6), whose subject is that file by its SHA-256 and SHA-512: the installer’s lists OpenSSL, Boost,
libpq and zlib at the versions the server is built with and every third-party file the installer packs.
You do not need them to install.
The Linux packages are on the same release. For 6.3.8 that is x86-64 and AArch64: the
generic hmailserver_6.3.8_amd64.deb, hmailserver_6.3.8_arm64.deb,
hmailserver-6.3.8-1.x86_64.rpm and hmailserver-6.3.8-1.aarch64.rpm, the names an installed
server’s update check asks for; each Debian 12 and 13, Ubuntu 24.04, Fedora 44 and 43, Enterprise Linux 10 and 9,
openSUSE Leap 16.0 and Tumbleweed release’s own package for both architectures (§35.2); the two AppImages, with
the source of the libraries they carry; the source tarball, hmailserver-6.3.8.tar.gz; and the Helm chart.
Each compiled file and the tarball has its SBOM, hmailserver-linux-6.3.8-SHA256SUMS.txt gives the SHA-256
of every Linux file, every file of the release has its Sigstore bundle beside it, named after it with
.cosign.bundle, and hmailserver-6.3.8.intoto.sigstore.json, the release’s SLSA build
provenance, names every file by its SHA-256, and hmailserver-6.3.8.intoto.jsonl is the same provenance as one line of JSON. The AArch64 files are cross-compiled on the project’s x86-64
runner and tested under emulation; no AArch64 machine has run them. From 6.3.6 the release’s pipeline also makes a
container image, registry.gitlab.com/hmailserver/hmailserver, for linux/amd64 and linux/arm64, built from
the release’s .debs and signed with cosign as that pipeline; it is pushed by a job started by hand once
the release is published, and until then the registry holds no image of that release. Installing and first-run setup on Linux are chapter 35; the rest of this
chapter is the Windows installer.
The Windows installer is Authenticode-signed from 6.3.1. It is signed with Azure Artifact Signing, against a certificate profile issued to Progressive Robot Ltd after Microsoft’s identity validation, and countersigned to RFC 3161. The elevation prompt names the publisher instead of reading Unknown publisher, and an enterprise policy that refuses unsigned binaries no longer refuses this one.
SmartScreen still warns. Microsoft puts a signed installer in the same row as an unsigned one, flagged unrecognised until reputation accumulates, and reputation attaches to a file that does not change — which a new 80 MB installer every few weeks never is. An EV certificate would not help; Microsoft removed EV’s SmartScreen bypass in 2024. Expect the same Windows protected your PC screen, now carrying the real publisher name. Releases before 6.3.1 are unsigned and always will be: a published release is immutable, so 6.3.0 could not be signed after the fact.
Only the Windows installer carries Authenticode — there is no such thing for a
.deb, an .rpm or an AppImage. From 6.3.5 every file of a release is signed with
Sigstore by the project’s GitLab CI pipeline for the release’s tag, and checked with the
server’s own verifier before the release is published: each file has <file>.cosign.bundle
beside it. 6.3.8’s name the identity https://gitlab.com/hmailserver/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.8,
the project’s path in the group hmailserver, with the project’s id, 86730559, in the
certificate, and the issuer https://gitlab.com. With cosign, and the file and its bundle beside you, this must
succeed:
cosign verify-blob --bundle hMailServer-6.3.8-x64.exe.cosign.bundle \
--certificate-identity 'https://gitlab.com/hmailserver/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.8' \
--certificate-oidc-issuer https://gitlab.com hMailServer-6.3.8-x64.exe
On Linux, a server from 6.3.4 on makes the same check its live update makes:
hmailserver --verify-update <file>, with the bundle beside the file; read the identity it
prints, which must end in @refs/tags/v6.3.8. sha256sum -c hmailserver-linux-6.3.8-SHA256SUMS.txt --ignore-missing
checks the Linux files, and Get-AuthenticodeSignature .\hMailServer-6.3.8-x64.exe, or the
-arm64.exe, should answer Valid, signed by CN=Progressive Robot Ltd. The release’s
SLSA provenance is checked with cosign verify-blob-attestation against the same identity (the
security policy’s Build provenance). A server from 6.3.4 on trusts the pipeline’s Sigstore identity
above, with the project’s id, so its live update can install 6.3.8 (§18.2); a server from 6.2.28 to 6.3.3 trusts
only the GitHub release workflow and is upgraded by hand. 6.3.5 was signed on 29 September 2026, before the project
moved to
gitlab.com/hmailserver/hmailserver, where the old addresses redirect, so its bundles name
the project’s former path, https://gitlab.com/Progressiverobot/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.5,
with the same issuer; a server from 6.3.4 on trusts both paths. 6.3.4 itself carries no Sigstore bundle: it was built and
Authenticode-signed by hand, after GitHub suspended the project’s account on 18 September 2026 and
before the GitLab pipeline existed, so no server’s live update installs 6.3.4, and it is installed by
hand: on Windows by running the installer (chapter 18), on Linux by installing the package (chapter 35).
6.3.4’s own check is
hmailserver-6.3.4-SHA256SUMS.txt, which names every file of the release but itself and its
signature with its SHA-256, and hmailserver-6.3.4-SHA256SUMS.txt.sig, an SSH signature of that
list made with the key that signs the release tags, the one key in the repository’s
.gitlab/allowed_signers. With OpenSSH 8.1 or later, in a clone of the repository and with the
downloads beside you:
ssh-keygen -Y verify -f .gitlab/allowed_signers -I [email protected] -n file -s hmailserver-6.3.4-SHA256SUMS.txt.sig < hmailserver-6.3.4-SHA256SUMS.txt
sha256sum -c hmailserver-6.3.4-SHA256SUMS.txt
The first must say Good "file" signature for [email protected], and the
second OK for every file (--ignore-missing when you downloaded only some). The
installer’s Authenticode signature is checked as before:
Get-AuthenticodeSignature .\hMailServer-6.3.4-x64.exe # Status Valid, signed by CN=Progressive Robot Ltd
The project’s security policy, Verifying a release, has these commands, and under Software bills of materials how to check that an SBOM describes your file.
For 6.3.3 and the releases before it back to 6.2.19, Sigstore is the check that
means something for every asset on the release: each carries a .cosign.bundle
beside it, signed by the GitHub release workflow of the time.
cosign verify-blob --bundle hMailServer-6.3.3-x64.exe.cosign.bundle \
--certificate-identity-regexp '^https://github\.com/Progressiverobot/hmailserver/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
hMailServer-6.3.3-x64.exe
Two SHA-256 values, deliberately. From 6.3.1 the release notes give the Windows installer’s hash twice — as built, and as published. The signing job replaces the installer asset with the signed one before cosign runs, so the bytes on the release are not the bytes the build produced. The published value is the one to check a download against, and it is those same published bytes the cosign bundle above is signed over. 6.3.1 is the first release for which the two differ. 6.3.4, signed by hand with no signing job and no cosign bundle, is checked against
hmailserver-6.3.4-SHA256SUMS.txtinstead (above). From 6.3.5 the notes give the installer’s published SHA-256 only, the value its bundle is signed over.
4.2 Run the installer
Right-click the installer and choose Run as administrator.
Work through the wizard — there is no welcome page; it opens straight on the licence:
1. Licence agreement. hMailServer is AGPL-3.0-or-later. In short: you may use it freely, including commercially; if you modify it and offer it to others over a network, you must publish your changes.

2. Destination folder. The default C:\Program Files\hMailServer is fine.

3. Components. This page matters, so read it:

| Component | Take it? | What it does |
|---|---|---|
| Server | Yes | The mail server itself. Everything else is optional. |
| Control Panel | Yes | The administration application. Without it you have no GUI. |
| Remote administration support | Only if scripting from another machine | Registers the hMailServer COM type library on a machine that is not the server, so that scripts and other COM clients there can administer a remote hMailServer. The Control Panel does not need it — it connects to remote servers by itself. |
Note for upgraders: “Administrative tools” in older versions bundled the classic Administrator and a PHP web interface. Both are gone (see chapter 25). The component now does only what it always really did — register the COM type library for scripts.
4. Database type. Choose Use the built-in database engine (SQL Server
Compact) unless you decided otherwise in §3.5. An upgrade never shows this page: the database type
already in hMailServer.INI is kept.

5. Start Menu folder. Where the shortcuts go. The default is fine.

6. Administrator password. hMailServer asks you to set a password for the
administration interface. This is not an email password — it is the master password
for the whole server: at least five characters, entered twice. Since 6.3.4 the installer has the server
store it as a PBKDF2 hash (hMailServer.exe --set-admin-password), where it used to write an
unsalted MD5 digest, and hands it to the database tools in its environment
(HM_ADMIN_PASSWORD), never on their command lines; before 6.3.4 a
password with a space, a double quote or backslashes before one failed the database step. An unattended
installation takes the password with /adminpassword= or, from 6.3.4, in
HM_ADMIN_PASSWORD, set in an elevated prompt; a password with a double quote must go in the
variable, because Inno Setup drops every double quote from its command line, and anybody listing
processes can read /adminpassword= while setup runs; setup refuses to start when either gives
one shorter than five characters. The page is skipped when the server already has a
password, as on an upgrade, which keeps it.

Use a long, unique password and store it in a password manager. Anyone with it owns every mailbox on the server.
Do not leave it unset. An unattended installation given neither
/adminpassword=norHM_ADMIN_PASSWORDsets no password, and while there is none every COM session starts authenticated as a server administrator and the REST API refuses to start. Set one before the server can be reached from anywhere but this machine: from an elevated prompt in theBinfolder,hMailServer.exe --set-admin-password, with the password on standard input or inHM_ADMIN_PASSWORD, never as an argument, then restart the service (Restart-Service hMailServer): a running service keeps the password it loaded, here none, until it is restarted or reinitialised.
7. Server profile (new in 6.3.4; shown on a new installation only, one with no database
yet). What kind of server this is: none, a small office
server, one behind a filtering gateway, an outbound relay or a high-volume sender. A profile sets about
thirty settings, each with its reason, and keeps what it replaced so that it can be put back later from
the Control Panel’s Set up this server → Setting profiles page or the Control Deck.
None keeps the settings this version ships with. A silent installation takes
/profile=office, gateway, relay, sender or none.
8. Service account (new in 6.3.4). On a new installation the default, and the
recommendation, is NT SERVICE\hMailServer, a virtual account of the service’s own that
reaches only the folders setup grants it; the other choice is LocalSystem, as earlier versions ran it
(§17.3). An upgrade keeps the account the service has unless you choose otherwise here, and a silent
installation takes /serviceaccount=virtual, localsystem or keep. If the
database is SQL Server on this machine reached with Windows authentication, give
NT SERVICE\hMailServer a login there before the service moves to it (CREATE LOGIN
[NT SERVICE\hMailServer] FROM WINDOWS, and a user in the hMailServer database with
db_owner); the page says so.
9. Ready to install. A summary of everything you chose. Click Install. The
installer copies files, registers the hMailServer Windows
service, creates the database and starts the service. If the .NET 10 Desktop Runtime is missing it is installed
silently — the Control Panel and the database tools both run on it. Since 6.3.4 a database the
installer creates is made secure before anyone configures it — a self-signed certificate for the
machine, STARTTLS offered on 25, 587, 110 and 143, and TLS required for authentication from the Internet
IP range — and the installer makes the stored-secrets key that seals the second factors and the
[Settings] secrets. That key is in this machine’s hMailServer.INI alone, sealed
under its DPAPI, and never in the database, so export it when setup asks, from an elevated prompt in
the Bin folder (.\hMailServer.exe --export-stored-secrets-key D:\keys\hmailserver-stored-secrets.key),
and keep the file with the database backups, stored apart from them: a database-level backup (a dump,
a SQL Server backup, a copy of the .sdf) restored without it opens no second factor and no
[Settings] secret. An upgrade makes no key and is not given these secure defaults.

More: Installing on Windows: wizard pages, service account, profile and the first hour and Unattended installs, winget and Chocolatey, and configuration from the environment on the forum.
4.3 Confirm it is running
Open Services (services.msc) and find hMailServer. It should say Running,
startup type Automatic, logging on as NT SERVICE\hMailServer on a new 6.3.4
installation that kept the installer’s default (as Local System if setup could not move it,
which it then says), and on an upgrade as the account it had, Local System unless that was
changed (the screenshot, from an earlier version, shows Local System).

Or from PowerShell:
Get-Service hMailServer
Status Name DisplayName
------ ---- -----------
Running hMailServer hMailServer
On Linux the service is a systemd unit of the same name:
systemctl status hmailserver
journalctl -u hmailserver -f
hmailserver --check-config
--check-config reads the configuration, reports what it says and exits without opening
a listener or touching the database: it prints the configuration path, the directories and the database
type it made of the file, and Database type: 0 means the Type key did not take. Run it
as the service’s user (sudo -u hmailserver hmailserver --check-config). It does not check
[Settings]: since 6.3.4 a [Settings] key edited into the file after the first start is
reported in the error log as HM5804 and not used, and is changed over the REST API or with
sudo hmailserver --set-setting instead.
Chapter 35 has the whole command set.
Since 6.3.4 the server also judges its own first hour, in twelve items, thirteen from 6.3.6: a profile, a domain, an account, TLS on the submission port, passwords only over TLS, SPF, DKIM, DMARC, a backup destination, a second factor for the administrator, the stored-secrets key, the service account and, from 6.3.6, no password on port 25 (§18.5), each done, action needed, cannot tell or not needed, with a sentence saying what it found. It heads the Control Panel’s Set up this server → Setup checklist page and is the Control Deck’s Checklist view.
4.4 Open the firewall
The installer does not open firewall ports for you. Until you do, nothing can reach your server. Run this in an elevated PowerShell:
New-NetFirewallRule -DisplayName "hMailServer SMTP" -Direction Inbound -Protocol TCP -LocalPort 25 -Action Allow
New-NetFirewallRule -DisplayName "hMailServer Submission" -Direction Inbound -Protocol TCP -LocalPort 587 -Action Allow
New-NetFirewallRule -DisplayName "hMailServer IMAP" -Direction Inbound -Protocol TCP -LocalPort 143 -Action Allow
New-NetFirewallRule -DisplayName "hMailServer IMAPS" -Direction Inbound -Protocol TCP -LocalPort 993 -Action Allow
New-NetFirewallRule -DisplayName "hMailServer POP3" -Direction Inbound -Protocol TCP -LocalPort 110 -Action Allow
New-NetFirewallRule -DisplayName "hMailServer POP3S" -Direction Inbound -Protocol TCP -LocalPort 995 -Action Allow
On Linux, with firewalld:
firewall-cmd --permanent --add-port={25,110,143,465,587,993,995}/tcp
firewall-cmd --reload
or with ufw:
ufw allow 25,110,143,465,587,993,995/tcp
The systemd unit grants CAP_NET_BIND_SERVICE ambiently, so the service can bind 25, 110,
143, 465, 587, 993 and 995 without ever running as root.
Your router or cloud firewall needs the same ports forwarded to this machine.
The TLS ports in these lists, 993 and 995 (and 465 in the Linux ones), are there because you will want them, but a new installation listens only on 25, 587, 110 and 143, the four listeners the database is created with (since 6.3.4 with STARTTLS offered on a self-signed certificate). Add the implicit-TLS ports once you have a certificate: on Windows on Connections & protocols → TCP/IP ports, on Linux over REST (
/api/v1/ports) or in the Control Deck (chapter 9).
More: Every port hMailServer listens on or connects to, and the firewall rules to match on the forum.
5. The Control Panel: your first look
The hMailServer Control Panel (hMailCP.exe) is the Windows way to administer the
server. It is a .NET 10 desktop application that talks to the server over the COM API —
which means it can administer a server on another machine just as easily as the local
one.
It is one route of three. The Control Deck — the administration page the REST
listener serves at /, in any browser — now covers most of the same ground
(chapter 36), and the REST API itself (chapter 24) is there for scripts. On Linux there is
no Control Panel and no COM at all, so those two are administration, beside the server’s own
command-line options and, since 6.3.4, hmctl, a command-line client over the same API (chapter 35).
Launch it from the Start menu.
5.1 Connecting

| Field | What to enter |
|---|---|
| Server | localhost for the machine you are sitting at, or the remote server’s name/IP |
| User name | Administrator |
| Password | The administration password you set during installation |
If you have enabled two-factor authentication (§17.4), you are asked for a six-digit code next.
To change the administrator password later, use Sign-in & access → Administrative
access, Password tab. On a server whose service runs as NT SERVICE\hMailServer, as a new
6.3.4 installation’s does, the tab’s Administrator password card refuses the change with a
sentence, because the file the password is kept in is read-only to the service. Set it instead from an elevated prompt beside hMailServer.exe:
put the new password in the HM_ADMIN_PASSWORD environment variable (never on the command
line), run hMailServer.exe --set-admin-password, and restart the service, which goes on with
the old password until then (§17.3).
5.2 The Welcome page

The Welcome page opens with twelve starting points under What do you want to do?, written as outcomes (“Mail is stuck or slow”, “Add a domain or a mailbox”, “Stop the spam”, “Renew or install a certificate” and eight more), each one click from the page that does it. Below them, Or browse by area has six tiles for the areas you will visit most. It is the fastest route to “add a domain” when you are starting out, but it is not where you land: connecting opens the Dashboard, and Welcome is a click away in the sidebar.
Since 6.3.4 the Welcome page also offers a sample domain: one button makes a throwaway
example.test with three mailboxes, a list of the three, a rule and eight delivered messages,
so that every page has something in it while you learn, and then turns into a Remove
button. The three mailboxes share one password, made when the sample is and shown once, so write it
down. They are
ordinary mailboxes that can sign in and relay, so remove the sample before the server takes real mail.
A button in the page’s header starts a seven-stop tour of the pages a new server needs (Domains,
TCP/IP ports, SSL certificates, then the Dashboard); it points and explains, and never acts.
5.3 Finding your way around
The left sidebar is the navigation tree: 66 pages in nine groups in 6.3.5, one more than in 6.3.4 (starred below), never more than two levels deep and grouped by what you are trying to do rather than by where the server keeps things. It is organised in the order you will need it:
Welcome ← what do you want to do?
Dashboard ← live counters and charts
Set up this server ← Setup checklist, Setting profiles, DNS records
Accounts & domains ← Domains, Groups, Public folders, Administrator roles
Mail flow & delivery ← Delivery of e-mail, Routes, Remote domains,
Incoming relays, Rules, Server messages, Server sendout
Connections & protocols ← TCP/IP ports, Protocols,
Web services & autoconfiguration, Webmail*, DNS resolver
Certificates & encryption ← SSL certificates, Certificates (ACME),
Transport encryption overview, SSL/TLS, Transport security
Spam & virus filtering ← Spam filtering overview, Quarantine,
Anti-spam settings, SURBL servers, DNS blacklists, White list,
Blocked senders, Allow and block list, Greylisting white list,
Greylisting bypass list, Domain white and black list,
Virus scanning overview, Anti-virus settings, Blocked attachments
Sign-in & access ← Authentication, Administrative access, IP ranges,
Auto-ban, Directory authentication (LDAP), Directory synchronisation
Monitoring & troubleshooting ← is mail flowing, and if not, where has it stopped?
Server status, Delivery queue, Live logs, Logging, Diagnosing stalled mail,
Message trace, Diagnostics, MX query, Suppression list, Audit trail,
Journaling and dual delivery, Discovery, API & monitoring, REST API keys
Maintenance ← Backup & restore, Performance, Advanced, Event scripts,
Server limits & expert settings
About
* new in 6.3.5. The Setup checklist was External setup, Certificates & encryption
was TLS & certificates, and Sign-in & access was Access & abuse protection;
each old name is still an alias the Ctrl+K palette finds.
Once you are connected, press Ctrl+K at any time for the command palette — since 6.2.14 it searches
settings as well as pages, indexed by label and INI key, so typing log level or
LogDeleteDays takes you to the setting itself. Start typing a page name and jump
straight to it, or say what you want to do (“stop spam”, “block an IP”). It also knows the old
navigation paths, so a path from an old forum post or your own notes still leads somewhere:
Settings > Security > Auto-ban & SSL/TLS opens Auto-ban.
The window remembers its size, position and maximised state between sessions, and both light and dark themes are fully supported (it follows your Windows setting until you choose a theme yourself, and follows High Contrast as well). Since 6.3.4 a command bar runs across the top of the window: a search field that opens the same Ctrl+K palette, a status pill saying whether the connection is up, the theme toggle, and the administrator’s menu, which holds who is signed in where, the server version, the language, the theme and About. Below 1,000 pixels of window width the sidebar folds to a rail of one icon per group.
Also since 6.3.4, every page has a help button, and F1 does the same: a pane opens beside the page with what the operator documentation says about it, built into the Control Panel, so it works on a server with no route to the Internet. And a save applies: under every field kept in the settings store a line says when a new value takes effect — Applies on save (211 of the keys), After reinitialize (28: a listener’s port or bind address, a TLS context, a work queue’s size) or After a restart (2). A bar at the foot of the window counts the saved settings still waiting, with Apply now: a reinitialise over COM, which stops and starts every one of the server’s services in place, but not the Windows service itself, and needs no elevation prompt. It asks first, and says so when the server is draining, because a reinitialise ends a drain. Connected to a 6.3.3 server, the Control Panel marks every such field After a restart and never says a save took effect.
Since 6.2.28 it speaks seventeen languages beside English — Czech, Danish, German, Spanish, Finnish, French, Italian, Japanese, Norwegian bokmål, Dutch, Polish, Brazilian Portuguese, Russian, Swedish, Turkish, Ukrainian and Simplified Chinese. Every page, dialog, message, hint and verdict sentence is translated: 4,196 texts in 6.3.4, not a subset. The language is chosen on the sign-in card or, since 6.3.4, in the command bar’s administrator menu; the Control Panel restarts in it, and the choice is remembered per Windows user.
5.4 The Dashboard

Live view of the server, refreshed every two seconds: uptime, messages processed, messages in
the queue, spam blocked and viruses removed, with charts of throughput and of SMTP, POP3 and IMAP
sessions over the last three minutes, 24 hours, 7 days or 30 days (the three longer ranges read the
history the server keeps, MetricsHistoryDays days of it, 7 by default). Below them a
needs attention card runs the same checks as the Setup checklist page — since 6.3.4 headed by
the first-hour checklist, and since 6.3.5 numbered in the same order — and links each outstanding item to the page that owns it. This is the page
to leave open on a second monitor; the state of the Windows service itself is on
Monitoring & troubleshooting → Server status.
More: The Control Panel on Windows: every page, what it writes, and what applies when on the forum.
6. Your first domain and mailbox
This is the chapter where you get working email.
Just learning? Make a sample domain first. Since 6.3.4 one button, on the Control Panel’s Welcome page or the Control Deck’s Checklist view, or one command (
hmctl sample-domain create, orhmailserver --sample-domain createon Linux) makes a throwawayexample.testwith three mailboxes, a distribution list, one rule and eight messages, so every page has something in it. The three mailboxes share one password, which the server makes when it creates the sample and shows once, in its answer; it is stored nowhere, so write it down. The same button, now Remove, orhmctl sample-domain removetakes all of it away and says what it removed. Remove it before the server takes real mail: its mailboxes are ordinary accounts that can sign in and relay like any other. Then come back here for your own domain.
6.1 Add your domain
Go to Accounts & domains → Domains, type the domain name into the box at the bottom of the domain list and click Add domain. Then select the domain and click Properties to open the domain dialog.

In the domain dialog:

| Field | Value |
|---|---|
| Domain name | yourcompany.com — the part after the @, with no www and no mail. |
| Domain enabled | Ticked |
| Maximum domain size (MB) (Limits tab) | 0 for unlimited, or a size in MB to cap the whole domain |
| Postmaster address | Leave it empty. This field is the domain’s catch-all, not the postmaster mailbox: mail to any address in the
domain that matches no account, alias, distribution list or route is redirected to it, so every typo and every
dictionary-attack recipient is accepted and stored instead of being refused with 550 Unknown user. The
[email protected] mailbox that RFC 5321 requires is an account, which you create in 6.2. |
Click Save.
A common mistake: the domain is
yourcompany.com, notmail.yourcompany.com.mail.yourcompany.comis the name of the machine;yourcompany.comis the domain whose email you are hosting. Addresses look like[email protected].
6.2 Add a mailbox
Select your domain, type the address and a password into the boxes under its account list and click Create account. Then click Edit (or double-click the account) to open the account dialog, where the rest of these fields are.
| Field | Value |
|---|---|
| Address | [email protected] |
| Password | Click Generate strong password in the account dialog (Generate beside the password box when you create the account) — it uses a cryptographic RNG and copies to your clipboard |
| Quota (MB) | Mailbox quota in MB. 0 = unlimited |
| Account enabled | Ticked |
| Administration level | Normal user for a normal mailbox. Domain administrator manages its own domain; only give Server administrator to yourself |

The account dialog also has tabs for Auto-reply (out-of-office), Forwarding, External (fetch mail from elsewhere by POP3 or IMAP), Rules, Sieve and Windows directory (Active Directory linking; the tab was called Directory in 6.3.3). In 6.3.4 it has fifteen tabs in all.


Create at least these two:
[email protected]— required by the email standards[email protected]— your actual mailbox
6.3 Test it locally, before touching DNS
Prove the server works before you involve the internet. From PowerShell on the server:
Send-MailMessage -SmtpServer localhost -Port 25 `
-From "[email protected]" -To "[email protected]" `
-Subject "First test" -Body "If you can read this, hMailServer is working."
Send-MailMessageis deprecated but perfectly good for a one-off test.
Then check Monitoring & troubleshooting → Delivery queue (should be empty — the message was delivered instantly) and Monitoring & troubleshooting → Live logs.

Now connect a mail client (chapter 7) and read the message. If it is there, your server works. Everything from here is about the outside world being able to reach it.
More: Your first domain and mailbox, and the DNS records your domain needs on the forum.
6.4 Creating accounts from Active Directory
New in 6.2.19. An account has always been able to authenticate against Active
Directory (the account dialog’s Windows directory tab); now the directory can also be the source of
accounts. Directory sync reads an LDAP directory and creates or updates the mailboxes it
says should exist — from its Control Panel page, Sign-in & access → Directory synchronisation, on an optional unattended schedule, or
from a script (Settings.PreviewDirectorySync / ApplyDirectorySync).
It is built around a preview. Planning and applying are one piece of code — the planner decides and writes nothing, the applier writes and decides nothing — so a preview cannot describe an action the apply would not take, and the Apply button stays disabled until a preview of the same options has been run.
What it refuses to do is as important as what it does:
- A domain takes part only if its Active Directory domain name is set — provisioning is opt-in per domain, so a search base pointed one level too high cannot provision into unrelated hosted domains.
- Nothing is ever deleted. The most it will do is clear Active, and only when asked — never on a truncated or empty enumeration, where an account that still exists is indistinguishable from one that was removed.
- An account that is not directory-linked, or is linked to a different AD domain, is reported rather than taken over.
Since 6.3.4 an organisation whose people are kept in Microsoft Entra ID or Okta can let the identity
provider do this instead, over SCIM 2.0 at /scim/v2 on the REST listener: it makes, renames, suspends and
removes accounts, with their aliases and distribution lists, as people join, change and leave. It needs an API key
whose role holds the provision permission, and unlike directory sync it does delete: a DELETE
removes the account and its mailbox.
More: Active Directory and LDAP sign-in, directory synchronisation and OAuth2 bearer tokens and SCIM provisioning from Entra ID and Okta on the forum.
7. Connecting a mail client
7.1 The settings your users need
| Setting | Value |
|---|---|
| Incoming server (IMAP) | mail.yourcompany.com, port 993, SSL/TLS |
| Incoming server (POP3) | mail.yourcompany.com, port 995, SSL/TLS |
| Outgoing server (SMTP) | mail.yourcompany.com, port 587, STARTTLS |
| User name | The full email address — [email protected], not you |
| Password | The account’s password, or an app password once the account has a second factor (TOTP) enrolled: from then on its own password is refused in every mail client, and app passwords are its only mail credential |
| Authentication | Normal password (or SCRAM-SHA-256, see §17.2; IMAP offers it only once Allow SASL PLAIN authentication is on, on the IMAP tab of Connections & protocols → Protocols) |
Two mistakes cover most failed client setups: entering the user name without the domain part, and using port 25 for sending. Users send on 587.
Ports 993, 995 and 465 do not exist until you create them: a new database has only SMTP 25 and 587,
POP3 110 and IMAP 143. Create the TLS ports on Connections & protocols → TCP/IP ports
(on Linux, in the Control Deck or over the REST API) and give each a certificate (chapter 9) before you
hand these settings out; a port added or changed is listened on from the next reinitialise (POST /api/v1/server/reinitialize, or systemctl reload on Linux) or service start, not from the save. Since 6.3.4 a new installation, and not an upgraded server, starts with a
self-signed certificate, STARTTLS offered on those four ports and TLS required for authentication from
the Internet IP range: from any other machine a client set to no encryption is refused its
password, so set it to STARTTLS, or to SSL/TLS on 993 and 995 once they exist. On a server whose database was
made before 6.3.4 the four ports stay as they were, seeded with no encryption, so also set 587 to
STARTTLS (required) on the same page and give it a certificate before telling users to send on
it. Mail clients warn about
the self-signed certificate until you replace it or ACME fetches one (§9.1).
IMAP or POP3?
- IMAP keeps mail on the server; every device sees the same mailbox, folders and read/unread state. Use IMAP.
- POP3 downloads mail to one device and (usually) deletes it from the server. Only use it if you have a specific reason.
7.2 Automatic client configuration
hMailServer can configure clients for your users automatically, so they type only their address and password.
Enable it on Connections & protocols → Web services & autoconfiguration in the
Control Panel, or set the keys below over REST (PUT /api/v1/settings/ini/{name}) or, on Linux,
which has no Control Panel, with sudo hmailserver --set-setting NAME=VALUE. Since 6.3.4 they are
kept in the database, not in hMailServer.INI: a line edited in the file is ignored in favour of
the stored value, named in the error log as HM5804 and, where the service may write the file,
put back.
WebServicesHttpPort=80
WebServicesHttpsPort=443
AutoconfigEnabled=1
AutoconfigEnabled is already 1 by default and applies when saved: giving the web services
listener a port is what turns autoconfiguration on. The two ports wait for a reinitialise
(Apply now in the Control Panel’s pending bar, or POST /api/v1/server/reinitialize),
and the HTTPS listener stays off until it has a certificate, from ACME or in
WebServicesCertificateFile and WebServicesPrivateKeyFile. The answer names
AutoconfigClientHost, or the server’s host name when that is empty; with neither set, the
endpoints answer 404.
Then add DNS records pointing autoconfig.yourcompany.com and
autodiscover.yourcompany.com at your server. Thunderbird and Outlook will find their
own settings.
More: Connecting a mail client: IMAP, POP3, SMTP submission, app passwords and the ports on the forum.
8. Making the internet trust you: DNS
This is the most important chapter in this document. A perfectly configured mail server with wrong DNS delivers nothing but spam-foldered mail.
Add these records at your DNS provider. Replace yourcompany.com and 203.0.113.10
with your own.
8.1 A record — where the server is
mail.yourcompany.com. IN A 203.0.113.10
8.2 MX record — where mail goes
yourcompany.com. IN MX 10 mail.yourcompany.com.
The 10 is a priority; with one server the number is arbitrary. An MX record must
point at a host name, never at an IP address.
8.3 PTR record — proving the IP is yours
10.113.0.203.in-addr.arpa. IN PTR mail.yourcompany.com.
You cannot set this yourself — ask your ISP or hosting provider. Without it, Google and Microsoft will reject or spam-folder your mail.
8.4 SPF — who may send as you
yourcompany.com. IN TXT "v=spf1 mx a:mail.yourcompany.com ~all"
This says: “the servers in my MX records, and this host, may send my mail; treat everything else as suspicious.”
-all— hard fail, reject anything else. Correct once you are sure.~all— soft fail, mark as suspicious. Start here: it is what the Control Panel suggests, and it is safer while you are still testing.
If you also send through another service (a newsletter platform, say), include it:
"v=spf1 mx include:sendgrid.net -all"
8.5 DKIM — signing your mail
DKIM adds a cryptographic signature proving the message came from you unaltered.
hMailServer generates the key for you. In the Control Panel, open your domain’s DKIM tab and click Generate key pair. It creates an RSA-2048 key, saves the private key, fills in the path, and shows you the exact DNS record to publish — with a Copy DNS value button.

You will get something like:
dkim._domainkey.yourcompany.com. IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq..."

Publish that TXT record. Signing is off until you switch it on: once the record resolves, tick Enable DKIM signing on the same tab and save the domain, and hMailServer signs every outgoing message from that domain. Do not tick it before the record is live: every message would then fail DKIM at the receiver.
hMailServer also supports Ed25519 DKIM (RFC 8463) — shorter keys, modern crypto — alongside RSA.
Since 6.3.4 the DKIM tab can also rotate the key for you (Key rotation;
also on the Control Deck and over the REST API). It is off on every domain until you switch it on. When the
key is older than the interval (180 days by default), or when you click Rotate now, it
generates a new key under a dated selector such as s20260915 and shows its TXT record; checks the
record from the server’s own resolver every fifteen minutes; moves signing to the new key only once the
record has carried exactly that key for the propagation period (72 hours by default); keeps the old selector
for a retention period (7 days by default) for mail still on its way; and then lists the old record as one
you may remove. It never edits DNS and does not switch signing on: publishing and removing the records stays
yours.
8.6 DMARC — the policy
_dmarc.yourcompany.com. IN TXT "v=DMARC1; p=none; rua=mailto:[email protected]"
Start with p=none: “don’t change how you treat my mail, but send me reports.” Read the
reports for a few weeks, confirm all your legitimate mail passes SPF and DKIM, then
tighten:
p=quarantine ← failures go to spam
p=reject ← failures are refused outright
Do not start at
p=reject. If something you had forgotten about sends mail as your domain, you will silently lose it.
Since 6.3.4 the server can read the reports for you. Name the rua= address as
the domain’s report mailbox (the domain’s Reports received tab, or
report_mailbox over REST), and the DMARC aggregate and TLS-RPT reports that arrive there, gzip, zip
or plain, are read into the database and charted on the Control Deck’s DMARC page: who sends as the domain,
from where, and with what result. The mailbox must be in a domain this server hosts. Nothing is read until you
name one, and nothing read there acts on any mail.
More: DKIM rotation, DMARC and TLS reports on the forum.
8.7 MTA-STS — enforcing encryption inbound
MTA-STS tells other servers that mail to you must be encrypted, preventing an
attacker from stripping TLS. hMailServer can host the policy itself. Since 6.3.4 these keys are kept in the
database, and the [Settings] section of hMailServer.ini is only its copy: a line
edited there is ignored in favour of the stored value, reported as HM5804 and, where the service
may write the file, put back (chapter 21). Set them on Connections & protocols →
Web services & autoconfiguration (the Listener and MTA-STS policy
hosting cards), over REST (PUT /api/v1/settings/ini/{name}) or COM, or on Linux with
sudo hmailserver --set-setting or hmctl ini set; the Control Deck has no editor for them:
WebServicesHttpsPort=443
MtaStsHostingEnabled=1
MtaStsPolicyMode=testing
The HTTPS listener starts only once it has a certificate, and it must be valid for
mta-sts.yourcompany.com: its own (WebServicesCertificateFile and
WebServicesPrivateKeyFile), or an ACME one with that name in AcmeDomains. A changed
port or certificate file takes effect at a reinitialise (Apply now); MtaStsHostingEnabled and MtaStsPolicyMode apply when saved. Start
with MtaStsPolicyMode=testing: it gives you TLS-RPT reports without losing mail while you find
out whether every sender can reach you over TLS (publish
_smtp._tls.yourcompany.com. IN TXT "v=TLSRPTv1; rua=mailto:[email protected]" to
receive them at the report mailbox of §8.6). Move to enforce once they can.
Point mta-sts.yourcompany.com at your server and publish:
_mta-sts.yourcompany.com. IN TXT "v=STSv1; id=20260101000000Z"
More: Requiring TLS: MTA-STS and TLS-RPT, DANE with ACME, and remote domain policies on the forum.
8.8 Checking your work
Give DNS an hour to propagate, then verify. hMailServer has an MX query tool built in (Monitoring & troubleshooting → MX query):

Also use these external checkers — they will tell you what the rest of the internet sees:
- https://mxtoolbox.com/SuperTool.aspx — MX, SPF, DKIM, DMARC, blacklists
- https://www.mail-tester.com — send it a message; it scores your setup out of 10
- https://dmarcian.com/dmarc-inspector/ — DMARC specifically
Aim for 10/10 on mail-tester before you consider yourself live.
DNS summary
| Record | Name | Value | Set by |
|---|---|---|---|
| A | mail.yourcompany.com |
Your IP | You |
| MX | yourcompany.com |
mail.yourcompany.com |
You |
| PTR | Your IP | mail.yourcompany.com |
Your ISP |
| TXT (SPF) | yourcompany.com |
v=spf1 mx a:mail.yourcompany.com ~all |
You |
| TXT (DKIM) | dkim._domainkey.yourcompany.com |
From the Control Panel | You |
| TXT (DMARC) | _dmarc.yourcompany.com |
v=DMARC1; p=none; rua=... |
You |
Part IIRunning it properly
9. Encryption and certificates
Mail must be encrypted in transit. You need a TLS certificate for
mail.yourcompany.com.
Since 6.3.4 a new installation is secured before anyone configures it: a database the installation created is given a self-signed certificate for the machine (listed as Self-signed (made at installation)), STARTTLS (optional) on ports 25, 587, 110 and 143 with that certificate, and TLS required for authentication from the Internet IP range. An upgraded or attached database keeps its listeners as they were. Mail clients warn about a self-signed certificate, so replacing it (§9.1 or §9.2) is still the first step.
Since 6.3.4 the connection to the database is encrypted too: MySQL and MariaDB use TLS whenever the
database server offers it, without checking its certificate (PostgreSQL’s client already did), and SQL
Server’s whole session is encrypted through MSOLEDBSQL 18 or SQL Native Client, but through SQLOLEDB
only the login unless TlsMode is require or verify-full. Where
the database is on another machine, set
TlsMode=verify-full and TlsCaFile in the [Database] section of
hMailServer.INI, which stays in the file and is read when the service starts (chapter 21).
verify-full is TLS or no connection: the database server’s certificate must chain to the CA in
TlsCaFile (SQL Server reads no file and checks against the Windows trust store) and name the
host or address in Server=. So give the database server such a certificate first, and try the
change with --check-database, which makes one connection and changes nothing, before
restarting the service.
9.1 The easy way: automatic Let’s Encrypt certificates
hMailServer has an ACME v2 client built in. It obtains a free certificate, installs it, assigns it to your TLS ports, renews it before expiry and reloads it — with no restart and no scheduled task. It assigns the certificate to every TLS port that has none and, since 6.3.4, to every port that still holds the self-signed certificate a new installation made; a port that names a certificate somebody chose is left alone.
Open Certificates & encryption → Certificates (ACME):

Or set the same keys over COM (Settings.SetIniSetting), over REST
(PUT /api/v1/settings/ini/{name}) or, on Linux, with
hmailserver --set-setting NAME=VALUE. Since 6.3.4 the database holds them and the
[Settings] section of hMailServer.INI is only its copy: a line edited in the file is ignored in favour of the stored value and reported as HM5804.
AcmeEnabled takes effect at the next reinitialise (Apply now on the Control
Panel’s pending bar); the other four below apply when saved. The keys:
AcmeEnabled=1
[email protected]
AcmeDomains=mail.yourcompany.com,mta-sts.yourcompany.com,autoconfig.yourcompany.com
AcmeHttpPort=80
AcmeReuseKey=1
Requirements: port 80 must be reachable from the internet (that is how Let’s
Encrypt verifies you own the name), and each name in AcmeDomains must already resolve
to this server.
AcmeReuseKey=1matters if you use DANE. Keeping the same private key across renewals keeps your published TLSA records valid. Leave it on.
Certificates are stored in the ACME folder of the data directory (Data\ACME on a
default Windows installation), or in AcmeCertificateDirectory when that is set.
More: Let’s Encrypt certificates are automatic: the built-in ACME v2 client on the forum.
9.2 The manual way
If you have a certificate from another CA, go to Certificates & encryption → SSL
certificates, add it (PEM certificate and private key, and the key’s passphrase if it is
encrypted), then assign it to your TLS ports under Connections & protocols → TCP/IP
ports, where the change takes effect at the next reinitialise or service start. The certificate file must hold the leaf certificate followed by the issuer chain. A TLS
port with no certificate does not fall back to plaintext: it fails to start (HM5113).

The port editor is where the certificate meets the port — protocol, bind address, connection security and the certificate to serve:

9.3 TLS settings
Certificates & encryption → SSL/TLS controls protocol versions and cipher suites, for the server’s own listeners and for the connections it makes when delivering. The defaults — TLS 1.2 and 1.3 only — are correct. Do not re-enable TLS 1.0 or 1.1 unless a specific ancient client forces you to, and then plan to replace that client.
More: TLS on the listeners: your own certificate, versions, ciphers and client certificates on the forum.
9.4 Outbound transport security
These protect mail you send:
MtaStsEnabled=1 ; honour recipients' MTA-STS policies
DaneEnforcementEnabled=1 ; honour recipients' DANE/TLSA records
DnssecValidationEnabled=1 ; validate DNSSEC for DANE and SPF/DKIM/DMARC lookups
All three are on by default. With DANE enforcement, a recipient whose DNSSEC chain is bogus will not receive your mail rather than receive it unencrypted — which is the point.
Configure them under Certificates & encryption → Transport security.

Since 6.3.4 you
can also demand TLS for one remote domain that publishes no policy of its own, such as a partner or a
bank, under Mail flow & delivery → Remote domains: encrypted,
verified (the certificate must chain to a trusted root and name the host) or
dane. Such a policy can only raise what MTA-STS or DANE require, and a delivery it cannot
meet is deferred with 4.7.0, never sent in the clear.
You can also send daily TLS-RPT reports to recipient domains: give a sender address on the Transport security page’s TLS reporting (TLS-RPT) card, where it applies when saved. Until one is set, the statistics are collected and nothing is sent. The key:
[email protected]
More: Requiring TLS: MTA-STS and TLS-RPT, DANE with ACME, and remote domain policies on the forum.
10. Stopping spam
hMailServer filters in layers. Each one is cheap and catches a different kind of junk.
Open Spam & virus filtering → Anti-spam settings:

SURBL servers checks the links inside a message body against URL blocklists:

10.1 How scoring works
Each test that matches adds a score. Two thresholds decide the outcome:
- Spam mark threshold — at or above this, the message is delivered but tagged
(
X-hMailServer-Spam: YESandX-hMailServer-Reason-*headers added, and the subject prefixed with[SPAM]if you switch that on) so client-side rules can file it. - Spam delete threshold — at or above this, the message is refused during the SMTP conversation, so the sending server is told. With quarantine on (the General tab’s Quarantine instead of refusing card), a message judged after its body has arrived is held for review on Spam & virus filtering → Quarantine instead.
Sensible starting values: mark at 5, delete at 20 (the shipped defaults). Set delete to 0 to switch
refusing off entirely while you are learning what your mail looks like.
Start by tagging, not deleting. Run for a fortnight with deletion off and read what got tagged. Only then turn on deletion, and set the threshold well above anything legitimate has scored.
10.2 The layers
| Test | What it does | Cost |
|---|---|---|
| SPF | Checks the sender’s SPF record (RFC 7208 since 6.3.4) | Up to 10 DNS-querying terms |
| DNS blacklists (DNSBL) | Is the sending IP a known spam source? | One DNS lookup per list |
| SURBL | Are the links in the body on a blocklist? | One DNS lookup per URL |
| Greylisting | Temporarily reject unknown senders; real servers retry, spambots do not | Free, but delays first mail |
| DMARC | Evaluates the sender’s DMARC policy; the only test on by default | The policy lookup, plus SPF and DKIM evaluation |
| SpamAssassin | Full content analysis, hundreds of rules | External service |
SPF follows RFC 7208 since 6.3.4, uses the server’s own resolver settings, and works on
Linux, where every check had been TempError. By default only a hard Fail scores, as before. A client on
127.0.0.0/8 or ::1 no longer passes before any policy is read, so a content filter on the same
machine that re-injects mail should authenticate, or connect from an IP range with anti-spam off.
DNS blacklists (Spam & virus filtering → DNS blacklists) are the best
effort-to-reward ratio. zen.spamhaus.org alone removes most spam. A new installation lists
zen.spamhaus.org and bl.spamcop.net but leaves both inactive: tick Active on the
ones you want. Public mirrors of the large lists rate-limit or refuse queries sent through a public DNS
service, so if a list suddenly stops matching anything, check its operator’s terms first.

Greylisting is remarkably effective and costs nothing, but delays the first message
from every new sender until the sending server retries after the initial delay, 30 minutes by default.
Warn your users, or leave it off for a customer-facing domain. It is switched on, and its delays are set,
on the Greylisting tab of Spam & virus filtering → Anti-spam settings. Since
6.3.4 the same tab can make a sender that retries too early wait longer
(GreyListingSlideOnEarlyRetry) and refuse one that keeps doing so with a 550 and a lock
(GreyListingAbuseThreshold). Both are off by default; the threshold counts attempts, so a
server delivering many queued messages to one recipient at once can reach it.
Also since 6.3.4, and each off until you set it: SPF’s SoftFail, TempError and
PermError can carry scores of their own, and a TempError can be deferred with 451 4.4.3; a null
sender that names a second recipient can be refused, and the connecting address banned; spam-trap addresses refuse
exactly as an unknown address does, ban the connecting address and, when the sender’s SPF passes,
block the sender; impersonation protection, per domain, flags or holds mail from outside that borrows a
colleague’s name or a look-alike of a domain you host; and country blocking judges connections and
sign-ins against a MaxMind-format country database you supply, since the server ships none.
A setting profile, new in 6.3.4, switches several layers on at once. Choose one on
Set up this server → Setting profiles, which shows the difference before anything is written, or, on a new
installation, on the installer’s Server profile page, the seventh, before the service account page (§4.2). The office profile (a small office server) turns on SPF, the MX check, DKIM verification and greylisting,
prefixes a marked message’s subject with [SPAM], and holds a message that reaches the delete threshold after DATA in the quarantine rather than
refusing it.
More: The anti-spam stack end to end on the forum.
10.3 SpamAssassin
For serious content filtering, connect SpamAssassin. hMailServer talks to spamd over
TCP.
Switch it on and set the host and port on the SpamAssassin tab of Spam & virus
filtering → Anti-spam settings (127.0.0.1 and port 783 are the shipped defaults), set
the score SpamAssassin contributes, and press Test SpamAssassin connection before you leave the
page.
Installing SpamAssassin on Windows is covered in the repository’s README — briefly, it
is Strawberry Perl plus Mail::SpamAssassin from CPAN, with spamd run as a Windows
service. On Linux, spamd is your distribution’s own package. hMailServer reaches it
over TCP either way, so it can equally well live on another machine.
Three settings added in 6.2.25, all off by default, on the same tab. Since 6.3.4 they are kept in the
database like every other [Settings] key: change them on that tab, over REST or COM, or on Linux with
hmailserver --set-setting NAME=VALUE, not by editing hMailServer.ini (chapter 21). Saved on that tab or over REST or COM,
they apply at once; a value stored with --set-setting while the service runs is taken at its next save, reinitialise or restart. SpamAssassinLearnOnMove works only with a spamd started with
--allow-tell:
SpamAssassinUser= ; whose spamd preferences to apply (empty = none)
SpamAssassinUserFromRecipient=0 ; 1 uses the recipient address as that user (one-recipient messages only)
SpamAssassinLearnOnMove=0 ; a move into Junk teaches spam, out of it teaches ham
An external filter hook
Since 6.2.24 hMailServer can hand a message to an external HTTP filter — rspamd
and the like — and fold the verdict into the same score SPF, DKIM and DMARC land in. A
"reject" or "soft reject" verdict is worth FilterHookRejectScore. The
settings are on the SpamAssassin tab too, on the card External filtering engine, and
are kept in the database like the three above.
FilterHookUrl= ; empty = off
FilterHookTimeoutSeconds=10
FilterHookRejectScore=100
FilterHookMaxMessageSizeKB=10240
FilterHookFailClosed=0 ; 0 = a filter that cannot be reached does not block mail
Two honest limits. The hook does not speak HTTPS to the engine, so the engine belongs on this machine or on a trusted network. And it cannot express a hard
5xxatDATAindependently of the thresholds — its verdict is a score, not a veto.FilterHookFailClosed=0is the default deliberately: a filter outage should not stop your mail.
10.4 White lists
Two separate lists, for two different jobs:
- Spam & virus filtering → White list — senders and IPs exempt from spam checks entirely.
- Spam & virus filtering → Greylisting white list — sending servers, by IP address, exempt from greylisting only, still spam-checked. Use this for services whose mail you need immediately.


6.3.4 adds three lists to the same group, all empty by default.
Spam & virus filtering → Greylisting bypass list lets a named domain or HELO suffix past
greylisting only when the message passes every check the entry requires (SPF, DKIM or DMARC, at least one) for that name, a narrower privilege than
bypassing greylisting on every SPF pass; every spam test still runs.
Spam & virus filtering → Domain white and black list decides about a sending organisation by
its envelope domain or HELO suffix rather than its IP address: black is refused at MAIL FROM, white
skips greylisting and is not refused by the score, though every test still runs.
Spam & virus filtering → Allow and block list is the server’s list of senders every
recipient wants or does not, read at delivery after the spam tests; a domain’s own list and each
person’s safe and blocked senders come before it, and the nearest wins. An allowed sender’s mail
is delivered unmarked; a blocked sender’s is held in the quarantine, or marked when the quarantine is
off, and never deleted.
More: Allow and block lists, digests, spam traps, country blocking and impersonation on the forum.
11. Stopping viruses
Open Spam & virus filtering → Anti-virus settings:

11.1 ClamAV
ClamAV is free, open source, and the usual choice. Install it, run freshclam to
download signatures, and run clamd listening on TCP 3310.
The connection test changed in 6.2.25. It now asks PING and VERSION first and
names the daemon it reached, and it streams its EICAR sample from memory instead of
writing it to a file. The old test failed on every Windows with real-time protection
enabled, because Defender removed the sample before clamd ever saw it. If you tested
this on an earlier build and concluded ClamAV was broken, test it again.
In hMailServer, on the ClamAV tab of that page, enable ClamAV, point it at localhost:3310
and press Test ClamAV connection. Then open Spam & virus filtering → Virus scanning
overview, which says whether an infected message would actually be caught: a scanner switched on
with a wrong host does not refuse mail, and when every enabled scanner errors, the message is delivered as
if it had been scanned and found clean. To hold such a message and try again instead, set
AVFailAction to 1 on the General tab (card When a scanner cannot run); as shipped it is
retried every 15 minutes and returned to its sender after 16 holds.
Action on virus found:
- Delete entire e-mail — the message is destroyed and delivered to nobody. The default, and recommended.
- Delete infected attachments only — the message is rebuilt, not edited: its first text part is kept with a notice added, the subject is prefixed with “Virus found”, and everything else is discarded, so an HTML version, inline images and every clean attachment go with the infected one. A message encrypted end to end (S/MIME or PGP/MIME) is never stripped: it is delivered exactly as it arrived and the application log says so, so only Delete entire e-mail stops it.
Do not configure a bounce for virus mail: leave Notify sender off. The sender address on a virus is virtually always forged, so a bounce just sends junk to an innocent third party — “backscatter”, and a good way to get blacklisted. Both notification switches are off by default for exactly this reason.
Since 6.3.4 the Anti-virus settings page also has a Sandbox tab: a message from outside whose attachment
is an executable, a script, a shortcut, an archive, a disk image, an office document or a PDF can be held until a
sandbox service you run or subscribe to has opened the file, and quarantined (or returned to its sender while the
quarantine is off) if the verdict is malicious. It is off until you set SandboxSubmitUrl and
SandboxVerdictUrl and switch SandboxEnabled on, and no vendor adapter is shipped. A message
encrypted end to end (S/MIME or PGP/MIME) is scanned only as ciphertext, so a clean result says nothing about its
content, and the application log says it could not be examined.
11.2 Blocked attachments
Separate from virus scanning, and worth using regardless: Spam & virus filtering →
Blocked attachments lists file-name wildcards, and it acts only while attachment blocking is switched on,
on the General tab of Anti-virus settings (AntiVirus.EnableAttachmentBlocking, off as
shipped; the office and gateway profiles on Set up this server → Setting profiles switch it
on). A matching attachment is not refused or deleted: .txt is appended to its name and its content is
replaced by the ATTACHMENT_REMOVED server message, which names the original file, so the message still
arrives and the recipient can see what was removed.

A sound starting list: *.exe, *.scr, *.pif, *.bat, *.cmd, *.com, *.vbs,
*.js, *.jse, *.wsf, *.wsh, *.hta, *.jar, *.msi,
*.msp, *.cpl, *.lnk, *.reg, *.ps1, *.iso, *.img. No legitimate correspondent emails you an .exe.
The list matches the declared file name only, ignoring case: it does not look inside archives or see a file whose extension lies. Since 6.3.4 a mail flow rule can do more than rename: one with an Attachment name condition can quarantine the message or reject it with a text, and the outbound content policy adds an Attachment type condition read from the file’s first bytes, so a renamed executable is still an executable (chapter 12). An encrypted message is never rewritten by the list, since its ciphertext counts as one attachment.
More: Viruses and dangerous attachments on the forum.
12. Sorting mail automatically: rules and Sieve
Two independent systems, both running during delivery.
12.1 hMailServer rules
Mail flow & delivery → Rules (on screen, Global rules) holds the server-wide rules. Since 6.3.4 a domain can have rules of its own, on the same page with the domain chosen at the top; they run after the server’s and act only on that domain’s mail. Each account also has its own Rules tab in its account dialog.

A rule is criteria plus actions. Criteria test the From, To and CC headers,
the subject, the body, the message size, the envelope recipient list, the number of delivery attempts or any header you name;
since 6.3.4 also an attachment’s name, and the four conditions of the outbound content policy: sensitive content (a card
number, an IBAN or an account number), an attachment’s type read from its first bytes, external recipients, and readable text.
Actions move to a folder, forward, reply, delete, create a copy, set a header, run a script function or stop rule
processing, and, in server and domain rules only, send using a route or bind to an address; since 6.3.4 also add a
disclaimer, reject with a text, require TLS, quarantine, or warn the sender. No rule action sets a flag: in Sieve,
imap4flags does that.
A Run script function action (the release notes call it the Run function action) calls a function of the event script by name, with the message. The function
goes in the script on Maintenance → Event scripts, and scripting must be switched on (Maintenance → Advanced,
Scripting tab). Since 6.3.4 the name must be a plain identifier: ASCII letters, digits, _ and $, not
starting with a digit. On Windows any other name is refused as HM5018 each time the rule matches and nothing is
run; on Linux, where event scripts are JavaScript and the action works since 6.3.4, such a name finds no function and the
ERROR log has a Script Error line. Every release from 6.0.0 to 6.3.3 wrote the name into the script’s call unescaped, in
VBScript and JScript alike (CVE-2026-100741), so whoever could edit a rule, a domain’s own administrator included, could
run code of their own in the service. Until a server older than 6.3.4 is upgraded, switch event scripting off. One that cannot do without its scripts should take the
OnClientValidatePassword, OnExternalAccountDownload and OnDeliveryFailed handlers out of a JScript
EventHandlers.js and reload the scripts, and, in either language, let nobody but the server administrator make or edit a
rule with a Run script function action.
Useful examples:
- Move anything with
[SPAM]in the subject to a Junk folder. - Forward everything for
sales@to a distribution list. - Delete messages over 50 MB before they fill a mailbox. The size is counted in bytes, so the value is
52428800, not50. A Delete is silent, because the sender has already been answered250; to have the sender told, use Reject with a text in a server rule (since 6.3.4, answered550 5.7.1before the message is accepted), or set Maximum message size on the SMTP tab of Connections & protocols → Protocols.
12.2 Sieve (RFC 5228)
hMailServer also implements Sieve, the standard mail filtering language — portable across mail servers, and what most modern clients expect.
Each account can have an active Sieve script, edited on the account’s Sieve tab:
require ["fileinto"];
if header :contains "subject" "[SPAM]" {
fileinto "Junk";
} elsif address :domain "from" "newsletters.example.com" {
fileinto "Newsletters";
} else {
keep;
}

Supported: keep, fileinto, discard, redirect, and implicit keep; tests header,
address, exists, size, allof/anyof/not; match types :is, :contains,
:matches. Two extensions arrived in 6.2.19 and 6.2.21:
imap4flags(since 6.2.19) —setflag,addflag,removeflagand the:flagstag onfileinto/keep, applied to the stored message. The five system flags (\Seen \Answered \Flagged \Deleted \Draft) can be stored; a custom keyword is noted in the application log rather than dropped silently.body(RFC 5173, since 6.2.21) — filters can read the message itself, not just its headers, with all three transforms::text(the decoded text of every text part — transfer encoding and charset are decoded before matching, so a rule keeps working when a sender’s client base64-encodes the part),:content "type"(decoded parts of the named MIME types), and:raw(the body exactly as it arrived).
Sixteen more have arrived since, and the capability line advertises twenty-four extensions in all:
| RFC | Extension | What it adds |
|---|---|---|
| 5490 | mailbox |
fileinto :create — file into a folder, creating it if it is missing |
| 5463 | ihave |
Capability probing as a test, so one script serves servers with different feature sets |
| 5183 | environment |
Which server, where, and at what phase the script is running |
| 5260 | date, index |
The date/currentdate tests, and :index/:last for repeated header fields. Two capability names, by that RFC’s own registration |
| 3685 / 5235 | spamtest, spamtestplus |
The server’s own spam verdict as a graded value. virustest is deliberately absent — infected mail never reaches a script |
| 7352 | duplicate |
Has a message with this identifier been seen before. The seen-store fails open |
| 5293 | editheader |
addheader and deleteheader. Received and Return-Path are protected |
| 5229 | variables |
set, ${} expansion, match variables from :matches, and the string test |
| 5429 | reject, ereject |
Both refuse by report: scripts run after the SMTP transaction has accepted the message, so the RFC’s DSN fallback is the only honest spelling |
| 6609 | include |
include, return, global. Personal scripts are the account’s own, global ones the administrator’s. Nesting capped at three levels |
| 5435 / 5436 | enotify |
Notifications go out with a null return path and Auto-Submitted: auto-notified; an auto-submitted message is never notified about |
| — | regex |
draft-ietf-sieve-regex, which never became an RFC. Implemented because Dovecot’s Pigeonhole implements it and clients offer it |
| 5228 | envelope |
Tests the SMTP envelope rather than the headers. Implemented earlier, advertised since 6.2.25, once a test proved the test command itself |
Also advertised, and present at 6.2.21: copy (RFC 3894), relational
(RFC 5231), subaddress (RFC 5233), vacation (RFC 5230) and vacation-seconds
(RFC 6131). The comparators are i;ascii-casemap, i;octet and i;ascii-numeric.
Anything this server does not implement is refused when the script is uploaded, with an error naming the construct (since 6.2.21) — a script never parses as valid and then silently does nothing.
To let users manage their own scripts from their mail client, enable ManageSieve
(RFC 5804). It is off as shipped (ManageSieveServerPort is 0). Since 6.3.4 its two keys, below, are kept in the
database, and their lines in hMailServer.INI are only a copy: a line edited in the file is ignored, reported as HM5804 and, where
the service may write the file, put back. Set them on the ManageSieve (RFC 5804) card of
Monitoring & troubleshooting → API & monitoring, over REST (PUT /api/v1/settings/ini/{name}) or COM, or on Linux with
sudo hmailserver --set-setting ManageSieveServerPort=4190. Both take effect at a reinitialise
(Apply now in the Control Panel or the Control Deck, or POST /api/v1/server/reinitialize), without a
service restart. The values:
ManageSieveServerPort=4190
ManageSieveServerBindAddress=127.0.0.1
ManageSieve’s only SASL mechanism is
PLAIN, but it is no longer offered blindly. The listener advertisesSTARTTLSwhenever a TLS certificate is configured on an IMAP, POP3 or SMTP port, and where the connecting IP range requires TLS for authentication it advertises an empty SASL list and refusesAUTHENTICATEwithNO (ENCRYPT-NEEDED)untilSTARTTLShas been issued. Bind it to127.0.0.1anyway unless a certificate is available to it.
For a cookbook of worked rule recipes — and what to do when a rule refuses to match — see chapter 27.
More: Rules and Sieve: what runs when, vacation replies and the Run function action on the forum.
13. Aliases, distribution lists and public folders
13.1 Aliases
An alias is a second address that delivers to a target: usually an existing mailbox, though the
target may also be another alias, a distribution list or an address at another server. info@ → sales@
costs no extra mailbox and no extra password.
Since 6.3.4 two more kinds of second address exist. Renaming a mailbox or moving it to another domain
(Move or rename… on the account’s General tab) keeps the old address as an alias of the new one
by default, so mail to it goes on arriving. And users can make masked addresses for themselves in the
webmail (Settings, Masked addresses): each delivers to its owner until switched off, and then mail to it is
refused rather than handed to the catch-all. ReaderMaskedAddressesMax (20 by default) caps how many each
person may make, and 0 removes the page.
Domain aliases work at the domain level: mail to yourcompany.net can be delivered
to the accounts of yourcompany.com.
13.2 Distribution lists
A distribution list delivers one message to many recipients — [email protected].
Since 6.3.4 an address, or a whole domain, can be routed to a webhook, and a route is asked before any account,
alias or list: a route that only posts takes the address’s mail in place of its mailbox, alias or list, so a
list whose address or domain is routed never sees the message and applies none of its posting rules to it. Route a
list’s domain with care.

You control who may post: anyone, only list members, only the one address in Require sender address (announcements only), or anyone whose address is in the list’s own domain; Require SMTP authentication can be added to any of these. Set this deliberately. A publicly postable list is a spam relay in waiting, and anyone in the domain only checks the address the sender claims: tick Require SMTP authentication with it, or the list is open to the internet.
Since 6.3.4 the distribution-list dialog also takes a Membership query: domain (the
list’s own domain), domain:other.example (another domain this server hosts),
pattern:sales-*@example.com or everyone. It is answered against the server’s active
accounts each time a message is sent and added to the addresses under Recipients, so everyone@ stays
current with nobody editing it. With Managed ticked, a list gets the list manager: each member is
sent a copy of their own that carries a way to leave, a member whose address bounces (by default one hard bounce, or five soft ones within thirty days) is paused and the owner
told, and the manager’s other fields decide who may join by themselves (nobody until the subscribe policy is
changed from closed, the default), whether an archive is kept and whether a daily digest is offered. Its web pages
need the REST listener and a ListBaseUrl, and nothing changes for a list until it is switched on. The
list does not rewrite From: a posting from an outside domain that publishes p=reject reaches
outside members failing DMARC once the list has changed it, and Gmail and Yahoo may refuse it, so know where a
list’s postings come from before you tick Managed.
13.3 Public folders
Accounts & domains → Public folders creates shared IMAP folders visible to multiple users — a shared archive, or a team inbox.

Permissions (select a folder and press Permissions) are granted to one account, to a group from Accounts & domains → Groups, or to Anyone (every authenticated account on the server), with the IMAP ACL rights of RFC 4314: lookup, read, insert, delete, administer and the rest. Grant Lookup with anything else: without it the folder does not exist for that user. A sub-folder given any entry of its own stops inheriting its parent’s entries, which are not merged with it.
More: Aliases, distribution lists, membership queries, mailing lists and public folders on the forum.
14. Routes and relays
14.1 Sending through a smart host
If your ISP blocks outbound port 25, or your IP has poor reputation, relay outbound mail through a smart host — your ISP’s server, or a service such as SendGrid, Mailgun or Amazon SES.
Configure it in Mail flow & delivery → Delivery of e-mail, on the Relayer tab — the SMTP relayer host, port, connection security and credentials. When the relay takes a user name and password, set the connection security to STARTTLS (required), or SSL/TLS if the relay expects it, not STARTTLS (optional), so that a downgrade cannot send your credentials in clear.
Changed in 6.3.4. The same tab’s OAuth2 for the relay (Microsoft 365) card signs in to the relay with a token instead of a password, and that token now goes over TLS only. A delivery that would present it (through the relayer, or a route, to a host on
OutboundOAuth2Hosts,smtp.office365.comby default) with connection security None, or STARTTLS (optional) against a server that does not offer STARTTLS, no longer sends the token in the clear: it fails with a temporary450 4.7.0and waits for its retries, and the error log’sHM5912says to set SSL/TLS or STARTTLS (required). Exchange Online offers STARTTLS on port 587, so a relay set up as Microsoft documents it is not affected.
The relayer field accepts several hosts separated by | —
smtp1.example.com|smtp2.example.com — and delivery fails over to the next when one
cannot be reached. This has always worked; it just went undocumented until 6.2.14. Every host in the list uses the
port, connection security and credentials configured beside the field, so they must all accept the same sign-in;
and a permanent (5xx) rejection is final and is not tried on the next host, which could otherwise
deliver the message twice.

This is the standard fix for a home or small-office installation that can receive mail but cannot send it.
14.2 Routes
A route overrides delivery for a specific domain — “mail for partner.example.com
goes to this specific server, not wherever DNS says.”

Used for internal domains, split delivery during a migration, or a partner with a private link.
New in 6.3.4. Mail flow & delivery → Remote domains, beside Mail flow & delivery → Routes, holds a policy for one remote domain or a pattern: the TLS this server demands in each direction (a delivery that cannot meet it is deferred, never sent in the clear), size, connection and rate limits, whether automatic replies and forwards may go there, and a recipient check with the primary for a domain this server is a backup MX for. A route says where mail for a domain goes and a policy says what this server will do when it gets there, so a policy applies to a routed delivery exactly as to an MX one; a saved policy is in force for the next message.
14.3 Incoming relays
Mail flow & delivery → Incoming relays lists servers that legitimately forward mail to
you — a front-end spam filter, for instance. hMailServer then reads the original sender
IP from the Received headers instead of blaming your own filter for everything. Each entry is a name and
an IP range (lower and upper address): keep the range to the gateway’s own addresses, because a machine inside it can
insert a forged Received header and choose which address is judged.
Changed in 6.2.24. The anti-spam tests now run against the address the relay actually observed, rather than the first bracketed value in the
Receivedheader — which was the client’s ownHELOliteral, and therefore chosen by the sender. It is the correct behaviour, and it moves scores: if you tuned thresholds around the old one, re-check them after upgrading.
Changed in 6.3.4. A listed relay changes two more things. A remote domain policy’s requirement of TLS on mail from a domain is not applied to a connection from the relay, because the hop that policy is about happened before the gateway, so require TLS on the gateway itself. And a domain’s external-sender tag treats mail a listed relay hands over as coming from inside, so behind a filtering gateway listed here nothing is tagged.

More: Routes, relays and smart hosts, OAuth2 relaying, restricted delivery and recall and Behind a gateway, load balancer or reverse proxy on the forum.
15. Backup and restore
15.1 What must be backed up
| What | Where | Why |
|---|---|---|
| Messages | The data directory (default C:\Program Files\hMailServer\Data); since 6.3.4, when MessageStoreObjectService names a store (s3 or azure), also the S3-compatible bucket or Azure Blob container that MessageStoreObjectBucket names |
The mail itself. A message held only in the object store is not in the data directory: the built-in backup with Back up messages reads it into the archive, and a copy of the data directory does not |
| Database | Your database server, or Database\hMailServer.sdf for the built-in |
Accounts, domains, settings |
hMailServer.INI |
Bin\hMailServer.INI |
The database connection, the directories, the administrator password and PasswordPepper, none of which the built-in backup carries; since 6.3.4 the [Settings] keys live in the database and the file holds a copy. Since 6.3.4 it also holds the stored-secrets key (the Windows installer makes one on a new installation; an upgraded one has none until --create-stored-secrets-key). A database backup restored without that key brings back every second factor and [Settings] secret unopenable, and on Windows a copy of this file is no copy of the key, because its line is sealed under the machine’s DPAPI: export it once with hMailServer.exe --export-stored-secrets-key FILE, elevated, and keep that file apart from the database backups |
| Certificates and DKIM keys | Wherever you put them; Data\ACME for ACME |
Recreating these is painful |
On Linux the same four live at /var/lib/hmailserver (messages, and the ACME
directory under it), your database server, /etc/hmailserver/hMailServer.ini, and
wherever you put certificates and DKIM keys. One extra file matters there and has no
Windows counterpart: /var/lib/hmailserver/.hmailserver-secret-key, the 32-byte key
every stored secret is encrypted under. Back it up with the database — a database
restored without it has lost every stored password. Since 6.3.4 it also opens the stored-secrets key line in hMailServer.ini, once there is one (sudo hmailserver --create-stored-secrets-key makes it), so keep the key file and the configuration together, or export the key with sudo hmailserver --export-stored-secrets-key FILE.
15.2 The built-in backup
Maintenance → Backup & restore backs up the database and settings, on a schedule if
you like. The schedule is four settings, not an external task. Since 6.3.4 they are kept in the database, not read from hMailServer.INI: set them on that page, over REST (PUT /api/v1/settings/ini/{name}) or COM, or with hmctl ini set, and each applies when it is saved. On Linux sudo hmailserver --set-setting NAME=VALUE stores one as well, but a value it stores while the service runs takes effect only at the service’s next save, a reinitialise or a restart, so reload the service afterwards (sudo systemctl reload hmailserver, a reinitialise). A line edited into the file is reported as HM5804 and, where the service may write the file, put back. The keys and their defaults:
ScheduledBackupTime= ; 24-hour local HH:MM for a daily run; wins over the interval
ScheduledBackupIntervalMinutes=0 ; or run every N minutes (0 = off)
ScheduledBackupKeepCount=0 ; keep this many archives (0 = keep all)
ScheduledBackupMaxAgeDays=0 ; and/or delete archives older than N days (0 = off)
Since 6.3.4 a new Windows installation runs the service as NT SERVICE\hMailServer, which can write only the folders it has been granted: the program folder’s own Data, Logs, Temp and Database. A backup destination anywhere else on the machine must be approved first, with Grant access on the Windows service account card of Maintenance → Server limits & expert settings, or hMailServer.exe --confirm /GrantServiceAccess from an elevated prompt. Some folders are never granted, approved or not: a drive’s root, a folder in the program folder outside those four, anything else in a Program Files folder, the Windows folder or the user profiles folder, and a folder reached through a junction or link. Move such a destination to a folder of its own, for example D:\Backups\hMailServer. A destination on a network share is reached as the computer account (DOMAIN\COMPUTER$), which needs the right on the share and its folder.
Since 6.2.25 a backup verifies itself. With BackupVerifyRestore=1 — the default —
the archive’s message store is extracted to a scratch directory through the same code a
restore runs, before the backup is reported complete. Set it to 0 only for a store your
temporary volume cannot hold.
Since 6.3.4 the archive also carries what an account keeps beyond its mail — the address book, webmail preferences, scheduled sends, file links, S/MIME keys, calendars, password history and message labels — and a restore puts it back. An archive written by 6.3.3 or earlier holds none of it, and a whole restore from one deletes it with the accounts it replaces.
Since 6.3.4 an archive can be encrypted. Set a passphrase (12 to 256 printable ASCII characters) under Encryption on Maintenance → Backup & restore and every archive is written as 7-Zip AES-256 with its headers encrypted; the passphrase is never written into the archive, a restore asks for it, and if it is lost, every archive written with it is lost, so keep it apart from the backups. An encrypted backup of messages needs Compress destination files on and room for the message store in the temp directory; an archive written without a passphrase holds the server’s stored secrets and every account’s second factor in the clear.

The built-in backup does not copy your messages by default. Tick Back up messages to include them, always together with Back up domains: without it the messages are not copied and the run fails. Or back up the data directory separately. Many people discover this at the worst possible moment.
With Compress destination files off, every run overwrites the same
DataBackupfolder, so you keep one generation of mail whatever the retention settings say.
15.3 Restoring
- Install the same version of hMailServer, and stop its service before you restore anything into it.
- Restore the data directory.
- Restore the database.
- Restore
hMailServer.INIonto the machine that wrote it. On a different Windows machine do not copy it over wholesale: its database password and stored-secrets key line are sealed under the old machine’s DPAPI and open nowhere else. Point the new installation’s own[Database]connection at the restored database, and copyPasswordPepperacross if one was set. - Since 6.3.4, if the database came from an installation with a stored-secrets key and this machine does not hold that key, import the exported key before the service starts:
hMailServer.exe --import-stored-secrets-key FILE(elevated) orsudo hmailserver --import-stored-secrets-key FILE. The import refuses while the machine holds another key, and a new Windows installation made one of its own, so first remove that installation’s[Security] StoredSecretsKeyline fromhMailServer.INI. Without the database’s key the service reportsHM7495, every sealed second factor refuses its codes and the[Settings]secrets read as not set. - Start the service and check Monitoring & troubleshooting → Server status.
On a new server, enter again what the restore does not bring in a form that opens there: each domain’s own relay password and, since 6.3.4, the backup passphrase, the SNMP agent’s passwords and community, the object store’s secret key or SAS and the sandbox’s bearer token. No archive carries them, and on Windows a copy of the database brings them sealed under the old machine’s DPAPI. Set the passphrase first: until it is set, a server restored from an archive writes its backups unencrypted, with its stored secrets and every account’s second factor in the clear, and on Windows one restored from a copied database fails every backup.
Since 6.3.4 one mailbox, or one message, can come back without a whole restore, from an archive in the backup destination taken with domains and messages: Restore a mailbox and Restore one message on Maintenance → Backup & restore, or the Control Deck, REST, COM and hmctl. Merge adds each archived message the mailbox lacks; Replace empties the account’s folders first, so mail that arrived since the backup is gone.
A caveat about secrets, and it differs by platform. With
ProtectStoredSecretsWithDPAPI=1(the default), stored passwords — the database password, route/fetch/relay passwords — are protected by the platform’s own secret store. Since 6.3.4 an account’s second factor and the[Settings]secrets, which earlier releases kept as written, are sealed under the installation’s stored-secrets key once it has one, and a copy of the database opens them only where that key is held or has been imported (§15.1, §15.3).On Windows the platform’s store is machine-scoped DPAPI, and the secrets cannot be decrypted on a different machine. Restoring a copy of the database onto new hardware means re-entering them. The built-in backup and restore does not: it carries route, fetch-account and certificate-key passwords in a portable encoding and protects them again on the new machine (the per-domain relay password is in no archive, so re-enter that either way). If you would rather have portable database copies, set
ProtectStoredSecretsWithDPAPI=0to keep the older portable encoding — weaker at rest, but movable. Since 6.3.4 that setting at 0 also stops the second factors and[Settings]secrets being sealed: they are stored as written.On Linux the same setting selects a key file instead:
<DataFolder>/.hmailserver-secret-key, 32 random bytes at mode 0600, written once the first time a secret is stored and never rewritten; each secret is AES-256-GCM under it and stored asLINUX1:<base64>. The key travels with the data directory, so a file-level restore that carriesData/carries the secrets with it — which the Windows store cannot offer. The built-in archive leaves the key file out, and carries the passwords and second factors it holds in a portable form instead; a domain’s S/MIME authority key travels as its envelope and must be rotated where it does not open. The file is refused if any account but its owner can read it, and if it cannot be made or read the secret is not stored at all rather than falling back to something weaker (HM6410 to HM6413, HM6416).A
Passwordencryption=6value written by a Windows installation is a DPAPI blob that nothing on Linux can open. It is recognised as one and reported as HM6414, and the password has to be re-entered in plain text.
More: Backup and restore: what is in the archive, what is not, and restoring one mailbox on the forum.
16. Monitoring and health
16.1 The live view
- Monitoring & troubleshooting → Server status — the server’s version, state and uptime,
the database, the session and message counters, and a list of configuration warnings (a missing
host name, an open relay, an auto-banned
127.0.0.1) worth reading first. - Monitoring & troubleshooting → Delivery queue — messages waiting to go out, each with its next try and try count. The age of the oldest message is your earliest warning, more than the queue’s size: a deep queue that drains is a busy server, but a message that has waited for hours is mail a destination is not getting.
- Monitoring & troubleshooting → Live logs — a real-time, colour-coded log tail, shown only when the Control Panel runs on the server itself.
- Monitoring & troubleshooting → Audit trail — since 6.3.4, and on as shipped: every administrative change, who made it, over which interface and from where, with a setting’s old and new value and Put back to restore the old one.


16.2 Logs on disk
Logs live in the Logs directory — /var/log/hmailserver on Linux.
Monitoring & troubleshooting → Logging controls what is written. Since 6.3.4
LogDeleteDays and JsonLogging are stored in the database, as nearly every [Settings] key is, and both apply when saved; on Linux set them as §16.3 describes, not by editing
hMailServer.ini.

- Turn debug logging on only while diagnosing something — it is verbose and it will fill your disk.
LogDeleteDays=30deletes the log files namedhmailserver_*.logorERROR_hmailserver_*.logthat nothing has written to for 30 days, and nothing else in the directory. The default,0, keeps everything for ever, which with debug logging on is the usual way a log volume fills. On Linux the packages install a logrotate rule for the same files: use one or the other, not both (§35.11).JsonLogging=1writes JSON lines, for ingestion into Elasticsearch, Loki or Splunk. It is overridden while the log format is NCSA, with no error.- Since 6.3.4 every entry can also be copied to a syslog collector (RFC 5424, over UDP, TCP or TLS)
or, on Linux under systemd with no collector named, to the journal. It is off as shipped; switch it on
with the syslog (RFC 5424) card on the Logging page or, on Linux, with
PUT /api/v1/settings/loggingor the Control Deck’s Settings view. These settings are read at the next start or reinitialise, not when saved.
More: Reading the logs: levels, files, the Logs page, following a connection, and fail2ban on the forum.
16.3 Prometheus metrics and health probes
One small HTTP listener serves the metrics and three health probes. It is off until it has a port
(MetricsServerPort is 0 as shipped). The bind address must be an IP address:
a host name or localhost is refused, the application log says
MetricsServer: Invalid bind address, and neither the metrics nor the probes start.
MetricsServerPort=9100
MetricsServerBindAddress=127.0.0.1
/metrics— Prometheus format: messages processed, spam and virus counts, TLS handshake success/failure, authentication success/failure, sessions per protocol, uptime, database pool state, delivery-queue depth and the age of its oldest message, delivery outcomes (hmailserver_messages_delivered_total,_deferred_total,_bounced_total), per-command latency and certificate expiry. Since 6.3.4 also the message store’s size and message count, country blocking, complaint rates and spam-trap hits: 38 metric families, against 30 in 6.3.3./livez— is the process alive?/readyz— 200 when the server is running and the database has answered within the last 20 seconds; 503 otherwise, including while it drains for maintenance or before a stop. While the server starts nothing is listening yet, so a probe is refused rather than answered 503./healthz— JSON summary of status, server state and database.
The /readyz and /livez shapes are deliberately Kubernetes-compatible, so a load
balancer or orchestrator can use them directly.
The listener takes authentication and TLS as of 6.2.25. Bound to anything but
loopback, /metrics needs a credential: without one it answers 503. The three probes answer
without a credential in every configuration. With a certificate and a key set, the whole port is HTTPS,
probes included:
MetricsServerAuthToken= ; bearer token for /metrics
MetricsServerAuthUsername= ; or HTTP Basic
MetricsServerAuthPassword=
MetricsServerCertificateFile= ; PEM
MetricsServerPrivateKeyFile=
MetricsPerDomainEnabled=0 ; per-domain series as well as server totals
And the server remembers its metrics. One row per metric per minute is kept for
MetricsHistoryDays (7) in hm_metricsamples — the table schema 6028 added — readable
over COM, over GET /api/v1/metrics/history, on the dashboard for a day, a week or a
month, and since 6.3.4 in the Control Deck’s Metric history view. OTLP export is
separate and off by default: OtelMetricsEndpoint,
OtelLogsEndpoint and OtelMetricsInterval (60).
Since 6.3.4 the server raises alerts of its own. Seven of its eleven conditions are on
as shipped, a failed backup and a certificate within 7 days of expiry among them, but nothing is mailed
until a recipient and a sender address are set (alert_recipient and
alert_sender_address under /api/v1/settings, or the Control Deck’s Settings view).
The rules themselves are on /api/v1/alerts/rules; there is no Control Panel page for them.
A read-only SNMPv3 agent, also new, answers from the same counters as /metrics; it is off as
shipped and answers no manager until one is listed in SnmpAllowedAddresses.
Monitoring & troubleshooting → Live logs is not the only live view any more either: the
Control Deck reads the log files over /api/v1/logs, a named file’s last 200 or 2,000 lines at a time
(chapter 36). Since 6.3.4 its Logs page also lists the connections open now and the last ones that
ended, and Follow shows every line logged for one of them as it is written
(GET /api/v1/logs/sessions). The server keeps the last 4,096 log entries in memory for this;
nothing survives a restart.
Configure the listener, the history and the OTLP export under
Monitoring & troubleshooting → API & monitoring on Windows. On Linux there is no Control
Panel: set these keys with sudo hmailserver --set-setting NAME=VALUE, with
hmctl ini set NAME VALUE (installed with the packages), or over the REST API (PUT /api/v1/settings/ini/{name}, with the
administrator password); the Control Deck has no editor for these keys. Since 6.3.4 the database holds them
on both platforms, and a line edited in hMailServer.ini is not applied and is reported as
HM5804 (chapter 21). The listener’s port, bind address, credentials and certificate files wait
for a reinitialise (Apply now in the Control Panel, sudo systemctl reload hmailserver
on Linux); the history, per-domain and OTLP keys apply when saved. A value stored with --set-setting while
the service runs reaches it at the next save made on the server, a reinitialise or a restart; nothing watches the table.

More: Monitoring and health: Prometheus metrics, health probes, Grafana, OTLP and SNMPv3 and Alerts, the audit trail, syslog and the journal, and per-domain reports on the forum.
16.4 Diagnostics
Monitoring & troubleshooting → Diagnostics runs ten tests against the running server,
among them outbound port 25 (or the smart host), your MX records and a connection to each MX host, the
backup directory, where message files are stored, the installation’s paths, an auto-banned
127.0.0.1 and any ERROR log file, and reports each in plain language, with the last
message-store consistency scan below. Fill in the local domain and test domain boxes first: three of the
tests are skipped silently when they are empty. Run this
first when something breaks. Since 6.3.4 the Control Deck has a Diagnostics view
that runs the same tests from a browser.

17. Security hardening
The settings in this chapter’s code blocks are [Settings] keys. Since 6.3.4 the
database holds them: set each on its Control Panel page, over COM (Settings.SetIniSetting),
over REST (PUT /api/v1/settings/ini/{name}) or, on Linux, with
sudo hmailserver --set-setting NAME=VALUE (a secret such as SRSSecret with the name
alone, typed at the prompt). A line edited in hMailServer.INI is ignored
in favour of the stored value, reported as HM5804 and, where the service may write the file,
put back (chapter 21). Every key in these blocks applies when it is saved in the Control Panel, over COM
or over REST, with no restart; a value stored with --set-setting while the service runs is in
force only once the service next saves a setting, reinitialises or restarts
(sudo systemctl restart hmailserver).
17.1 The essentials
- Never become an open relay. hMailServer’s defaults are safe; the danger is a careless IP range. Check Sign-in & access → IP ranges and make sure no external range is allowed to relay without authentication.

- Auto-ban blocks an IP after repeated failed logins. On by default — leave it on. Tune it under Sign-in & access → Auto-ban.

A 6.2.10-era capture, when auto-ban was the third tab of the SSL/TLS page. Auto-ban is a page of its own now; the protocol versions stayed on Certificates & encryption → SSL/TLS.
- Require TLS. Disable plaintext logins on ports reachable from the internet: set
Require TLS for auth (Require SSL/TLS when authenticating in the range’s dialog) on the
Internet IP range, and
AUTHis not offered on a connection from that range until it has started TLS. A new installation of 6.3.4 starts that way: the installer, or on Linux the unit’s first start after--create-database, gives the database it created a self-signed certificate, STARTTLS on 25, 587, 110 and 143 and Require TLS for auth on the Internet range. An upgraded server is never changed; the setup checklist’s Passwords only over TLS item says what is left. Mail clients warn about the self-signed certificate until it is replaced (chapter 9). - Strong passwords everywhere. Use the generator in the account dialog.
17.2 Modern authentication
hMailServer supports SCRAM-SHA-256 and SCRAM-SHA-256-PLUS (with channel binding) across IMAP, POP3 and SMTP submission — the password never crosses the wire, even inside TLS. Also supported: OAuth2 bearer tokens (XOAUTH2 and OAUTHBEARER, RFC 7628) for integration with an identity provider.
Password storage defaults to PBKDF2-HMAC-SHA256, with Argon2id available. Existing weaker hashes upgrade transparently the next time the user logs in. You can enforce a floor; the scheme and the floor are both set on Sign-in & access → Authentication, card Password storage:
MinimumAcceptedHashAlgorithm=4 ; 4 = PBKDF2; refuse a logon whose stored hash is weaker. 0 = off (the default)
scrypt joined the list in 6.2.25. PreferredHashAlgorithm now takes 3 (SHA256),
4 (PBKDF2, the default), 5 (Argon2id) or 7 (scrypt); anything else is refused at read time
and reported as HM5528 while the server falls back to 4. If you ask for Argon2id on a
build whose OpenSSL has no Argon2id KDF, that is reported as HM5607 and scrypt is used
instead. The work factor is settable, and only ever re-derived upward at the next logon:
PasswordHashIterations=0 ; PBKDF2 iterations. 0 = the built-in 210,000
PasswordHashMemoryKB=0 ; Argon2id memory. 0 = the built-in 19,456 KiB
PasswordHashTimeCost=0 ; Argon2id passes. 0 = the built-in 2
Also since 6.2.24 and 6.2.25: SASL EXTERNAL (RFC 4422 Appendix A) — a client
certificate verified against the port’s CA logs on as the mailbox its addresses name, on
SMTP, IMAP and POP3; app passwords, so a device gets a credential of its own; and
OAuth2 hardening — OAuth2JwksUrl makes the provider’s JWK Set the signing-key source,
and OAuth2IntrospectionUrl adds token introspection so a revoked token stops working.
Both are off unless configured.
Since 6.3.4 the webmail and the Control Deck also take passkeys and single sign-on through an OpenID Connect or SAML 2.0 identity provider, and a new password can be checked against the published corpus of breached passwords (off by default; the Password tab of Sign-in & access → Administrative access). None of these reaches IMAP, POP3 or SMTP, which still take the password or an app password and the protections in §17.5.
SCRAM-SHA-1 is not offered, deliberately. A client that cannot do SCRAM-SHA-256 uses
PLAINorLOGINover TLS.
17.3 Run as a least-privilege account
Since 6.3.4 a new installation runs the service as NT SERVICE\hMailServer,
the service’s own virtual account: no password, and it reaches only the folders setup grants it.
Before 6.3.4 every installation ran as LocalSystem, the most privileged account on the
machine, and an upgrade keeps the account the service has. The installer’s Service
account page offers the other one either way, and a silent installation takes
/serviceaccount=virtual, /serviceaccount=localsystem or
/serviceaccount=keep. To move an upgraded service later, run the installer again, use
Move to NT SERVICE\hMailServer on Maintenance → Server limits & expert
settings (card Windows service account), or, from an elevated Command Prompt in the
Bin folder, run hMailServer.exe --confirm /GrantServiceAccess, then, only if it
ended in 0, hMailServer.exe --service-account virtual /Register; whichever way you choose, the service runs as the new account once it is restarted. Move to LocalSystem on the same card, or hMailServer.exe --service-account localsystem /Register and a restart, goes back.
Before the move, give SQL Server reached with Windows authentication on the same machine a login for the account
(CREATE LOGIN [NT SERVICE\hMailServer] FROM WINDOWS and a db_owner user), and keep
certificate and key files inside Data\Certificates or in a folder you approve.
hMailServer.exe /GrantServiceAccess grants the account the program folder’s Data, Logs,
Temp and Database folders and the folders an administrator approved, and nothing the configuration
merely names. Under the virtual account hMailServer.INI is read-only to the service, which
works on with the database’s values, and three things only LocalSystem could do are refused with a
sentence: the live update’s apply (an administrator runs the verified installer instead), the
auto-ban’s firewall rules and Utilities.MakeDependent. Because the file is read-only to it,
a change of the administrator password over COM or in the Control Panel is refused as well: run
hMailServer.exe --set-admin-password from an elevated prompt (it reads the password from
HM_ADMIN_PASSWORD or standard input, never from its command line) and restart the service. ServiceAccountName and
ServiceAccountPassword are retired: /Register takes the account from
--service-account alone.
From 6.3.6 Setup also runs hMailServer.exe /ProtectFolders at every installation and
upgrade, whatever account the service runs as: the program folder’s Data, Logs,
Temp, Database and Events and Bin\hMailServer.INI stop inheriting
what the folder above hands down — BUILTIN\Users’ Read above all, which let any account that
could sign in to the computer read every message, the logs, the event scripts and the database’s password —
and keep SYSTEM and the Administrators, the service’s grant, every deny entry and every entry of their own. On a
large message store the first run takes minutes. A folder the configuration names outside the program folder, the
backup and PST import folders and a key file with permissions of its own are checked and not changed: a
CHECK line in the setup log names who may read each and the icacls that closes it. An
account that is not an administrator and read the logs or the message store — a script, a backup program, a
monitoring agent — is given an entry of its own
(icacls "<folder>" /grant "<account>:(OI)(CI)RX"), which later runs keep; an editor that
saves hMailServer.INI by replacing the file gives it Bin’s permissions again, so run
/ProtectFolders after it.
On Linux these two settings do nothing. The packaged systemd unit already runs the
server as its own hmailserver user, never as root, and declares the rest of the
sandbox itself — ProtectSystem=strict with ReadWritePaths limited to
/var/lib/hmailserver and /var/log/hmailserver, NoNewPrivileges, a capability
bounding set of nothing but CAP_NET_BIND_SERVICE, a private /tmp, and a system-call
filter. Change any of it with systemctl edit hmailserver, because a package upgrade
replaces the unit file and a drop-in survives it. Chapter 35 has the detail.
17.4 Two-factor authentication for administrators
The Control Panel offers TOTP two-factor authentication of two kinds, both on the Two-factor tab of Sign-in & access → Administrative access. Use the card Second factor on the administrator credential (recommended): it shows a scannable QR code; scan it with Microsoft Authenticator, Google Authenticator, Authy or 1Password, confirm with a code, and every subsequent administrator logon requires one, over COM and the REST API as well (below). The card Two-factor for this Control Panel only, whose setup dialog the Connect screen’s Set up two-factor authentication… link also opens, is older and weaker: the Control Panel asks for its code only after the server has accepted the password, so it does nothing for the REST API or for scripts.

Turning the Control-Panel-only kind on or off requires the Control Panel to run as administrator on Windows — its secret is stored under
HKLM.
Since 6.2.25 the recommended second factor is the server’s, not the Control Panel’s. Enrol it
with Settings.EnrolAdministratorTOTP (and clear it with
Settings.DisableAdministratorTOTP); Application.AdministratorTOTPEnabled reports
whether it is on. Once enrolled, every administrative route asks for the code: a COM
client authenticates with Application.AuthenticateWithCode, and the REST API
wants the six digits in an X-hMailServer-OTP header. That closes the gap where a
script could bypass a check the GUI was making on its own. Since 6.3.4 a code the server has accepted is refused if it comes again, over COM and REST alike, so a script that sent the password and a code with every REST request signs in once instead (POST /api/v1/session, then the hmailsession cookie) or uses an API key; otherwise only its first request in each half minute is accepted (chapter 24). The secret itself lives in
[Security] AdministratorTotpSecret in hMailServer.INI; deleting that line with an
elevated editor and restarting the service is how you recover a locked-out administrator credential.
Under NT SERVICE\hMailServer, a new 6.3.4 installation’s account, the file is read-only
to the service, so enrolling or removing the second factor over COM, REST or the Control Panel is refused
(409 over REST): move the service to LocalSystem for the enrolment and back (§17.3).
Since 6.3.4 the administrator can also enrol a passkey for the Control Deck, which stands
in for the one-time code there, and administration can be delegated by role rather than by
sharing the administrator password: four built-in roles, serveradmin, domainadmin,
helpdesk (find an account, reset its password, unlock it, read where its mail went) and
auditor (read everything, change nothing), and roles of your own, assigned to accounts in named
domains on Accounts & domains → Administrator roles. An account with no assignment keeps
exactly the authority its admin level always gave it.
17.5 Rate limiting
MaxSubmissionsPerIPPerMinute=30 ; cap MAIL FROM per source IP per minute
MaxOutboundPerDestinationPerMinute=60 ; cap outbound per destination domain per minute
The first refuses excess submissions with 421 — it contains a compromised account. The
second defers rather than bounces, which keeps you the right side of large providers’
rate limits. Both are 0, off, until you set them; the values above are a starting point, not the
defaults.
Tarpits arrived in 6.2.25 and account lockout in 6.2.24, all off by default. The tarpits are implemented as a pause on the connection’s own timer, never a sleeping thread — which is why the two old COM properties of the same name were stubs for years.
LogonTarpitSeconds=0 ; delay before a failed logon is refused: this × the failures so far on the connection, capped at 30 s
SmtpTarpitCount=0 ; recipients an unauthenticated SMTP session may name before...
SmtpTarpitDelaySeconds=0 ; ...each further RCPT TO waits this long, capped at 30 s
Pop3LoginDelaySeconds=0
AccountLockoutThreshold=0 ; failed logons for one user name before that name is locked (0 = never)
AccountLockoutWindowMinutes=30
AccountLockoutMinutes=30
A password policy, applied when a password is set:
PasswordPolicyMinimumLength=0
PasswordPolicyRequireMixedCase=0
PasswordPolicyRequireDigit=0
PasswordPolicyRequireNonAlphanumeric=0
PasswordPolicyRejectCommon=0
PasswordPolicyHistoryCount=0 ; refuse re-use of the last N (needs schema 6019)
PasswordPolicyMaximumAgeDays=0
And if you run event scripts, ScriptAllowedObjects restricts which COM classes a
script may create. The default is *, which is what every earlier version did; set it to
a list and CreateObject of anything else fails inside the script with error 429. At * a script can
create WScript.Shell and run any command the service’s account may, which is what let a script injected through
CVE-2026-100741 run commands on the machine (a narrower list only limits what it can reach): in every release from 6.0.0 to 6.3.3 a value handed to a JScript server’s
OnClientValidatePassword, OnExternalAccountDownload or OnDeliveryFailed handler
could carry script past the escaping, and a rule’s Run function action wrote its function name into the call
unescaped, in either language.
A Windows server with event scripting on should be on 6.3.4 or later (§28.2, chapter 25). The key is
about COM, which Linux does not have: event scripts there run in JavaScript since 6.3.4, with no file,
process or network access (§28.2), so this key has no effect on Linux.
More: IP ranges, auto-ban, lockout, tarpits, sending limits, send-as and password storage on the forum.
17.6 SRS and BATV
If you forward mail, enable SRS, which rewrites the envelope sender when you forward so that forwarded mail still passes SPF at the next hop:
SRSEnabled=1
SRSSecret=<a long random string you never change>
SRSSecretmust be stable. Changing it invalidates outstanding SRS addresses, which remain valid for 21 days — so bounces for recently forwarded mail will be lost.
BATV (prvs) signs your envelope sender so you can recognise and drop backscatter.
It needs BATVEnabled=1 and a BATVSecret of its own, a different long random string:
with the secret empty the switch reads enabled and nothing is signed or checked. SRS and BATV are both on
Certificates & encryption → Transport security, card Forwarded mail & bounce protection
(SRS / BATV).
17.7 Advanced hardening
Maintenance → Server limits & expert settings (titled Advanced hardening in the build
shown below) collects the remaining controls — DPAPI secret protection, connection limits, message-store
fsync, and consistency checking — and the Windows service account card, whose three buttons since 6.3.4 grant the account its folders
and move the service between accounts (§17.3). Its
[Settings] rows apply when they are saved, not at a service restart as that build said.

Since 6.3.4 each account’s second factor and the [Settings] secrets
(SRSSecret, BATVSecret and the OAuth2 and metrics credentials among them) are sealed
under the installation’s stored-secrets key once it has one;
ProtectStoredSecretsWithDPAPI=0 keeps them as written. The Windows installer makes the key on a
new installation. An upgraded installation, and the Linux packages, seal nothing until an administrator runs
hMailServer.exe --create-stored-secrets-key from an elevated prompt (sudo hmailserver
--create-stored-secrets-key on Linux), which the setup checklist offers, and restarts the service.
Export the key at once with --export-stored-secrets-key FILE and keep the file with the
database backups but stored apart from them: a database-level backup (a dump, or a copy of the database
file) restored without it opens no second factor and no [Settings] secret.
More: Hardening a public mail server: secure defaults, checklist, sealed secrets and roles on the forum.
Part IIIMaintenance
18. Upgrading hMailServer
18.1 How upgrades work
hMailServer upgrades in place. The installer stops the service, replaces the program files, upgrades the database schema if needed, and restarts. Your mail, accounts, domains and settings are preserved.
Two things change during an upgrade:
- Program files in
C:\Program Files\hMailServer\Binare replaced. On Linux the package manager replaces/usr/bin/hmailserverand/usr/share/hmailserver, and the post-install step runshmailserver --upgrade-database(chapter 35). - The database schema is upgraded by
DBUpdater, which the installer runs automatically. Current schema version: 6108 (6.3.6 to 6.3.8; 6105 in 6.3.5, 6104 in 6.3.4, 6040 in 6.3.3, 6038 in 6.3.2, 6031 in 6.2.27 to 6.3.1, 6030 in 6.2.25 and 6.2.26, 6025 in 6.2.24, 6011 in 6.2.19 to 6.2.21, 6005 in 6.2.10 to 6.2.18, and 6001 to 6005 in the 6.0 to 6.2 releases before them).
Your data directory (the messages) is left alone. From 6.3.4 the [Settings] section of hMailServer.INI is taken into the database at the first start and is the database’s copy from then on (§18.5), and an administrator password still stored in [Security] as MD5 or salted SHA-256 is written again as PBKDF2 at the first successful administrator sign-in (§18.5).
18.2 The upgrade path
The database upgrade chain is continuous from every earlier release of this fork, and of the original hMailServer up to 5.6, on MySQL, MS SQL, PostgreSQL and SQL CE; on MySQL and MariaDB, 6.3.4 and later need MySQL 5.7.9 or MariaDB 10.2.2 or later and the ALTER privilege on the database (below). You do not need to step through intermediate versions:
5.3.x ┐
5.4.x ┤
5.5.x ┼──▶ 6.3.8 (DBUpdater walks every intermediate schema step for you)
5.6.x ┤
6.0.x ┤
6.1.x ┤
6.2.x ┤
6.3.x ┘
Not the original’s 5.7 builds of August and September 2026. They went on from schema 5708 by steps of their own (5709 to 5711), which this fork’s chain does not have; the table below says what to do instead.
| Coming from | Path | Notes |
|---|---|---|
| 6.3.7 | The live update, or run the 6.3.8 installer | No schema step: 6.3.8 needs 6108, as 6.3.7 does, so the upgrade changes no table. The live update keeps 6.3.7’s own file as its rollback image (§18.6); a service that runs as NT SERVICE\hMailServer downloads and verifies it, and an administrator installs it with Install update in the Control Panel on the server, with one Windows prompt. On Linux 6.3.7’s own update helper applies it to the generic .deb and .rpm and to both AppImages; a server installed from one of 6.3.7’s per-release packages is upgraded with its package manager and its release’s own package of 6.3.8 (§35.13). A server whose UpdateSigningIdentity, UpdateSigningIssuer and UpdateSourceRepository still name the project’s former path refuses 6.3.8: clear the three first (below) |
| 6.3.6 | The live update, or run the 6.3.8 installer | No schema step: 6.3.8 needs 6108, as 6.3.6 does, so the upgrade changes no table. The live update keeps 6.3.6’s own file as its rollback image (§18.6); a service that runs as NT SERVICE\hMailServer downloads and verifies it, and an administrator installs it with Install update in the Control Panel on the server, with one Windows prompt. On Linux 6.3.6’s own update helper applies it to the generic .deb and .rpm and to both AppImages; a server installed from one of 6.3.6’s per-release packages is upgraded with its package manager and its release’s own package of 6.3.8 (§35.13). A server whose UpdateSigningIdentity, UpdateSigningIssuer and UpdateSourceRepository still name the project’s former path refuses 6.3.8: clear the three first (below) |
| 6.3.5 | The live update, or run the 6.3.8 installer | A 6.3.5 database is at 6105 and is walked forward to 6108 in three one-way steps (below): 6106 changes no column, 6107 finishes the greylisting key and deletes the rows in which an address had been cut to 200 characters, and 6108 adds the never-ban list by reverse name and each SMTP listener’s choice of what it does with AUTH, On for every port it finds. The live update keeps 6.3.5’s own file as its rollback image, which is no way back once these steps have run (§18.6); a service that runs as NT SERVICE\hMailServer verifies the download and leaves the install to an administrator, and on Linux a backup destination inside /var/lib/hmailserver stops the unattended apply until it is moved (§35.13). A server whose UpdateSigningIdentity, UpdateSigningIssuer and UpdateSourceRepository still name the project’s former path refuses 6.3.8: clear the three first (below) |
| 6.3.4 | The live update, or run the 6.3.8 installer | A 6.3.4 database is at 6104 and is walked forward to 6108 in four steps: 6.3.5’s one, which adds a column to the quarantine’s table, then 6.3.6’s three. The live update installs 6.3.8 without a rollback image, since 6.3.4 carries no Sigstore bundle (§18.6); otherwise as from 6.3.5 |
| 6.3.0 – 6.3.3 | Run the 6.3.8 installer, by hand | A 6.3.3 database is at 6040 and is walked forward to 6108 in sixty-eight steps, a 6.3.2 one (6038) in seventy, and a 6.3.0 or 6.3.1 one (6031) in seventy-seven; on MySQL and MariaDB step 6102 rebuilds every table (below) |
| 6.2.x | Run the 6.3.8 installer | Drop-in. A 6.2.27 or 6.2.28 database is at 6031 and is walked forward to 6108 — 6.3.2’s seven steps, 6.3.3’s two, 6.3.4’s sixty-four, 6.3.5’s one and 6.3.6’s three, in place; any earlier 6.2 is walked all the way, automatically, as always |
| 6.0 / 6.1 | Run the 6.3.8 installer | Schema upgraded automatically |
| 5.6 | Run the 6.3.8 installer | Schema upgraded automatically, as is a database of the original’s 5.7 development builds up to schema 5708. Read §18.5 — the GUI has changed |
| 5.3 – 5.5 | Run the 6.3.8 installer | As above. Very old installs: back up first and test the restore |
| The original project’s releases up to 5.6 | Run the 6.3.8 installer | This fork extended the original’s schema from 5708 by its own steps, so a database of the original up to 5.6 (5601) is walked forward like this fork’s own |
| The original’s 5.7 builds of August and September 2026 (5.7.0, 5.7.1, 5.7.2) | Not the installer: move the mail over IMAP | The original went on from 5708 by steps of its own (5709 to 5711), which this fork’s chain does not have. The installers of 6.3.7 and earlier take such an installation for their own and replace its programs, so never run one of them over it; 6.3.8’s setup refuses it before it changes anything. Set this server up beside it and move the mail over IMAP (Migration.md) |
6.3.0 and 6.3.1 made no schema change — a 6.2.28 database was already at 6031 and
their installers found nothing to do. 6.3.2 moved the schema to 6038 in seven steps
(contacts, account preferences, scheduled sends and snoozes, files sent as links, message keywords,
S/MIME keys, SURBL expected results), and 6.3.3 needs 6040: 6039 widens a
domain’s relay-password column so a DPAPI envelope fits, and 6040 adds the CardDAV tables. Every
step upgrades in place and DBUpdater runs whichever ones your database still needs. Nothing 6.3.0 added is on by default: the
REST API, the Control Deck and the portal all need RestApiPort set, and up to 6.3.3 a server
upgraded without touching its settings behaved as 6.2.28 did, apart from the delivery fix and the
rebuilt webmail that 6.3.3 brought to everyone. An upgrade to 6.3.4 changes more (below). 6.3.1’s Windows installer was the first this
project Authenticode-signed (§4.1).
6.3.4 needs 6104, sixty-four steps from 6.3.3’s 6040. Two of them take time on a
large database (below), and 6.3.4 is the release that changes some things without a switch: the
settings move into the database, and §18.5 lists the rest. The last step, 6104, lets the server record
who made each fetch account from then on, so that one a person makes in the webmail is fetched only
from a public address, unless ReaderFetchPrivateHosts (off by default) is on, and one made without the
server’s authority is never given the
server’s own OAuth2 token.
6.3.5 needs 6105, one step from 6.3.4’s 6104, on all four backends: it adds
hm_quarantine.quarantinehelo, the HELO name a held message arrived with, which stays empty for every
message held before the upgrade. From 6.3.3 or older the upgrade runs 6.3.4’s sixty-four steps first, and
what this section says about them applies. What 6.3.5 changes without a switch is in §18.5.
6.3.6 needs 6108, three steps from 6.3.5’s 6105, on all four backends, each one way, so take
the backup first. 6106 changes no column: it gives every external account’s days to keep
messages the one meaning both fetchers now read (-1 deletes a message on the remote server once it has been
collected, 0 leaves every message there, n deletes it n days after collection), translating what is
stored so that every account goes on doing what it did or stops deleting, and none starts (§18.5).
6107 finishes the greylisting key that schema 6072 began: it keys every triplet written before 6072
in the server, deletes the rows in which an address had been cut to the old columns’ 200 characters, so such a
sender is greylisted once more, and makes the key unique; on a large hm_greylisting_triplets the keying
runs before the step’s transaction, committed as it goes, so an upgrade stopped part-way continues where it
stopped. 6108 adds the never-ban list by reverse name and, for each SMTP listener, what it does with
AUTH, written On for every port an upgrade finds, so no listener answers differently on the morning of
the upgrade. From 6.3.4 the upgrade runs 6.3.5’s step first, and from 6.3.3 or older 6.3.4’s sixty-four, and
what this section says about them applies. What 6.3.6 changes without a switch is in §18.5.
6.3.7 needs 6108 too, as 6.3.6 does: it adds no schema step, so an upgrade from 6.3.6 changes no table, and the live update’s rollback to 6.3.6, should 6.3.7 not start, meets no newer schema. From 6.3.5 or older the upgrade runs 6.3.6’s three steps first, and what this section says about them applies. What 6.3.7 changes without a switch is in its section of the CHANGELOG (§18.5).
6.3.8 needs 6108 too, as 6.3.6 and 6.3.7 do: it adds no schema step, so an upgrade from either changes no table, and the live update’s rollback, should 6.3.8 not start, meets no newer schema. What 6.3.8 changes without a switch is in its section of the CHANGELOG (§18.5).
Upgrading from anything older than 6.3.1? Go straight to 6.3.8. 6.3.2 could not upgrade a database whose schema was older than its own: its guard for a refused database covered the whole Settings object, which DBUpdater reads before it runs the first script, so every upgrade from 5.x or 6.2.x stopped with The server has not loaded its configuration and left the database untouched. 6.3.3 and later upgrade such a database in one go, and nothing in a database was changed by a failed 6.3.2 attempt.
On 6.3.1 or 6.3.2 and using the Control Panel’s live update? The first real update, 6.3.1 to 6.3.2, failed with the installer’s exit code 5 because the Control Panel that started it kept its own files open. 6.3.3 fixes that, but the helper that runs a live update is the one already installed, so update by hand once: close the Control Panel, run the installer.
A 6.3.4, 6.3.5, 6.3.6 or 6.3.7 server can install 6.3.8 by its live update. 6.3.8 is signed with Sigstore by the project’s GitLab pipeline at its path in the group
hmailserver, which every server from 6.3.4 on trusts (§4.1). On Windows, a service that runs as LocalSystem installs it itself; one that runs asNT SERVICE\hMailServer, as a new installation from 6.3.4 on does, downloads and verifies it, reportsHM7463and leaves the install to an administrator: on 6.3.6 or 6.3.7, Install update in the Control Panel on the server installs it with one Windows prompt; on 6.3.4 or 6.3.5 the administrator runs the installer from the release, not the copy the server downloaded, once, and from then on Install update installs the next release. A 6.3.7 or 6.3.6 server keeps its own file as its rollback image, and a 6.3.5 server 6.3.5’s, which is no way back once the upgrade’s schema steps have run, since 6.3.5 refuses a database they have moved to 6108 (§18.6); there is none for 6.3.4, which carries no Sigstore bundle. From 6.3.5 or 6.3.4 the backup taken before the upgrade is the way back, so take it first. The update is applied by the version already installed, so two things of 6.3.4 and 6.3.5 still hold for it: on Linux a backup destination inside/var/lib/hmailserverfails the backup before an unattended apply (HM5014), and the apply is refused until the destination is moved; and a server whoseUpdateSigningIdentity,UpdateSigningIssuerandUpdateSourceRepositorystill name the project’s former path, set so that an older server could install 6.3.5, trusts that path alone and refuses 6.3.8. Clear the three first: on 6.3.4 and 6.3.5 in the Control Panel, withDELETE /api/v1/settings/ini/<name>or on Linux withhmailserver --set-setting <name>=, not in the file, which 6.3.4 and 6.3.5 put back; on 6.3.6 or 6.3.7, which read them fromhMailServer.inialone, by deleting their lines from[Settings]with an editor run as an administrator, or on Linux withsudo hmailserver --set-setting <name>=, and restarting the service. On Linux, install 6.3.8 on 6.3.4 or 6.3.5 with the package manager rather than by the live update: the update is applied by the apply step of the version it replaces, which is among what 6.3.6’s security fixes replace (chapter 25), and until a server’s helper is that of 6.3.6 or later,systemctl disable --now hmailserver-update.pathturns the apply off while the check and the download go on. A server from 6.2.28 to 6.3.3 trusts only the GitHub signer and is upgraded by hand: runhMailServer-6.3.8-x64.exe; on Linux, install the package withaptordnf, as for one installed from 6.3.3’s AArch64 packages or AppImages, which 6.3.6 to 6.3.8 carry again (§35.2). A server from 6.2.28 to 6.3.3 also needs one line before its update check works again (§18.7).
Four steps in the 6011 → 6031 chain need a maintenance window on a large database.
6012 → 6013 and 6014 → 6015 each add a column to hm_messages and then update every message
row to fill it, so they take longer the larger the store. 6024 → 6025 rewrites
hm_messages.messageflags from tinyint to smallint — a table
rewrite on your largest table, holding locks, on MS SQL, SQL Server Compact and
MySQL/MariaDB; PostgreSQL is unaffected. 6029 → 6030 adds seventeen FOREIGN KEY
constraints with ON DELETE CASCADE, deleting the orphan rows they would otherwise
refuse, and reads every child table once. Plan all four like an index build. After them, 6.3.2’s
6035 → 6036 adds a column with a default to hm_messages on all four backends; how long that
takes depends on the backend and its version, so on a large store time it on a copy first.
Two steps in 6.3.4’s part of the chain take time too. 6101 adds two columns to
hm_messages, the table with a row for every stored message: the account an authenticated
session submitted a message as, and the domains that passed DMARC and SPF when it arrived. On a large
store it is not instant on every backend. On MySQL and MariaDB, 6102 rebuilds every table
as utf8mb4 under utf8mb4_general_ci, InnoDB and ROW_FORMAT=DYNAMIC, so
that an emoji or a CJK Extension B character is stored instead of refused. It takes as long as copying
the database, with free disk space of the order of the largest table; it needs MySQL 5.7.9 or MariaDB
10.2.2 or later, as 6041 and 6101 before it do, so on an older server the upgrade stops at 6041 with
error 1071 and changes nothing; it needs the ALTER privilege on the database, not only on its tables; and two rows the old
collation told apart and the new one does not stop it with error 1062, which names the index and the
value — remove or rename one and run the upgrade again, since every statement of the step can run
twice. SQL Server, SQL Server Compact and PostgreSQL only move the version at 6102. One more
6.3.4 step, 6072, widens the greylisting table’s two address columns and indexes it, which takes
time only where that table is very large.
A known defect in the 6029 → 6030 step, unfixed at 6.3.1 and fixed in 6.3.2. That step’s orphan sweep runs children before parents. Three of the parent tables are themselves pruned by those deletes, and each one’s children are cleaned earlier, against a parent that has not yet lost its rows — so pruning an orphaned account, fetch account or distribution list re-orphans rows nothing revisits, and the foreign-key constraint that follows is refused.
The scope, stated fully. It fires only on an upgrade from a schema below 6030 on a database that already holds orphaned rows; a database without them upgrades correctly, and a fresh installation is never on this path. It affects all four database backends. When it does fire it fails loudly, in the database engine’s own words, and rolls back rather than doing anything quietly. No installation has reported hitting it. The fix is a reordering of seventeen delete statements; it shipped in 6.3.2, together with a gate that executes a schema upgrade on a seeded database on every push — which is why it could not ship earlier.
This chapter’s readers cross that step. A 6.2.19 to 6.2.21 installation sits at schema 6011, below 6030. Take the backup at §18.3 step 1 before you start. If the upgrade does stop here it has rolled back, so the database is where it was; the error names the constraint it could not add, and the orphaned rows behind it have to go before the upgrade will pass.
The upgrade is one-way: an older server refuses a newer database rather than misreading it. That is what makes §18.3 step 1 non-negotiable.
On Linux
A first installation is the package, the [Database] section of the configuration file and
three commands; there is no wizard:
sudo apt install ./hmailserver_6.3.8_amd64.deb # Ubuntu 26.04; any other release: its own .deb, or with dnf its own .rpm (§35.2)
sudoedit /etc/hmailserver/hMailServer.ini # [Database]: Type, Server, Database, Username, Password, Port; and, before the first start, [Settings] RestApiPort and RestApiBindAddress
sudo hmailserver --set-admin-password # as root: the file is root-owned, and this writes it
sudo -u hmailserver hmailserver --create-database
sudo systemctl enable --now hmailserver
A package upgrade replaces the binary, runs hmailserver --upgrade-database as the
service user from its post-install step when a database is configured, and restarts the unit if it was
running. A failed schema step is reported but does not fail the package, so read what the upgrade
printed; sudo -u hmailserver hmailserver --upgrade-database is safe to run again by hand.
Chapter 35 is the full walk-through.
Moving a Windows installation to Linux is not a supported migration, and the 6.3 releases do not claim it is. The database schema is the same on both and the message store’s on-disk format is the same on both, so the pieces are there — what is missing is a tested path. Treat a move as a new installation plus a mailbox-level migration (§33.8), not as a restore.
18.3 Before you upgrade
- Back up. Database, data directory and
hMailServer.INI. See chapter 15. - Write down two numbers. The server version, on the Control Panel’s About page
(before 6.2, Help → About in the classic Administrator), and the schema version, which
Monitoring & troubleshooting → Server status shows; when the server will not start,
select value from hm_dbversionreads it. If you need support, those are the first two questions. - Pick a quiet time. From 6.2.24 or later the service is down for a minute or two, except on a
very large store, where 6036 and 6101 add columns to
hm_messages; from 6.2.21 or earlier on a large store it is longer, and on MySQL or MariaDB step 6102 takes as long as copying the database (§18.2). Senders will retry, so nothing is lost, but users will notice. - Coming from 6.3.3 or earlier, restart the service once after your last change to a
[Settings]key. The first start of 6.3.4 or later takes a line changed after the old server last started — in the file, or on a 6.3.3 Control Panel running on the server, which writes the file directly — for an edit, reports it asHM5804and puts it back. IfHM5804appears after the upgrade, set the named keys again in the Control Panel, or on Linux withhmailserver --set-setting.
18.4 Doing it
- Download
hMailServer-6.3.8-x64.exefrom updates.progressiverobot.com/download/hMailServer-x64.exe, which leads to the newest stable release’s installer, and check it (§4.1). - Run it as administrator.
- The installer detects the existing installation and offers to upgrade. Accept.
- Keep your existing database settings when asked.
- On the Service account page (new in 6.3.4), keeping the account the service runs as is
the default. The other choice moves a LocalSystem service to
NT SERVICE\hMailServer, or a service under any other account back to LocalSystem (§17.3). A move is made after the database is upgraded, and the installer asks before it grants the service a folder or key file the configuration names outside the program folder. With SQL Server reached through Windows authentication, give the virtual account a login there before you move to it. - Let it finish, then confirm the service is running and that the ERROR log
(
ERROR_hmailserver_<date>.login theLogsfolder) has nothing from the time of the upgrade; an empty ERROR log is the signal you want.HM5804there names a[Settings]line that was put back (§18.3). - Open the Control Panel and check About shows 6.3.8.
- Send a test message in and out.
From 6.3.4 a stop drains first. That serves the upgrades from 6.3.4 on, 6.3.4, 6.3.5, 6.3.6 or 6.3.7 to 6.3.8 among them; an upgrade
from 6.3.3 or older stops the service as it always did. The SMTP, IMAP, POP3 and ManageSieve
listeners close at the socket, so senders and mail clients retry, and the delivery queue pauses, while
the webmail, CalDAV and CardDAV, the REST API and the metrics listener stay up and /readyz
answers 503. ShutdownDrainSeconds is how long the stop then waits for what is in flight; at
its default of 0 nothing is waited for, so set it on a production server (30 is a reasonable figure).
Where something other than this installer replaces the binaries, UpgradeOnStart=1 under
[Database] in hMailServer.ini (a file key, off by default) has the service apply the
schema steps itself as it starts; it backs nothing up first, and belongs on one node at a time.
18.5 Things that changed, and what to do about them
Coming from 6.3.7, what changes on the upgrade to 6.3.8 without a switch is in 6.3.8’s section of the CHANGELOG, under What changes on upgrade without a switch, and its Before you upgrade names the changes most likely to need something done (chapter 25); coming from 6.3.6, 6.3.7’s section, below it, adds what 6.3.7 changed. From 6.3.5 or older, what follows applies as well.
Coming from 6.3.5, forty-six things change on the upgrade to 6.3.6 without a switch, listed
first in 6.3.6’s section of the
CHANGELOG
and in chapter 25. Read before upgrading: mail can stop flowing if your resolver strips DNSSEC. With
DaneEnforcementEnabled and DnssecValidationEnabled on, as they are by default, an MX or TLSA
lookup that cannot tell — no answer, SERVFAIL, a signed zone’s answer without its signatures or
its proof that no records exist — no longer counts as “unsigned”: the host is not used, and a delivery with
no host left is deferred with 4.7.0. A server whose resolver cannot answer DNSSEC queries, or behind a
firewall that drops DNS answers longer than 512 octets, now defers its mail to most domains, and says so in each
deferral and in the application log; make the resolver answer DNSSEC queries (any current public or ISP resolver
does), or set DaneEnforcementEnabled to 0. A private name under a signed public domain, answered by an
internal DNS server, is delivered to through a route. A host that the destination’s TLSA records or MTA-STS policy
require TLS of, and that offers no STARTTLS, is deferred where the message bounced at the first attempt;
a host with only DANE-TA records whose certificate does not meet them is deferred where it was delivered to as if no
record were published.
Then what asks something of you. External accounts: days to keep messages means
one thing for POP3 and IMAP from 6.3.6 (-1 deletes a message once collected, 0 keeps every message, n deletes
it after n days; from 6.2.25 to 6.3.5 the IMAP fetcher read 0 and -1 the other way round, and the Control
Panel, the Deck, the webmail and the REST API described that reversed meaning), and schema step 6106 keeps every
account doing what it did or stops it deleting: afterwards look at your IMAP external accounts (one that was emptying
its remote INBOX now shows -1 and goes on until you set 0) and your POP3 accounts at -1 (they go on deleting until you
set 0), and change a script that writes the value, or an OnExternalAccountDownload script that answers 1
for an IMAP account, to the one meaning (chapter 29, FetchAccount). Scripts outside the service that use
COM, on a server with an administrator password: the eight helpers of Utilities that do work, and a
Message or FetchAccount the program creates itself, answer only a program that has signed in
on an Application it still holds; Attachments.Add and Attachment.SaveAs answer
the server administrator alone; and mail a program signed in as an account queues must be from an address that account
may send as (chapters 30 and 31). Windows file permissions: Setup runs hMailServer.exe /ProtectFolders
at every installation and upgrade, whatever account the service runs as, so the Data, Logs,
Temp, Database and Events folders and hMailServer.INI stop
inheriting BUILTIN\Users’ Read and keep SYSTEM, the Administrators and the service (on a large
message store the first run takes minutes); a script, a backup program or a monitoring agent that read the logs or the
message store as an ordinary account needs an entry of its own
(icacls "<folder>" /grant "<account>:(OI)(CI)RX"), which later runs keep, and a Control
Panel not run as administrator says Could not read the file on its live log page (§17.3). An editor that saves
hMailServer.INI by replacing the file gives it Bin’s permissions again: run
hMailServer.exe /ProtectFolders after it. The administrator’s commands: every command line of
hMailServer.exe but the service’s own writes nothing to the error log or the Application log and says
what it did on its own output, so a script that read the error log after /Register or
--upgrade-database reads the output instead; the store commands and the object-store copies stop, naming
it, when the data folder holds a junction, mount point or symbolic link that the administrators do not alone control;
on Linux such a command run as root first becomes the service’s account. Installing by script: the
installer exits 10 when a step after its files did not complete and the database is as the earlier
version left it, and 11 once its database step has changed the database, where it exited 0;
/GrantServiceAccess that cannot read the database ends in 7 unless an administrator confirmed. The
live update’s trust is read from hMailServer.ini alone, and COM and REST refuse a change to the
five settings (chapter 21, Self-update), so a signer of your own goes in the file, in every server’s file
where several share a database; and a server whose UpdateSigningIdentity, UpdateSigningIssuer
and UpdateSourceRepository still name the project’s former path refuses 6.3.6 until the three are
cleared (§18.2). Behind a reverse proxy: the REST listener’s plain loopback port answers only a request
whose Host is localhost, an IP address, the server’s host name or the host of an address
configured as the one the webmail is reached at from outside, and refuses any other with 421; a proxy that forwards
the browser’s own Host needs that name set first, and must pass the Control Deck’s page through
unchanged (§24.2). Port 25: every SMTP listener stays On and still takes AUTH, so nobody is banned
on the morning of the upgrade; the setup checklist’s new item No password on port 25 says so until you
move clients that sign in on 25 to 587 and set port 25 to Honeypot or Off, on which the first AUTH of a
session bans the address (a new installation’s port 25 is a Honeypot). URI block lists: the SURBL test
asks each list about every domain a message links to, up to 200 — up to 256 DNS queries per list for a message
with many links, where it was 16 — and a message whose links name more than 200 domains scores the new setting
SurblTooManyDomainsScore, 5 by default; a list’s error answers are never a listing, so a list of your
own that answers 127.0.0.1 for a listing is made to answer another address, such as 127.0.0.2;
and a sender list’s URL entry written as a reversed address is written as the address itself. ManageSieve
(off by default) is held to deadlines, serves one connection per address before sign-in, and ends a signed-in session
after ten idle lines in a row or two minutes of waiting in all. IP addresses written in text are read the same
way on Windows as on Linux — four decimal parts, or IPv6 as RFC 4291 writes it — so a value kept in an
older form (192.168.1, a leading zero read as octal, an address with a port) is read the new way from the
first start: an API key’s source restriction so written refuses the key from every address, a REST, web-services or
SNMP bind address so written leaves that listener stopped, and an AutoBanNeverBan entry so written
protects nothing (HM6524). Webmail S/MIME: a correspondent’s certificate is kept for
encrypting replies only when its chain is trusted, under the message’s From address; review the certificates kept
before the upgrade on the webmail’s Security page. Windows, account retention: from 6.2.25 to 6.3.5 an
account saved through a webmail’s password or out-of-office plugin could have its retention overwritten; check the
retention of the accounts whose holders use such a plugin. The packaging Compose file runs PostgreSQL 18: a
deployment that takes the new docker-compose.yml moves its PostgreSQL 16 data once, with a dump and a
load. A monitor that matches “The response carried no HTTP headers.” needs the new sentence, which
says who ended the connection.
The rest need nothing: the MX-record test and the SURBL test cut a host name by the Public Suffix
List, so a sender at a public suffix itself with no MX (github.io, co.uk) now fails the
MX-record test, and the SURBL test reads a link as a browser does; backups leave out the live update’s folder, and
a destination inside the data directory works; tlds.txt is removed and error 4335 retired; the
project’s address is gitlab.com/hmailserver/hmailserver; the live update keeps its outcome and a verified installer,
and tries a failed check again once; on Linux the backup and event logs are UTF-8; more web requests go through
HttpProxy when it is set; a calendar message delivered by mail changes a stored event only from a sender
the server verified; an account or the administrator holds at most fifty REST browser sessions, and a pending single
sign-on or passkey sign-in is carried by the browser, sealed; what the auto-ban put below the server is lifted after a
stop too; the Control Panel’s text can be selected and copied; event scripts on Linux run in QuickJS 2026-06-04;
and scripting off runs no script engine.
Coming from 6.3.4, five things change on the upgrade to 6.3.5 without a switch, listed
first in 6.3.5’s section of the
CHANGELOG
and in chapter 25: the Control Panel’s sidebar is in a new order, with a new Set up this server
group and two groups renamed (§5.3); the quarantine shows the HELO name and groups by the sender’s domain;
the webmail’s message list is one Tab stop, and Enter, Escape and the formatted signature’s keys do what
they say; an invitation sent the way Outlook sends one is shown as one; and GET /api/v1/quarantine
gives three more fields, with new members over COM. A UI test that finds a sidebar group by its old
navgroup- or navrail- id must be changed.
Coming from 6.3.3 or earlier, twenty-four things change on the upgrade to 6.3.4 without a switch. The full list is the first part of 6.3.4’s section in the CHANGELOG; these are the ones an administrator meets first:
- The database is the settings store, and
[Settings]inhMailServer.iniis its copy (chapter 21). The section is taken into the database at the first start. A line edited after the old server last started, including one edited to prepare the upgrade, is taken for an edit and put back (HM5804in the error log), so make such a change after upgrading. From then on change settings in the Control Panel, over COM or REST, or withhmailserver --set-settingon Linux — not in the file, where an edited line is put back and deleting a line no longer returns a setting to its default. - Most saved settings take effect at once: of the file’s keys, 211 at once, 28 at a reinitialise and 2 at a restart, and every interface lists the ones still waiting.
- SPF is RFC 7208. 127.0.0.0/8 and
::1no longer pass before any policy is read, so a content filter that re-injects mail should authenticate, or connect from an IP range with anti-spam off. - The audit trail and alerts are on. Nothing is mailed until
AlertRecipientandAlertSenderAddressare set. Compromised-account detection starts in watch mode and holds nothing until it is set to enforce. - A destination that asks the server to slow down is paused for
OutboundBackoffMinutes(2, doubling toOutboundBackoffMaxMinutes, 60); 0 turns it off. - IMAP answers as the RFCs say where Dovecot’s imaptest found it did not: a mailbox that
does not exist is refused
NOrather thanBAD, every body fetch without.PEEKsets\Seen, SORT and THREAD order messages as the RFCs do, and an APPEND stores the literal byte for byte. - The database connection is encrypted whenever the database server offers TLS: MySQL
and MariaDB without checking the certificate, SQL Server through MSOLEDBSQL 18 or SQL Native Client for
the whole session, and through SQLOLEDB only the login unless
TlsModeisrequireorverify-full; PostgreSQL is unchanged ([Database] TlsMode, chapter 21). - The administrator password is hashed again at the first successful administrator sign-in; a browser session made under the old hash ends once. The installer now writes it as PBKDF2 itself (§4.2).
- A password stored before 6.3.4 with a character above U+00FF is refused, and the application log names the account: its hash was made over NUL bytes on Windows or question marks on Linux, so any other password of that form opened it. Set such a password again, and soon. ASCII and Latin-1 passwords sign in as before.
- A one-time code is taken once. A script that sends the administrator’s password
and a code with every REST request should sign in once (
POST /api/v1/session) or use an API key; otherwise only its first request in each half minute is accepted. - The outbound relay sends its OAuth2 token over TLS only. A route or the SMTP relay
whose host is on
OutboundOAuth2Hostsand whose connection security is none fails its deliveries with a temporary450 4.7.0andHM5912until it is set to TLS or STARTTLS required. - A domain named like one of the server’s own directories —
protected,quarantine,filesand the rest — is refused for a new domain or a rename; one that already exists is named asHM7597at every start: rename it. - A COM change to a route, an incoming relay, a DNS black list, a certificate or another shared
configuration object takes effect at
Save(). A script that set one and relied on it without saving must now save. - A rule’s Run function action names a plain function. A name that is not ASCII letters,
digits,
_and$, not starting with a digit, is refused asHM5018each time the rule fires, in VBScript and JScript alike; on Linux such a name runs nothing and the ERROR log has a Script Error line. On a JScript server every value a handler is given now arrives exactly as it was, backslashes included. - A restore lists the archive before it extracts anything and refuses an unsafe one whole; mail labelled ISO-8859-1 is decoded as windows-1252.
- The service account. An upgrade keeps the account the service runs as; only a new
installation defaults to
NT SERVICE\hMailServer(§17.3). - The listeners and the secrets. Only a new installation is given a self-signed
certificate, STARTTLS on 25, 587, 110 and 143 and TLS required for authentication from the Internet IP
range, and, from the Windows installer, a stored-secrets key. An upgrade keeps its listeners as they
were, and nothing is sealed until an administrator runs
--create-stored-secrets-key, which the setup checklist offers. Export the key at once with--export-stored-secrets-key, import it on every other server that shares the database, restart each server, since the next start seals what is stored as written, and keep the file with the database backups but apart from them: a database backup restored without it opens none of the sealed secrets.
Coming from 5.x or early 6.x, the administration GUI is different.
The classic hMailServer Administrator (hMailAdmin.exe) was retired in 6.2 and its
source removed in 6.2.10. It is replaced by the Control Panel (hMailCP.exe), which
does everything the old tool did — domains, accounts, aliases, distribution lists,
routes, rules, IP ranges, TCP/IP ports and SSL bindings, server settings, status, queue,
logs, backup, certificates, scripts and public folders — plus a live dashboard, complete
settings coverage and optional two-factor authentication.
- The navigation no longer mirrors the old Administrator’s tree: its pages sit in eight task-based groups (chapter 20). A path from old notes still finds its page in the Ctrl+K palette, so Settings > Advanced > IP ranges opens Sign-in & access → IP ranges.
- Your TOTP secret carries over — the Control Panel uses the same one.
- The Control Panel needs the .NET 10 Desktop Runtime, which the installer installs silently if missing.
- If that runtime install fails, you have no mail server, not just no GUI.
DBSetup,DBSetupQuickandDBUpdaterare .NET applications too, the upgrade has to move the schema, and the server refuses to start against an old one. Recovery: install the .NET 10 Desktop Runtime (x64) by hand from https://dotnet.microsoft.com/download/dotnet/10.0, runDBUpdater.exefrom the installation’sBinfolder, then start the service. Since 6.3.4hMailServer.exe --upgrade-database, run fromBinwith the service stopped, walks the same chain without .NET or the administrator password, and exits 0 when it is done.
The PHP WebAdmin is gone. It stored the administrator password in plaintext in a PHP session and needed DCOM opened up for the web server account. It was 2008-era code. If you used it to administer the server remotely, use the Control Panel instead — it connects to a remote host directly, which is both simpler and safer.
“Administrative tools” means something narrower now. With both old front-ends gone, the component’s only job is registering the COM type library so scripts on another machine can administer this server. If you do not script remotely, you do not need it.
18.6 Rolling back
If an upgrade goes wrong:
- Uninstall 6.3.8.
- Restore the database from your backup — this is the essential step after an upgrade from 6.3.5 or older, because the schema was upgraded and an older server will refuse a newer schema; an upgrade from 6.3.6 or 6.3.7 changed no table, so either meets no newer schema (§18.2).
- Going back to 6.3.3 or older, put the pre-upgrade
hMailServer.INIback beside it if the newer version had sealed the stored secrets: the older version’s installer would otherwise hand Windows the sealed service-account password, and the service would not start. Without such a copy, setServiceAccountPassword(and any other of those secrets you use) again with the older version’s Control Panel once it runs, and runhMailServer.exe /Registeragain. An older build ignores the[SettingsStore]and[SettingsOverride]sections, so move a value only[SettingsOverride]supplied into[Settings]. - Install your previous version.
The data directory does not normally need restoring: an upgrade does not rewrite message files. Mail that arrived after the backup is on disk with no row in the restored database, and is not seen.
This is why §18.3 step 1 is not optional. Without a database backup there is no rollback from an upgrade that ran schema steps, as every upgrade from 6.3.5 or older does.
A live update from 6.3.4 has no automatic rollback. Before it installs, the updater fetches the installer of the version that is running and verifies it against its Sigstore bundle, so that it can put that version back if the new one does not start. 6.3.4 has no bundle, so a 6.3.4 server installs 6.3.5, 6.3.6, 6.3.7 or 6.3.8 without one: the application log says no rollback image for hMailServer 6.3.4 could be had, and if the new version does not start, the update’s outcome says no rollback image was available, and the way back is the list above, with 6.3.4’s installer run by hand. 6.3.5, 6.3.6 and 6.3.7 carry a bundle, so a live update from any of them to 6.3.8 fetches the running version’s as its rollback image. From 6.3.5 that image is no way back once the upgrade’s schema steps have run: 6.3.5’s updater runs it whenever the service is not running again in time, without looking at the database, and 6.3.5 refuses a database the steps have moved to 6108, so the backup taken before the upgrade is the way back (the list above).
More: Upgrading from 5.x, 6.2 or 6.3: the 24 changes without a switch, and rolling back on the forum.
18.7 Staying up to date
- Watch the downloads page: every release is published there with its notes.
- Or let the server watch. Since 6.2.28 it can check for releases itself. Nothing
happens at all until you turn it on — no request, no identifier, no counts:
UpdateCheckEnabled=0is the default. Switch it on from the Updates card of Monitoring & troubleshooting → API & monitoring (since 6.3.4 a save there applies at once) and the release feed is read at startup and everyUpdateCheckHours(24) and reported on Monitoring & troubleshooting → Server status. Fetching (UpdateAutoDownload) and applying are two further opt-in steps, andUpdateBackupBeforeApply=1means an unattended apply does not happen without a configured backup destination and a backup that succeeded. - What is applied is verified against the release’s Sigstore bundle — chain to
Fulcio, this project’s release-workflow identity and issuer, the signature over the
file, and inclusion in the transparency log — and that check cannot be turned off. If
the service does not come back within
UpdateServiceWaitSeconds(180),hMailServer.Updater.exereinstalls the previous version, when it could fetch and verify that version’s installer before the install; on 6.3.4, which has no bundle, it cannot (§18.6). - 6.3.4 is the exception: it carries no Sigstore bundle, so no live update installs it —
a server of any version that is offered it says it cannot be verified and downloads nothing. Install it
by hand (§18.4). A server from 6.2.28 to 6.3.3 still looks for releases at the project’s former GitHub repository, github.com/Progressiverobot/hmailserver, which
has answered 404 since 18 September 2026: add
UpdateFeedUrl=https://updates.progressiverobot.com/releases.jsonunder[Settings]in itshMailServer.iniand restart the service, and its check works again. 6.3.4 reads that feed by itself, and trusts both GitHub’s signer, for the releases up to 6.3.3, and this project’s GitLab pipeline, which signs the releases from 6.3.5 on: 6.3.5 under the project’s former path,https://gitlab.com/Progressiverobot/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.5, and the releases from 6.3.6 under the group path the project moved to on 29 September 2026,https://gitlab.com/hmailserver/hmailserver//.gitlab-ci.yml@refs/tags/v<version>; a server from 6.3.4 on trusts both paths. A server left on 6.3.3 or older trusts only GitHub’s, so its live update refuses every release from 6.3.5 on: install the current release by hand, once, and its live update takes the releases after that; until then keepUpdateAutoDownloadoff on such a server. Use theUpdateFeedUrlvalue exactly: without.jsonit names a directory and the check reports The update feed answered HTTP 302. On 6.3.4 and later the line can go, though leaving it changes nothing: clear it in the Control Panel, withDELETE /api/v1/settings/ini/UpdateFeedUrl, or on Linux withhmailserver --set-setting UpdateFeedUrl=, not in the file, where a changed line is put back (HM5804). - Under
NT SERVICE\hMailServer, the account a new 6.3.4 installation runs as, the apply is refused with a sentence: the installer takes an administrator, who downloads it from the release page, checks its Authenticode signature and runs it (§17.3). - Authenticode is an extra check, and from 6.3.1 it can pass.
UpdateRequireAuthenticode=1makes the server demand a Windows-trusted Authenticode signature on the downloaded installer as well as the Sigstore bundle. It has existed since 6.2.28 and, until 6.3.1, refused every release of this project, because none carried one. It is off by default, and it is Windows-only: on Linux there is no Authenticode to check, and the server reports that rather than passing. Up to 6.3.3 updating a Linux server is the package manager’s job; from 6.3.4 its live update applies the package through a root helper (§35.13). - Every release lists exactly what changed and why, and carries SBOMs. Release assets are signed with Sigstore from 6.2.19 to 6.3.3 and again from 6.3.5, by the project’s GitLab pipeline; 6.3.4 carries an Authenticode-signed installer, an SBOM beside each installer and package, and SHA-256 sums signed with the release tags’ SSH key (§4.1), and no bundle.
- Security fixes are called out explicitly in the release notes.
- Read the roadmap —
Roadmap.mdin the repository states what is planned, what is deliberately refused, and why.
More: The update check and the live update: feed, verification, window and rollback on the forum.
19. Troubleshooting
19.1 Start here, always
- Monitoring & troubleshooting → Diagnostics — the built-in self-test.
- Monitoring & troubleshooting → Live logs — watch what happens as you reproduce the problem. It reads the log folder, so it works only in a Control Panel running on the server itself.
- Monitoring & troubleshooting → Delivery queue — is mail stuck, and what is the error?
Chapter 32 is the companion lookup reference: what individual database, DNS and SMTP error messages mean, and what to do about each.
19.2 “I can’t receive mail from outside”
Work through in order:
| Check | How |
|---|---|
| Is the service running? | Get-Service hMailServer |
| Is port 25 open on Windows Firewall? | §4.4 |
| Is port 25 forwarded by your router/cloud firewall? | Your firewall’s own configuration |
| Does your ISP block port 25? | Ask them. Very common on consumer lines |
| Is your MX record right? | Monitoring & troubleshooting → MX query, or mxtoolbox |
| Can the world reach you? | From outside your network: Test-NetConnection mail.yourcompany.com -Port 25 |
| Is the domain in hMailServer and Active? | Accounts & domains → Domains |
19.3 “I can’t send mail”
| Symptom | Likely cause |
|---|---|
| Client says “relay denied” | Client is not authenticating, or is using port 25 instead of 587 |
| Mail sits in the queue | Outbound port 25 blocked, or DNS resolution failing. Use a smart host (§14.1) |
| Remote server rejects you | Missing PTR, missing SPF, or your IP is blacklisted |
Mail to one destination waits in the queue, and the log says Delivery deferred by this server's own shaping, not by a remote server. |
Since 6.3.4, a destination that answers with a 421, or with one of the providers’ rate-limit replies, is given a pause: deliveries to it alone wait for OutboundBackoffMinutes (2 by default, doubling to OutboundBackoffMaxMinutes, 60), and a wait this server chose does not spend the message’s retries until the message has been queued for longer than its retry schedule spans, so a destination that never stops asking still ends in a bounce. Through a smart host, the relay is that one destination. It is on after an upgrade; OutboundBackoffMinutes set to 0 turns it off |
A new 6.3.4 installation refuses a client’s sign-in from another machine: 530 A SSL/TLS-connection is required for authentication. |
The secure defaults of a new installation: the Internet IP range requires TLS for authentication, and ports 25, 587, 110 and 143 offer STARTTLS with a self-signed certificate. Set the client to STARTTLS on 587, and do not clear the range’s requirement to suit an old client. Clients warn about the self-signed certificate until you replace it with an ACME or your own certificate (chapter 9). This computer’s own clients, and an upgraded server, are not affected |
More: Troubleshooting by symptom: no mail in, no mail out, a service that will not start on the forum.
19.4 “My mail goes to spam”
Almost always DNS. In order of impact:
- PTR record — ask your ISP. Missing PTR is the single biggest cause.
- SPF — publish it, and make sure it lists your actual sending server.
- DKIM — generate the key on the DKIM tab of the domain’s Properties (Accounts & domains → Domains), which prints the exact TXT record to publish; Set up this server → DNS records checks it against live DNS.
- DMARC — publish it, even as
p=none. - Blacklists — check your IP at mxtoolbox. New IPs sometimes arrive pre-tainted; most lists have a delisting form.
- Test it — https://www.mail-tester.com. Fix everything it flags.
19.5 “The Control Panel won’t connect”
| Check | Notes |
|---|---|
| Is the service running? | The Control Panel talks to the running service |
| Right password? | The administration password, not a mailbox password. If it is lost, set a new one on the server: hMailServer.exe --set-admin-password from an elevated prompt, with the password in HM_ADMIN_PASSWORD or on standard input and never as an argument (a pipe from Windows PowerShell 5.1 carries only ASCII and turns every other character into ?, so use the variable for a password with any other character), or sudo hmailserver --set-admin-password on Linux. The running service keeps the old one until it is restarted or reinitialised. After an upgrade to 6.3.4, an administrator password stored with a character above U+00FF is refused until it is set again, and the application log says so |
| Connecting remotely? | The COM API must be reachable and DCOM permitted |
| A change of the administrator password is refused with a sentence about the file | Since 6.3.4 a new installation runs the service as NT SERVICE\hMailServer, to which hMailServer.INI is read-only, so the Control Panel cannot write the password there. Set it with hMailServer.exe --set-admin-password from an elevated prompt, then restart the service. Enrolling the administrator’s second factor is refused the same way: move the service to LocalSystem for the enrolment and back |
| Just restarted the service? | The Control Panel reconnects itself; give it a few seconds |
19.6 “Database too old” / “Authentication failed” after install
Both were real defects in 6.2.4 and are fixed in 6.2.5 and later. If you see either on a fresh install, you are running an old build — upgrade to 6.3.8.
19.7 Where the files are
| What | Default location |
|---|---|
| Program | C:\Program Files\hMailServer\Bin |
| Configuration | C:\Program Files\hMailServer\Bin\hMailServer.INI |
| Messages | C:\Program Files\hMailServer\Data |
| Logs | C:\Program Files\hMailServer\Logs |
| Built-in database | C:\Program Files\hMailServer\Database |
| ACME certificates | C:\Program Files\hMailServer\Data\ACME |
On Linux the packaged layout is:
| What | Default location |
|---|---|
| Program | /usr/bin/hmailserver, with /usr/share/hmailserver (including DBScripts); since 6.3.4 also /usr/bin/hmctl, the command-line client over the REST API |
| Configuration | /etc/hmailserver/hMailServer.ini (mode 0640, root:hmailserver) |
| Messages | /var/lib/hmailserver |
| Logs | /var/log/hmailserver |
| Temporary files | /var/lib/hmailserver/temp |
| Event scripts (JavaScript, since 6.3.4) | /var/lib/hmailserver/events/EventHandlers.js |
| Machine secret key | /var/lib/hmailserver/.hmailserver-secret-key (mode 0600). Since 6.3.4 it also seals the stored-secrets key, once one has been made, which is kept in hMailServer.ini ([Security] StoredSecretsKey): back the two up together, or export the key with sudo hmailserver --export-stored-secrets-key FILE |
The server looks for hMailServer.ini beside its own executable first and falls back to
the packaged path; --config <file> overrides both. Chapter 35 covers the rest.
Since 6.3.4 the database is the store for the [Settings] keys, and the file’s [Settings] section is a copy of it. Change a setting in the Control Panel, over REST (PUT /api/v1/settings/ini/{name}) or COM (Settings.SetIniSetting), with hmctl ini set, or on Linux with sudo hmailserver --set-setting NAME=VALUE (the Control Deck edits only a few of these keys), not by editing the file: a line edited there is ignored in favour of the stored value, reported in the error log as HM5804 and, where the service may write the file, put back. The file keeps its other sections, [Database], [Directories] and [Security] among them, and PasswordPepper, the one setting still set in it. A new 6.3.4 installation on Windows runs the service as NT SERVICE\hMailServer, to which hMailServer.INI is read-only.
19.8 Getting help
- Source and releases: https://gitlab.com/hmailserver/hmailserver, mirrored on GitHub at github.com/RainbowWizard-coder/hMailServer (the source and every release; issues and merge requests stay on GitLab).
- Bug reports and questions: the project’s GitLab issues, with templates for a bug, a feature and a question, or, without a GitLab account, an email to the project’s Service Desk address, contact-project+hmailserver-hmailserver-86730559-issue-@incoming.gitlab.com, which GitLab files as a confidential issue that only the maintainers see; their replies reach you by email. Security reports go only by the routes in SECURITY.md. Managed support: contact Progressive Robot.
- The forum: https://www.hmailserver.co.uk/, where this fork’s releases are announced, answers questions about running it; General support is the place for one that is not a defect. General hMailServer questions not specific to this fork are often answered on the original project’s long-running hMailServer forum.
- A security vulnerability: never in public. Report it privately, as the project’s security policy says: a confidential issue on GitLab — check that it is still marked confidential before you submit it, and choose the Security template — or an email to contact-project+hmailserver-hmailserver-86730559-issue-@incoming.gitlab.com, which GitLab files as a confidential issue, so no GitLab account is needed.
When reporting a problem, include your version (the Control Panel shows it; on Linux, hmailserver --version), the platform and the database backend, the relevant log extract and the ERROR_hmailserver_<date>.log for the same window, and what you have already checked. Remove passwords, keys and real email addresses from anything you paste: an issue is public. More: Read this before you post: what a support request needs on the forum.
Part IVReference
20. Every Control Panel page
This is the Windows desktop application, page by page. The Control Deck — the
administration page the REST listener serves at / — covers a large part of the same
ground in a browser, and is the only graphical administration a Linux server has; see
chapter 36 for what it does. At 6.3.5 a Deck view reaches 498 of the 508 properties this program writes, and
a few things remain this program’s alone, among them editing the event scripts, which are a file on the
server’s disk, and the Active Directory pickers.
6.3.5 has 66 pages: the ten marked (new in 6.3.4) arrived in 6.3.4, and the Webmail page, marked (new in 6.3.5), in 6.3.5. 6.3.5 renamed one page (External setup is the Setup checklist) and two groups (TLS & certificates is Certificates & encryption, Access & abuse protection is Sign-in & access), moved the Setup checklist, Setting profiles and DNS records into a new group, Set up this server, and put each group’s pages in the order a new administrator needs them. The table follows the sidebar: Welcome and Dashboard, then the nine groups — Set up this server (3 pages), Accounts & domains (4), Mail flow & delivery (7), Connections & protocols (5), Certificates & encryption (5), Spam & virus filtering (14), Sign-in & access (6), Monitoring & troubleshooting (14) and Maintenance (5) — then About. A path from the old tree, such as “Settings > Maintenance > Performance”, still opens its page from the Ctrl+K palette; “Settings > Security > Auto-ban & SSL/TLS”, the one page split in two, opens Auto-ban. The old names External setup, TLS & certificates and Access & abuse protection are aliases the palette finds too. Since 6.3.4 an account that signs in with an administrator role sees only the pages its role may use (a help-desk role, Welcome and About); the server refuses the rest whatever a page shows.
Where it writes decides when a change applies. What the table calls COM is stored in
the database. Since 6.3.4 so are the [Settings] keys (schema 6042), with the [Settings]
section of hMailServer.INI kept as a copy: the Control Panel writes them over COM only, even on the
server itself, and a line edited in the file instead is reported as HM5804 and, where the service
may write the file, put back. Under each such field a line says when it applies: Applies on
save (211 keys), After reinitialize (28: a listener’s port or bind address, a TLS
context, a work queue’s size), applied by Apply now on the pending bar at the foot of the window, which
stops and starts every service in place, with no restart of the Windows service and no elevation, or
After a restart
(2). [Database], [Directories], [LDAP], [SendingLimits],
[SendingLimitsOverrides] and hMailServerApiKeys.ini stay in their files. Connected
to a 6.3.3 server, the Control Panel marks every such field After a restart, as that server
needs.
| Page | What it is for | Where it writes | When a change applies |
|---|---|---|---|
| Welcome | Twelve starting points written as outcomes, each one click from the page that does it, then six area tiles. Since 6.3.4 also a button that makes a throwaway sample domain, example.test, and removes it again |
Actions only (the sample domain) | At once |
| Dashboard | Live counters and charts for processed mail, spam, viruses and sessions — Live, 24 hours, 7 days or 30 days — and a “needs attention” summary of what still needs doing outside the server. Connecting opens this page; the state of the service is on Server status | — | — |
| Set up this server → Setup checklist | Everything this server needs done outside it — DNS records, key files, trusted lists — and the state of each, headed since 6.3.4 by the first-hour checklist the server judges for itself. Since 6.3.5 its items are numbered in the order a new installation meets them, the first that needs action marked as the next step, and it heads the new Set up this server group; it was External setup, under Monitoring & troubleshooting | Read-only | — |
| Set up this server → Setting profiles (new in 6.3.4) | The four setting profiles — office, gateway, relay, sender — each with its difference from this server, applied with a box to tick where an entry closes a listener or weakens a check, put back, or the shipped settings kept | COM Settings.GetProfile, ApplyProfile, RevertProfile |
As each setting’s own mark says |
| Set up this server → DNS records | The SPF, DKIM, DMARC, MTA-STS and TLS-RPT records each domain should publish, with a live check | Read-only | — |
| Accounts & domains → Domains (on screen “Domains & accounts”) | Domains, accounts, aliases, distribution lists, domain aliases, DKIM. Since 6.3.4 the domain dialog has fourteen tabs and the account dialog fifteen | COM Domains |
At once |
| Accounts & domains → Groups | Account groups, used for public-folder permissions; Members… edits who is in each | COM Settings.Groups |
At once |
| Accounts & domains → Public folders | Shared IMAP folders and their permissions | COM Settings.PublicFolders |
At once |
| Accounts & domains → Administrator roles (new in 6.3.4) | Who may administer what: the four built-in roles (server administrator, domain administrator, help desk, auditor), custom roles, and the accounts that hold each in which domains | The database, through COM | At once |
| Mail flow & delivery → Delivery of e-mail | Retry schedule and delivery limits (Delivery tab), the smart-host relayer and its OAuth2 (Relayer tab), the rule loop limit (Rules tab), PIPELINING, CHUNKING and hard-linked local copies; since 6.3.4 the Bounces and the suppression list card. The host name is on Protocols (SMTP tab) and the text of bounces on Server messages | COM Settings and [Settings] keys |
At once |
| Mail flow & delivery → Routes | Per-domain delivery overrides | COM Settings.Routes |
At once |
| Mail flow & delivery → Remote domains (new in 6.3.4) | Per remote domain: the TLS this server demands in each direction, the size and connections it will attempt, recipient verification for a domain it backs up, and gateway encryption to a partner’s key | COM Settings.RemoteDomainPolicies |
At once |
| Mail flow & delivery → Incoming relays | Upstream gateways (spam filters, load balancers) whose IP addresses should not count as the connecting client in anti-spam host checks | COM Settings.IncomingRelays |
At once |
| Mail flow & delivery → Rules (on screen “Global rules”) | Server-wide mail rules, evaluated top to bottom | COM Application.Rules |
At once |
| Mail flow & delivery → Server messages | The text of bounces and system messages: a fixed set, whose text you may edit but not the list | COM Settings.ServerMessages |
At once |
| Mail flow & delivery → Server sendout | Send a message to every account (maintenance notices) | Action only | At once |
| Connections & protocols → TCP/IP ports | Listening ports, their SSL bindings, from 6.3.6 each SMTP listener’s SMTP authentication (On, Off or Honeypot: Off and Honeypot answer 502 and ban the address at the first AUTH of a session; a new installation’s port 25 is a Honeypot, and an upgraded server’s ports are all On), and a live “Listening” badge (the badge on the local server only) |
COM Settings.TCPIPPorts |
Stored at once; a listener added or changed takes effect at the next reinitialise or service restart |
| Connections & protocols → Protocols | SMTP, IMAP and POP3 on or off and their behaviour, the server’s host name (SMTP tab), and every idle timeout; tabs Services, SMTP, IMAP, POP3 and Timeouts | COM Settings and [Settings] keys |
At once |
| Connections & protocols → Web services & autoconfiguration (on screen “Web services & client autoconfiguration”) | The HTTP/HTTPS listener for Thunderbird and Outlook autoconfiguration and MTA-STS policy hosting, and the CalDAV and CardDAV redirects | [Settings] keys |
At once, except the listener’s ports, bind address and certificate files, which wait for a reinitialise (Apply now) |
| Connections & protocols → Webmail (new in 6.3.5) | How people sign in to the webmail, the self-service portal and the Control Deck, how long a session lasts and what a person may do, on the tabs Sign-in (passkeys, OpenID Connect, SAML, account recovery), Browser sessions, What people may do, Encryption, Notifications and Import | Mostly the REST API, which is off as shipped (RestApiPort 0); while it is off, each tab that needs it says so |
As the line under each field says |
| Connections & protocols → DNS resolver | Name servers, the Windows DNS cache, when DNSBL checks run | [Settings] keys |
At once; an answer already cached stays until its TTL runs out |
| Certificates & encryption → SSL certificates | Certificates available to TLS ports, with parse, expiry and key-match state | COM Settings.SSLCertificates |
At once |
| Certificates & encryption → Certificates (ACME) (on screen “Automatic certificates (ACME)”) | Automatic Let’s Encrypt issuance and renewal | [Settings] keys |
At once, except the switch (AcmeEnabled) and AcmeCertificateDirectory, which wait for a reinitialise (Apply now) |
| Certificates & encryption → Transport encryption overview | Read-only judgement of TLS versions, ciphers, ports and certificates together | Read-only | — |
| Certificates & encryption → SSL/TLS | TLS versions, ciphers, key-exchange groups, session resumption | COM Settings and [Settings] keys |
The TLS 1.3 cipher suites, the key-exchange groups and the session-resumption settings after a reinitialise (Apply now); the listeners build their TLS context when the servers start |
| Certificates & encryption → Transport security | MTA-STS, DANE, DNSSEC, ARC, TLS-RPT, DMARC rua, SRS and BATV | [Settings] keys |
At once |
| Spam & virus filtering → Spam filtering overview | Read-only judgement of the whole spam pipeline, each row linking to its page | Read-only | — |
| Spam & virus filtering → Quarantine | Messages held as suspected spam: release, delete, expire. It holds nothing until QuarantineEnabled is on (Anti-spam settings) |
Actions only | At once |
| Spam & virus filtering → Anti-spam settings (on screen “Anti-spam”) | Thresholds, quarantine, SPF, DKIM, DMARC and ARC, host checks, greylisting, SpamAssassin, the external filter engine, the recipient tarpit | COM Settings.AntiSpam and [Settings] keys |
At once |
| Spam & virus filtering → SURBL servers | URL blocklists checked against message bodies | COM collection | At once |
| Spam & virus filtering → DNS blacklists | IP blocklists checked against connecting servers | COM collection | At once |
| Spam & virus filtering → White list (on screen “Anti-spam white list”) | Senders exempt from spam checking | COM collection | At once |
| Spam & virus filtering → Blocked senders | Claimed sender addresses and domains refused with a score (100 by default, above the delete threshold) | COM collection | At once |
| Spam & virus filtering → Allow and block list (new in 6.3.4) | Addresses and domains every account wants or does not: the server’s list, below each account’s own list and its domain’s; an entry may be a URL | COM AntiSpam.SenderList |
At once |
| Spam & virus filtering → Greylisting white list | Senders exempt from greylisting only | COM collection | At once |
| Spam & virus filtering → Greylisting bypass list (new in 6.3.4) | Domains and HELO suffixes let past greylisting only when the message passes SPF, DKIM or DMARC as the entry requires | COM AntiSpam.GreyListingBypassEntries |
At once |
| Spam & virus filtering → Domain white and black list (new in 6.3.4) | Sending domains this server has decided about, by envelope domain or HELO suffix: white skips greylisting and the score’s refusals, black is refused at MAIL FROM |
COM AntiSpam.DomainPolicyList |
At once |
| Spam & virus filtering → Virus scanning overview | Read-only judgement of whether the configured scanners can actually run | Read-only | — |
| Spam & virus filtering → Anti-virus settings (on screen “Anti-virus”) | ClamAV, ClamWin or a custom scanner; action on detection; what to do when a scanner cannot run; the switch for attachment blocking | COM Settings.AntiVirus and [Settings] keys |
At once |
| Spam & virus filtering → Blocked attachments | Attachment-name wildcards (*.exe) whose attachments are stripped — only while attachment blocking is on in Anti-virus settings |
COM collection | At once |
| Sign-in & access → Authentication | OAuth2/XOAUTH2 token authentication, password hashing and pepper, ports without AUTH | [Settings] keys, except the password pepper, which is kept in hMailServer.INI only; under NT SERVICE\hMailServer the page cannot write it, and it is set in the file with an elevated editor |
At once, except a pepper set in the file, which applies when the service is restarted |
| Sign-in & access → Administrative access | Administrator password, mailbox password policy, two-factor authentication (tabs Password and Two-factor) | COM and [Settings] keys; the administrator password and the server-enforced second factor in hMailServer.INI [Security], the Control-Panel-only second factor under HKLM\SOFTWARE\hMailServer. Under NT SERVICE\hMailServer that file is read-only to the service, so the password card and enrolling or removing the server-enforced second factor are refused: hMailServer.exe --set-admin-password, from an elevated prompt, sets the password instead; enrolling needs the service moved to LocalSystem for the enrolment and back, and the factor is removed by deleting its line with an elevated editor and restarting the service |
At once, except a password set with --set-admin-password, which the running service takes only when it is restarted or reinitialised |
| Sign-in & access → IP ranges | Who may connect, authenticate and relay, and whether a password needs TLS | COM Settings.SecurityRanges |
At once |
| Sign-in & access → Auto-ban | Failed-logon banning, per-name lockout, logon tarpit | COM Settings and [Settings] keys |
At once |
| Sign-in & access → Directory authentication (LDAP) | Authenticate mailbox passwords against a directory | hMailServer.INI [LDAP], on the server itself |
Within two seconds — no restart |
| Sign-in & access → Directory synchronisation | Provision mailboxes from the directory, preview then apply | hMailServer.INI [LDAP], and COM actions |
Preview and Apply run inside the server when asked |
| Monitoring & troubleshooting → Server status | Version, state (Pause and Resume), uptime, database, session and message counters, configuration warnings, and the update check with its Download and Install buttons. A server from 6.3.4 on installs a later release by its live update; on Windows a service running as NT SERVICE\hMailServer leaves the install to an administrator (§18.2) |
Actions only | At once |
| Monitoring & troubleshooting → Delivery queue | Outbound mail waiting; view the source, retry or delete individual messages | Actions only | At once |
| Monitoring & troubleshooting → Live logs | Real-time colour-coded log tail — local server only | — | — |
| Monitoring & troubleshooting → Logging | Log level, format, retention, JSON output, message trace; since 6.3.4 a syslog (RFC 5424) card | COM Logging.* and [Settings] keys |
At once, except the syslog card, whose settings are taken when the syslog sink starts: at the next reinitialise or service start |
| Monitoring & troubleshooting → Diagnosing stalled mail | Which half stalled (accepting or delivering), debug logging on and off, and the settings that bound a session, each with a button to its page | COM Logging.* |
At once |
| Monitoring & troubleshooting → Message trace | What happened to one message, by sender or recipient. It records nothing until MessageTraceEnabled is on (the Logging page) |
Actions only | At once |
| Monitoring & troubleshooting → Diagnostics | Built-in self-test, plus the last message-store consistency scan | — | — |
| Monitoring & troubleshooting → MX query | Look up any domain’s MX records using the server’s own resolver | — | — |
| Monitoring & troubleshooting → Suppression list (new in 6.3.4) | Addresses a domain no longer sends to because mail to them bounced, visible and removable by hand. Bounces are read only once bounce processing, off by default, is on (Delivery of e-mail, card Bounces and the suppression list) | The database, through COM | At once |
| Monitoring & troubleshooting → Audit trail (new in 6.3.4) | Who changed what, when, over which interface and from where — every administrative change one row of a hash chain — and Put back, which returns a setting to the value it had (server administrator only) | Read-only, plus Put back | At once |
| Monitoring & troubleshooting → Journaling and dual delivery (new in 6.3.4) | A copy of a domain’s mail, or of one account’s, sent to an address, as an envelope report with the message attached or as the message itself (server administrator only) | The database (hm_journalrules), through COM |
At once |
| Monitoring & troubleshooting → Discovery (new in 6.3.4) | The eDiscovery cases, read-only: each case’s searches, holds and exports, and its lines of the audit trail. Discovery has no COM interface, so the page reads the REST listener: it needs RestApiPort set, a certificate it can check for an address beyond loopback, and an administrator without a second factor; a case is opened, searched, held and exported on the Control Deck |
Nothing: it reads over REST | — |
| Monitoring & troubleshooting → API & monitoring | The REST API and the browser Control Deck, Prometheus and OpenTelemetry monitoring and metric history, the Windows Event Log, ManageSieve, the shutdown drain, the live-update settings and the HttpProxy forward proxy. Since 6.3.4 the update feed field is labelled Update feed URL, and the default feed is https://updates.progressiverobot.com |
[Settings] keys |
At once, except the ports, bind addresses and certificate files of the REST, metrics and ManageSieve listeners and the metrics credentials, which wait for a reinitialise (Apply now) |
| Monitoring & troubleshooting → REST API keys | Scoped, expiring credentials for the REST API — local server only | hMailServerApiKeys.ini |
At once: the server re-reads the file on every request |
| Maintenance → Backup & restore | Backup contents, destination, schedule, verification and restore | COM Settings.Backup and [Settings] keys |
At once |
| Maintenance → Performance | Caching, connection and thread tuning, message indexing, database connections; since 6.3.4 a Message store tab — storage compression, the smallest attachment held once, and what the store saves | COM, [Settings] keys and hMailServer.INI [Database] |
At once, except the external-fetch thread count and DatabaseStatementTimeout (after a reinitialise) and the Database tab’s [Database] rows, edited on the server itself (after a service restart); the Message store switch applies to the next message stored |
| Maintenance → Advanced | Default domain, IPv6 preference, mirroring and archiving, disk-space limits, and the scripting engine (Scripting tab): on or off, and the language, since 6.3.4 VBScript, JScript or JavaScript (the Linux builds’ engine, which a Windows server refuses at load) | COM and [Settings] keys |
At once; the script engine reloads on Save |
| Maintenance → Event scripts | The VBScript/JScript event handlers themselves — local server only | The script file on disk | “Save & reload” reloads the engine |
| Maintenance → Server limits & expert settings | DPAPI secrets, fsync, consistency checking, proxies, sending limits, ceilings; since 6.3.4 the Windows service account card — what the service runs as (a new 6.3.4 installation runs as NT SERVICE\hMailServer, an upgraded one keeps its account), and Grant access, Move to NT SERVICE\hMailServer and Move to LocalSystem, each after the elevation prompt |
[Settings] keys; [SendingLimits] and [SendingLimitsOverrides] in hMailServer.INI |
At once; the sending limits are re-read within seconds; a changed service account takes effect when the service is restarted |
| About | Version, licence, build information; since 6.3.4 also in the command bar’s administrator menu | — | — |
Pages not pictured above
The remaining pages are shown here for completeness. The screenshots were captured from a 6.2.10 build, before the redesign 6.3.4 carries: the sidebar they show is the older tree, and the page pictured as “Advanced and scripting” is now titled “Advanced”.
Connections & protocols → Protocols — five tabs (Services, SMTP, IMAP, POP3, Timeouts) enabling or disabling the three protocol servers and tuning their behaviour:

Maintenance → Performance — caching, connection and thread tuning on the Threads, Cache, Indexing and Database tabs, and since 6.3.4 a fifth, Message store:

Maintenance → Advanced — default domain, mirroring and archiving, disk-space limits, the scripting engine:

Mail flow & delivery → Server messages — the text of bounces and system messages:

Accounts & domains → Groups — account groups, used for public-folder permissions; today each group also has a Members… button, which the picture, taken before the member editor existed, does not show:

Mail flow & delivery → Server sendout — send a message to every account, for maintenance notices:

About — version, licence and build information:

21. Settings reference (hMailServer.INI)
Nearly every [Settings] key here is also editable in the Control Panel. Since 6.3.4 each of their
fields there is marked Applies on save, After reinitialize or After a
restart, and a bar at the foot of the window says how many saved settings still wait, with
Apply now, which is a reinitialise rather than a service restart. Over REST,
PUT /api/v1/settings/ini/{name} writes any of them but the five of the live update’s trust, which from 6.3.6 are read from the file alone (Self-update, below), with the administrator password only (an
API key is refused); of the [Settings] keys the Control Deck edits only the four its Settings view carries,
SmtpTarpitCount, SmtpTarpitDelaySeconds, UseLanguage and
RewriteEnvelopeFromWhenForwarding (chapters 24 and 36). Unless a section
says otherwise, the keys below live in [Settings], and the values shown are the
defaults.
The file is Bin\hMailServer.INI on Windows and
/etc/hmailserver/hMailServer.ini on Linux. The server looks for the file beside its own
executable first and falls back to the packaged path; --config <file> overrides both.
Since 6.2.19 these values are also stored in the database, so they can be
administered remotely through the Control Panel. Since 6.3.4 the database is the settings
store, and [Settings] in the file is its copy. At the first start after the upgrade the
section’s keys are taken into the database, with nothing to run by hand; a line edited after the old
server last started, including one edited to prepare the upgrade, is taken for an edit and not applied (and put back where the service may write the file), so
make such a change after upgrading. From then on a line edited in the file is ignored in favour of the
stored value, named in the error log as HM5804 and, where the service may write the file, put
back, and deleting a line no longer returns a setting to its default: DELETE /api/v1/settings/ini/{name},
hmctl ini delete <name> or COM Settings.DeleteIniSetting(name) does. The Control Panel
cannot: clearing a box there stores an empty value, which a number reads as 0 rather than as its default.
Change a setting in the Control
Panel, over COM (Settings.SetIniSetting), over REST (PUT /api/v1/settings/ini/{name})
or, on Linux, with sudo hmailserver --set-setting NAME=VALUE, which reaches a running service at
its next save, reinitialise (sudo systemctl reload hmailserver) or restart. Under a service
account that cannot write the file — NT SERVICE\hMailServer, a new Windows installation’s,
and the Linux package’s hmailserver user, whose file is 0640 root:hmailserver —
the copy is not refreshed and the database’s values are used. A [SettingsOverride] section
is the deliberate way round a database that cannot be read, and is named as HM5807 at every
start. No new key is added to [Settings] from 6.3.4 on: a new setting is a row in the
database. Every key now says when a save applies it. Of the keys below, the REST, metrics,
web-services and ManageSieve listeners’ ports, bind addresses, credentials and certificates, the six
TLS context keys from TlsKeyExchangeGroups to TlsTicketKeyRotationSeconds,
AcmeEnabled and DatabaseStatementTimeout wait for a reinitialise, and
ServiceAccountName and ServiceAccountPassword for a restart; every other
[Settings] key below applies when it is saved. The [Database] and [Security]
keys, read from the file, wait for a restart.
More: Settings: what is not stock, when a save takes effect, and the three levels on the forum.
Transport security and authentication
MtaStsEnabled=1 ; honour recipient MTA-STS policies when sending
DaneEnforcementEnabled=1 ; honour recipient DANE/TLSA records when sending
DnssecValidationEnabled=1 ; validate DNSSEC for DANE and SPF/DKIM/DMARC lookups
DnssecTrustAnchors= ; override root trust anchors ("tag alg digesttype hex;...")
ArcSealingEnabled=0 ; add ARC seals when forwarding (uses the domain's DKIM key)
TlsRptFromAddress= ; sender for daily TLS-RPT reports (empty = disabled)
TlsRptOrganizationName=hMailServer
TlsKeyExchangeGroups=X25519MLKEM768:SecP256r1MLKEM768:X25519:secp384r1:secp256r1
TlsCipherSuites13= ; empty = OpenSSL's own defaults
TlsSessionTicketsEnabled=1
TlsSessionCacheSize=0 ; 0 = OpenSSL's default, negative = off
TlsSessionTimeoutSeconds=0
TlsTicketKeyRotationSeconds=0 ; 0 = OpenSSL's single, never-rotated key
The default key-exchange list puts post-quantum hybrids first. An OpenSSL older than those hybrids — the one Debian and Ubuntu ship, for instance — rejects the list and the fallback handles it. Since 6.3.0 that is reported once rather than once per listener and once per outbound delivery, which is the difference between a note and hundreds of medium errors an hour.
Automatic certificates (Let’s Encrypt)
AcmeEnabled=0 ; issue and renew certificates automatically
AcmeContactEmail= ; expiry notices from the CA
AcmeDomains= ; comma-separated host names for the certificate
AcmeDirectoryUrl=https://acme-v02.api.letsencrypt.org/directory
AcmeHttpPort=80 ; port for http-01 challenges
AcmeReuseKey=1 ; keep the same key across renewals (keeps TLSA records valid)
Web services (MTA-STS hosting, client autoconfiguration)
WebServicesHttpPort=0 ; 80 to enable
WebServicesHttpsPort=0 ; 443 to enable (uses the ACME certificate if none is set)
WebServicesBindAddress=0.0.0.0
MtaStsHostingEnabled=1 ; serve https://mta-sts.<domain>/.well-known/mta-sts.txt
MtaStsPolicyMode=enforce ; enforce, testing or none
MtaStsPolicyMaxAge=604800
MtaStsPolicyMx= ; override mx patterns (default: the domain's live MX records)
AutoconfigEnabled=1 ; Thunderbird autoconfig + Outlook autodiscover
AutoconfigClientHost= ; host name clients connect to (default: the server's host name)
Per domain, point mta-sts.<domain>, autoconfig.<domain> and autodiscover.<domain>
at this server, and include them in AcmeDomains for HTTPS.
Administration and monitoring
RestApiPort=0 ; REST API + the Control Deck at / and the portal at /portal
RestApiBindAddress=127.0.0.1 ; TLS is required unless bound to 127.0.0.1, localhost or ::1
RestApiCertificateFile= ; PEM; falls back to the ACME certificate
RestApiPrivateKeyFile=
MetricsServerPort=0 ; Prometheus metrics endpoint (/metrics) + health probes
MetricsServerBindAddress=127.0.0.1
MetricsServerAuthToken= ; bearer token for /metrics (with no credential set, /metrics answers
; 503 on any bind but loopback; the probes still answer)
MetricsServerAuthUsername= ; or HTTP Basic
MetricsServerAuthPassword=
MetricsServerCertificateFile= ; TLS on the metrics listener
MetricsServerPrivateKeyFile=
MetricsHistoryDays=7 ; per-minute metric samples kept in hm_metricsamples
MetricsPerDomainEnabled=0
OtelMetricsEndpoint= ; OTLP metrics (separate from OtelEndpoint)
OtelLogsEndpoint= ; OTLP logs
OtelMetricsInterval=60
HttpProxy= ; host:port for the server's web client: the update feed and downloads, JWKS,
; introspection and the like; ACME, the outbound XOAUTH2 token and inbound
; webhooks go direct
CalDavRedirectUrl= ; /.well-known/caldav redirects here, to another server's CalDAV;
; empty = this server's own CalDAV at /dav/ (6.3.4), on the web-services listener
CardDavRedirectUrl= ; the same for /.well-known/carddav (this server's own CardDAV since 6.3.3)
IMAPCompressionEnabled=1 ; advertise IMAP COMPRESS=DEFLATE (RFC 4978)
WindowsEventLogEnabled=1 ; critical/high errors to the Windows Event Log — syslog on Linux
WindowsEventLogLevel=2 ; 1=Critical, 2=+High, 3=+Medium, 4=everything
DatabaseStatementTimeout=30 ; seconds a statement may run (0 = none), set at connect on PostgreSQL and
; MySQL; MS SQL and SQL CE use ADO's own 30 unless an upgrade or maintenance
; script ran on the connection
SlowQueryLogMilliseconds=0 ; log queries slower than this (0 = off)
LogDeleteDays=0 ; prune date-stamped logs older than N days (0 = keep all)
ShutdownDrainSeconds=0 ; on stop, wait up to N seconds for sessions to finish
MessageStoreFsync=0 ; force each message to physical disk before acknowledging
MessageStoreConsistencyCheck=0; periodically cross-check message rows against files
ManageSieveServerPort=0 ; ManageSieve (RFC 5804); standard port 4190
ManageSieveServerBindAddress=127.0.0.1
JsonLogging=0 ; write logs as JSON lines
Bounded waits and timeouts
New in 6.2.17–6.2.18. Every wait that can hold a thread from a shared pool now has a ceiling, and the log names the one that was hit. All values are in seconds.
FinalizationTimeout=240 ; ceiling on accepting one message after end-of-data; past it
; the sender gets 451 and retries (0 = off)
SAMaxTimeout=90 ; SpamAssassin ceiling; past it the message is accepted unscanned
DBConnectionAcquireTimeout=60 ; maximum wait for a pooled database connection
DNSQueryTimeout=10 ; per DNS query
ScriptTimeout=60 ; per event-script invocation
ExternalProcessTimeout=300 ; external helper processes (command-line scanners and the like)
ClientSessionCeiling=1800 ; absolute lifetime of an outbound delivery session — distinct
; from the idle timeout, which re-arms on every byte received
Secret protection and least privilege
ProtectStoredSecretsWithDPAPI=1 ; machine-scoped DPAPI for reversible stored secrets
ServiceAccountName= ; retired in 6.3.4: the installer, or /Register --service-account, chooses
ServiceAccountPassword= ; retired in 6.3.4
On Linux the same key means a key file, not DPAPI. 1 selects
<DataFolder>/.hmailserver-secret-key— 32 random bytes at mode 0600, written once and never rewritten, each secret AES-256-GCM under it and stored asLINUX1:<base64>. Back it up with the database (chapter 15).ServiceAccountNameandServiceAccountPassworddo nothing there: the systemd unit runs the server as its ownhmailserveruser.
Deliverability and SMTP standards
SRSEnabled=0 ; Sender Rewriting Scheme for forwarded mail
SRSSecret= ; HMAC secret for SRS (required when SRSEnabled=1)
MaxSubmissionsPerIPPerMinute=0 ; cap MAIL FROM per source IP per minute (0 = off)
MaxOutboundPerDestinationPerMinute=0 ; cap outbound per destination domain (0 = off)
OutboundPipelining=1 ; pipeline the envelope where the remote advertises PIPELINING
OutboundChunking=1 ; send the message as one BDAT chunk where it advertises CHUNKING
SmtpAuthenticatedSenderCheck=0 ; 1 = a signed-in account may send only as itself, an alias of it, an
; address it verified or a mailbox that granted it the post right
DkimOversignHeaders= ; header fields to over-sign
DkimAcceptSha1=0 ; restore rsa-sha1 signing and verification (RFC 8301 forbids it)
DmarcTreeWalkEnabled=1
DmarcRptFromAddress= ; DMARC aggregate reports (empty = disabled)
DmarcRptOrganizationName=hMailServer
DmarcRptSchemaVersion=1
SpfVoidLookupLimit=2
SMTPProxyProtocolEnabled=0 ; accept PROXY protocol from a trusted reverse proxy
SMTPProxyProtocolTrustedIPs=
SMTPXClientEnabled=0 ; accept XCLIENT from a trusted front end
SMTPXClientTrustedIPs=
MinimumFreeDiskSpaceMB=100 ; below this, mail is refused with a temporary error
DiskSpaceWarningThresholdMB=1024
MinimumFreeDiskSpaceMBchanges behaviour on upgrade without a switch. It is new since 6.2.21 and defaults to 100: below that floor the server refuses mail with a temporary error rather than filling the disk. Set it to 0 to restore the older behaviour.
PIPELINING, SMTPUTF8/EAI (RFC 6531/6532), ENHANCEDSTATUSCODES (RFC 2034), DSN
(RFC 3461), CHUNKING/BDAT and BINARYMIME (RFC 3030) and 8BITMIME are advertised
automatically and need no configuration. Legacy HELO sessions keep the classic
non-enhanced replies.
Anti-spam, anti-virus and filtering
FilterHookUrl= ; external HTTP filter (rspamd and the like); empty = off
FilterHookTimeoutSeconds=10
FilterHookRejectScore=100
FilterHookMaxMessageSizeKB=10240
FilterHookFailClosed=0
SpamAssassinUser= ; whose spamd preferences to apply
SpamAssassinUserFromRecipient=0
SpamAssassinLearnOnMove=0 ; moving to Junk teaches spam, out of it teaches ham
QuarantineEnabled=0
QuarantineRetentionDays=30
AVFailAction=0 ; what to do when the scanner cannot be reached
AVFailRetryMinutes=15
AVFailMaxHolds=16
Passwords, lockout and tarpits
PasswordHashIterations=0 ; 0 = the built-in work factor; re-derived upward only
PasswordHashMemoryKB=0
PasswordHashTimeCost=0
PasswordPolicyMinimumLength=0
PasswordPolicyRequireMixedCase=0
PasswordPolicyRequireDigit=0
PasswordPolicyRequireNonAlphanumeric=0
PasswordPolicyRejectCommon=0
PasswordPolicyHistoryCount=0
PasswordPolicyMaximumAgeDays=0
AccountLockoutThreshold=0
AccountLockoutWindowMinutes=30
AccountLockoutMinutes=30
LogonTarpitSeconds=0
SmtpTarpitCount=0
SmtpTarpitDelaySeconds=0
Pop3LoginDelaySeconds=0
ScriptAllowedObjects=* ; which COM classes an event script may create (Windows only)
PreferredHashAlgorithm takes 3 (SHA256), 4 (PBKDF2, the default), 5 (Argon2id) or
7 (scrypt, new in 6.2.25). Anything else is refused at read time and reported as
HM5528; see §17.2.
PasswordPepper, a server-wide secret mixed into Argon2id and scrypt hashes (empty, and off,
by default), is since 6.3.4 the one [Settings] key kept in the file only: never stored in the
database and never in a backup archive. It is changed in the file, or over COM or REST, which write only
the file; where the service cannot write the file, as under NT SERVICE\hMailServer and in the
Linux package, an administrator sets it with an elevated editor and restarts the service. Set it before
accounts exist: changing it later invalidates every Argon2id and scrypt password. A server restored onto
new hardware needs the same value copied into its file by hand (chapter 15), or no account whose password
is an Argon2id or scrypt hash can sign in.
OAuth2
OAuth2JwksUrl= ; the provider's JWK Set as the signing-key source
OAuth2JwksCacheSeconds=3600
OAuth2IntrospectionUrl= ; token introspection, so revocation takes effect
OAuth2IntrospectionClientId=
OAuth2IntrospectionClientSecret=
OAuth2IntrospectionCacheSeconds=300
OAuth2IntrospectionFailOpen=0
OutboundOAuth2TokenUrl= ; XOAUTH2 for outbound relay (Microsoft 365 and the like)
OutboundOAuth2ClientId=
OutboundOAuth2ClientSecret=
OutboundOAuth2Scope=https://outlook.office365.com/.default
OutboundOAuth2Hosts=smtp.office365.com
OutboundOAuth2FixedToken=
FetchOAuth2Hosts=outlook.office365.com
Storage, retention and archive
ArchiveDomains= ; scope the archive to named domains (empty = all)
ArchiveRetentionDays=0
ArchiveHardLinks=0
DeliveryHardLinks=0 ; one file on disk, a name in each recipient's folder
BackupVerifyRestore=1 ; verify the archive before calling a backup complete
ScheduledBackupTime= ; HH:MM local for a daily backup; wins over the interval
ScheduledBackupIntervalMinutes=0
ScheduledBackupKeepCount=0
ScheduledBackupMaxAgeDays=0
IMAPExpungeRetentionRecords=5000
IndexerFullText=0 ; full-text index (schema 6023); off
IndexerFullTextBatchSize=250
IndexerFullTextMinTokenLength=3
IndexerFullTextMaxTokensPerMessage=2048
MessageTraceEnabled=0
MessageTraceRetentionDays=30
QuotaWarningPercent=90 ; since 6.3.4 a domain or an account may hold its own value
RejectFullMailboxAtRcpt=1
Self-update
New in 6.2.28. Every one of these is inert until UpdateCheckEnabled=1: the
default makes no request, sends no identifier and counts nothing (§18.7).
UpdateCheckEnabled=0 ; nothing happens at all until this is 1
UpdateCheckHours=24
UpdateChannel=stable
UpdateFeedUrl= ; empty = this project's own release feed
UpdateSourceRepository= ; the repository a signature must be bound to
UpdateSigningIdentity=
UpdateSigningIssuer=
UpdateTrustRootsFile= ; point these five at a private Sigstore instance
UpdateLogPublicKeyFile=
UpdateRequireAuthenticode=0 ; 1 also requires Authenticode (Windows only)
UpdateAutoDownload=0
UpdateWindow= ; the window an unattended apply may run in
UpdateBackupBeforeApply=1
UpdateServiceWaitSeconds=180 ; wait this long for the service, then roll back
UpdateRequireAuthenticode only became worth setting in 6.3.1. It has
existed since 6.2.28, and until then 1 refused every release of this project, because
none carried an Authenticode signature; the Windows installer has carried one since 6.3.1
(§4.1). The check is WinVerifyTrust on the downloaded installer and it is
Windows-only — on Linux there is nothing to verify, so the server says so rather
than reporting a pass. Up to 6.3.3 updating a Linux server is the package manager’s job; since 6.3.4 the live update applies there through a root helper, though 6.3.4 itself is installed by
hand, from its .deb or .rpm file (§35.13).
UpdateFeedUrl and 6.3.4. The release feed moved off GitHub: a server from 6.3.4 on reads
https://updates.progressiverobot.com, which GitLab Pages serves from the project’s
releases, without being told. A server from 6.2.28 to 6.3.3 still reads the former GitHub repository’s Releases API, which
has answered 404 since 18 September 2026; set
UpdateFeedUrl=https://updates.progressiverobot.com/releases.json there and restart the service,
and its check works again. Such a server’s live update trusts only GitHub’s signer, so it refuses
every release signed on GitLab, from 6.3.5 on; installing the current release by hand, once, avoids that, and until then keep
UpdateAutoDownload off there, or it downloads a GitLab-signed installer at every check and
refuses it. 6.3.4 itself carries no Sigstore bundle and is installed by hand (§18.7).
From 6.3.6 the live update’s trust is read from the file alone.
UpdateSourceRepository, UpdateSigningIdentity, UpdateSigningIssuer,
UpdateTrustRootsFile and UpdateLogPublicKeyFile are read from [Settings] in
hMailServer.ini on every platform, never from the database, so that the process an update is checked for
does not choose what it is checked against. At the first start of 6.3.6 a value 6.3.4 or 6.3.5 stored in the
database is taken out, and where it said something the file does not, the error log names it once as
HM5809, with both values; a line for one of the five in [SettingsOverride] is ignored and
named at every start. COM and the REST API (409) refuse a change to them, and so the Control Panel and the Control
Deck. Most servers set none of the five. To set one, edit the file – on Windows with an editor run as an
administrator, on Linux with sudo hmailserver --set-setting NAME=VALUE – and restart the service; where
several servers share a database, put it in every server’s file before the first is upgraded, since the first
6.3.6 to start takes the rows out for all of them.
Under NT SERVICE\hMailServer, the account a new 6.3.4 installation runs as, the server still
checks for releases, but an apply is refused before anything happens, because that account cannot run
an installer: an administrator downloads the release from its release page, checks its Authenticode
signature and runs it (§17.3). An UpdateWindow there applies nothing and takes no backup,
and says why once in the error log (HM7463).
The [Database] and [Security] sections
These do not live in [Settings], because they say how the database is reached, and they
stay in the file after 6.3.4 moved the rest into the database:
[Database]
TlsMode= ; new in 6.3.4: empty = prefer; require, verify-full or disable
TlsCaFile= ; new in 6.3.4: the CA certificates verify-full checks against
UpgradeOnStart=0 ; new in 6.3.4: 1 applies the schema steps as the service starts
PostgreSQLSslMode= ; require, verify-full, ... an unknown mode refuses the connection
PostgreSQLSslRootCert=
ConnectionStringOptions= ; appended to the OLE DB connection string (MS SQL)
AllowUnencryptedConnection=0 ; still read; since 6.3.4 known to change nothing
Since 6.3.4 the connection is encrypted whenever the database server offers TLS. MySQL
and MariaDB through MariaDB Connector/C — the client bundled on Windows and the one Ubuntu packages — had
never used TLS, whatever the documents said; with TlsMode empty they now use it whenever it is
offered, without checking the certificate, and connect without it when it is not. SQL Server through
MSOLEDBSQL 18 and SQL Native Client now encrypts the whole session; through SQLOLEDB, which the server
uses where MSOLEDBSQL 18 or later is not installed and no failover partner is set, only the login is
encrypted unless TlsMode is require or verify-full. PostgreSQL is unchanged when nothing
is set, and its two keys above, which came first, win over TlsMode and TlsCaFile. The
application log says at every start what the connection got, and --check-database makes one
connection and says the same.
And [Security] AdministratorTotpSecret holds the administrator’s TOTP secret. It is
written by enrolment, not by hand; clearing the line is how a locked-out administrator
credential is recovered (§17.4). Under NT SERVICE\hMailServer the service cannot write the
file, so enrolment is refused until the service is moved to LocalSystem for it and back, and the line
is cleared with an elevated editor and a restart. [Security] AdministratorPassword holds the
administrator password as a PBKDF2 hash; since 6.3.4 an older MD5 or SHA-256 value is written again as
PBKDF2 at the next administrator sign-in, where the service may write the file. Where it cannot, as under
that account and in the Linux package, set the password
with hMailServer.exe --set-admin-password from an elevated prompt, or sudo hmailserver
--set-admin-password, then restart the service: a running one keeps the old password until it is
restarted or reinitialised. Neither takes the password as an argument. On Windows the verb reads the
HM_ADMIN_PASSWORD environment variable or, when that is not set, one line of piped input (from
Windows PowerShell 5.1, a password in plain ASCII only); on Linux it asks for it.
New in 6.3.4: [Security] StoredSecretsKey. It holds the installation’s
stored-secrets key, under this machine’s DPAPI (under the key file above on Linux). Each account’s
TOTP second factor and the [Settings] secrets (SRSSecret, BATVSecret,
OAuth2HmacSecret, OAuth2IntrospectionClientSecret,
OutboundOAuth2ClientSecret, OutboundOAuth2FixedToken, MetricsServerAuthToken,
MetricsServerAuthPassword,
ServiceAccountPassword and GssapiServicePassword) are sealed under it, so that
every server sharing the database can open them. The Windows installer makes the key on a new
installation. An upgrade gets none, and nothing is sealed until an administrator runs
hMailServer.exe --create-stored-secrets-key from an elevated prompt, or sudo hmailserver
--create-stored-secrets-key, which is also how a packaged Linux server gets one. The key is never in
the database or in a backup archive: export it at once with --export-stored-secrets-key
<file>, keep that file with the database backups but stored apart from them, and import it
with --import-stored-secrets-key on every other server that shares the database, then restart
each. A database dump restored without the key brings back every second factor and
[Settings] secret unopenable. ProtectStoredSecretsWithDPAPI=0 keeps them as
written.
22. Ports reference
| Port | Protocol | Encryption | Who uses it | Open to the internet? |
|---|---|---|---|---|
| 25 | SMTP | STARTTLS | Other mail servers delivering to you | Yes — required |
| 587 | SMTP submission | STARTTLS | Your users sending mail | Yes |
| 465 | SMTPS | Implicit TLS | Your users sending mail (legacy-modern) | Optional |
| 143 | IMAP | STARTTLS | Your users reading mail | Optional — prefer 993 |
| 993 | IMAPS | Implicit TLS | Your users reading mail | Yes |
| 110 | POP3 | STARTTLS | Your users reading mail | Optional — prefer 995 |
| 995 | POP3S | Implicit TLS | Your users reading mail | Optional |
| 80 | HTTP | None | ACME challenges, autoconfig | Only if using ACME/web services |
| 443 | HTTPS | TLS | MTA-STS policy, autoconfig and autodiscover, the Apple profile, CardDAV and (since 6.3.4) CalDAV for phones and desktop clients, and the OpenPGP Web Key Directory (6.3.4) | Only if using MTA-STS hosting, autoconfiguration (autoconfig, autodiscover or the Apple profile), CardDAV or CalDAV, or the Web Key Directory |
| 4190 | ManageSieve | STARTTLS when an IMAP, POP3 or SMTP port has a certificate | Users managing Sieve scripts | No — off by default (ManageSieveServerPort=0), localhost only when on |
| (your choice) | REST API, Control Deck, portal | TLS | Administration scripts, the Control Deck at /, and mailbox users through the portal at /portal |
Only with TLS. Off by default (RestApiPort=0) and bound to loopback when on; a portal your users reach from outside needs a certificate and a bound address, or a TLS-terminating proxy in front |
| (your choice) | Metrics | None, or TLS | Prometheus, health probes | No — off by default (MetricsServerPort=0) and bound to 127.0.0.1 by default; the bind address must be an IP address, not localhost. On a non-loopback bind, /metrics answers 503 until you set a credential (a token, or a user name and password), and the health probes answer without one. TLS is optional: a certificate and key make the whole port HTTPS, probes included; without them it is plain HTTP |
| 161 UDP | SNMP (new in 6.3.4) | SNMPv3 at authPriv only (HMAC-SHA-256, AES-128); SNMPv2c optional and off, its community in the clear | A monitoring system that speaks SNMP. Set over REST, in the Control Deck or with hmctl; there is no Control Panel page |
No — off by default (SnmpEnabled), and it answers nobody until SnmpAllowedAddresses names a manager; bind it to a management address |
| 783 | spamd | None | hMailServer talking to SpamAssassin (chapter 10) | No — outbound, to your own spamd |
| 3310 | clamd | None | hMailServer talking to ClamAV (chapter 11) | No — outbound, to your own clamd |
A new database is created with four listeners, all without encryption: SMTP on 25 and 587, POP3 on 110 and IMAP on 143. Ports 465, 993 and 995 are not created for you; add them on the TCP/IP ports page (on Linux, the Control Deck’s Ports view or hmctl port create) once you have a certificate. A port added or changed is listened on from the next reinitialise (POST /api/v1/server/reinitialize, or systemctl reload on Linux) or service start, not from the save. Since 6.3.4 a new installation (the Windows installer after its database step, or on Linux the service’s first start after --create-database; never an upgraded or attached database) gives those four a self-signed certificate for the machine and sets them to STARTTLS optional, and makes the Internet IP range require TLS for authentication, so that a password from another machine never crosses the network in the clear. Replace the certificate with a real one: ACME gives its certificate to every listener that holds the self-signed one (chapter 9).
The mail ports are configured under Connections & protocols → TCP/IP ports on Windows, the web services ports (80 and 443) under Connections & protocols → Web services & autoconfiguration, and the REST, metrics and ManageSieve listeners under Monitoring & troubleshooting → API & monitoring. On Linux
there is no Control Panel: the mail listeners are configured through the REST API (or hmctl, since 6.3.4) or the
Control Deck’s Ports view, and the web services, REST, metrics and ManageSieve listeners, whose ports are [Settings] keys the Control Deck has no editor for, over the REST API (PUT /api/v1/settings/ini/{name}, or hmctl ini set) or with sudo hmailserver --set-setting (chapters 24, 35 and 36). The REST API, hmctl and the Control Deck all need the REST listener, and since 6.3.4 you turn it on with sudo hmailserver --set-setting RestApiPort=8045 and sudo hmailserver --set-setting RestApiBindAddress=127.0.0.1, then sudo systemctl reload hmailserver, a reinitialise, because the listener binds only when the services start. Editing those keys in hMailServer.INI after the first start does not work: the database holds the [Settings] keys, and a line edited in the file is reported as HM5804 and the stored value is used.
The REST listener carries all of this on one port — the JSON API under
/api/v1/, the Control Deck at /, the webmail portal at /portal (with its script at
/portal.js), and POST /api/v1/session, which exchanges a password for an
HttpOnly, SameSite=Strict session cookie. Since 6.3.4 it also serves SCIM 2.0 under /scim/v2/, the list manager’s public pages under /lists/, and the single sign-on pages under /portal/oidc/ and /portal/saml/. There is no second port to open. The portal comes out of the binary; the Control Deck is served from the file
WebAdmin/index.html that the Windows installer and the Linux packages install with the server (since 6.3.4 with its language catalogues in WebAdmin/languages), and a server without that file answers / with a short page that says "Web administration page not installed". The listener does not start until the administrator password is set, and TLS is required
unless the listener is bound to 127.0.0.1, localhost or ::1.
Port 80 alone is not quite enough for Apple devices. Since 6.2.25 the
.mobileconfigprofile is served over HTTPS only: a plain-HTTP request is answered with a 301 to the same URL on theWebServicesHttpsPortlistener where one is configured, and a 403 that says why where none is. A TLS-terminating proxy signals withX-Forwarded-Proto: https, which the listener believes only from loopback or from a proxy listed inWebServicesTrustedProxies(new in 6.3.3); from any other peer the header is ignored. Thunderbird autoconfig and Outlook autodiscover are unchanged.

23. The COM API and scripting
hMailServer exposes a full COM API. Anything the Control Panel can do, a script can
do — it is the same interface, apart from a few pages that do not go through COM: Discovery (6.3.4) reads eDiscovery cases over
the REST listener, because discovery has no COM interface, and REST API keys writes hMailServerApiKeys.ini on the local server. This chapter is the introduction; chapters 29–31 are the
complete object reference, and chapter 28 documents every event script in depth.
COM is Windows. It is not part of the Linux build, and neither are VBScript and JScript event scripts — their engine is Windows Active Scripting; since 6.3.4 a Linux server runs event scripts in JavaScript instead (§28.2). On Linux every automation example in this chapter and in chapters 29–31 is replaced by the REST API (chapter 24), which since 6.3.0 writes most of what COM writes, or by
hmctl, the command-line client over it that the Linux packages have installed since 6.3.4. Chapter 35 covers what that does and does not reach.
From PowerShell
$app = New-Object -ComObject 'hMailServer.Application'
$app.Authenticate('Administrator', 'your-admin-password') | Out-Null
# Add a domain
$domain = $app.Domains.Add()
$domain.Name = 'example.com'
$domain.Active = $true
$domain.Save()
# Add an account
$account = $domain.Accounts.Add()
$account.Address = '[email protected]'
$account.Password = 'a-strong-password'
$account.Active = $true
$account.MaxSize = 1000 # MB, 0 = unlimited
$account.Save()
# Report on every mailbox
foreach ($d in $app.Domains) {
foreach ($a in $d.Accounts) {
'{0,-40} {1,8} MB used' -f $a.Address, [math]::Round($a.Size, 1) # Size is already in MB
}
}
Call Save(). Add() returns a new, unsaved object, and a script that forgets
Save() looks as though it worked and changes nothing. Since 6.3.4
the same holds for a property set on an existing route, incoming relay, DNS blacklist, SURBL server,
remote domain policy, server message, blocked attachment, SSL certificate or group: it is written on
the script’s own copy and takes effect at Save(), where up to 6.3.3 it changed the running
server at once, saved or not. A script that relied on that must now call Save().
Authorization note. In 6.2.10 a real defect was fixed here: fifteen COM methods returned
S_OKwhen they had actually refused the call, because they returnedfalsefrom a function whose return type isHRESULT— andfalseis0, which isS_OK. Five of them returned before writing their out-parameter, so an unauthorized caller got “success” plus uninitialized memory. If you script against the COM API, upgrade to 6.2.10 or later and make sure your scripts check return values. Since 6.3.4 a refusal also says why: up to 6.3.3, 72 COM classes answered one with a bare HRESULT (0x800403E9), and each now carries the server’s own sentence.Authenticateno longer accepts an account’s app password as a logon (up to 6.3.3 it did, past any second factor, which let a domain administrator who issued an app password on the server administrator’s mailbox sign in as the server administrator); an app password still opens the mailbox over IMAP, POP3 and SMTP.
Event scripts
Event scripts are VBScript or JScript on Windows and, since 6.3.4, JavaScript on Linux. Scripting is off as shipped. In the Control Panel, switch it on and choose the language on the Scripting tab of Maintenance → Advanced, and edit the script itself on Maintenance → Event scripts (local server only). The server runs the script at fifteen defined points in the mail flow; the main ones are below, and chapter 28 has them all:
| Event | Fires when |
|---|---|
OnClientConnect |
A client connects |
OnAcceptMessage |
A message has been received and is about to be queued (the usual place for a script to refuse it) |
OnDeliverMessage |
A message is about to be delivered |
OnDeliveryFailed / OnDeliveryStart |
A delivery attempt starts, before virus scanning (OnDeliveryStart); delivery to a
recipient has failed, once for each such recipient (OnDeliveryFailed) |
OnError |
An error is logged |
OnExternalAccountDownload |
Mail is fetched from an external account |

Sub OnAcceptMessage(oClient, oMessage)
If InStr(LCase(oMessage.Subject), "urgent invoice") > 0 Then
oMessage.HeaderValue("X-Suspicious") = "possible-invoice-fraud"
oMessage.Save
End If
End Sub
Event scripts run inside the mail flow. A slow script slows every message behind it, and one that hangs holds them until
ScriptTimeout(60 seconds by default) interrupts it; on Windows the interrupt cannot release a handler blocked inside a COM call, which holds them for as long as that call takes. A script that raises an error is reported, and the event keeps whateverResultit had, which for most events means carrying on. Keep them short, and test on a non-production server.Upgrade a server that runs event scripts to 6.3.4 or later. In every release from 6.0.0 to 6.3.3, on a Windows server scripting in JScript with an
OnClientValidatePasswordhandler, a password could run code inside the server before the client had signed in;OnExternalAccountDownloadandOnDeliveryFailedwere open the same way, and so was a rule’s Run function action in either language (CVE-2026-100741, §28.2). Until you can upgrade, switch event scripting off. Since 6.3.4 a Run function action must name a plain function (ASCII letters, digits,_and$, not starting with a digit); any other name runs nothing, and on Windows is reported asHM5018each time the rule matches.
24. The REST API
The REST API is an HTTP interface to the running server. It was a small read-mostly
surface until 6.3.0. It is now the interface the server is administered through wherever there
is no Control Panel and no COM — which is to say, on Linux — and it is the engine underneath
the two pages the server itself serves, the Control Deck at / and the webmail
portal at /portal.
The scale, before the detail. At 6.3.4 build/check-deck-parity.py counts
586 method-and-path operations, 258 of them writes
(hmailserver/docs/DeckParity.md). The tables below were written for a smaller API and do
not list every one of them; the server describes the /api/v1/ routes itself at
GET /api/v1/openapi.json, which is the list to trust.
What the API cannot write matters as much as what it can. Up to 6.3.2 no route wrote a
domain’s DKIM signing configuration, and on Linux, which has no Control Panel and no COM, nothing
did. Since 6.3.3 PUT /api/v1/domains/{domain} writes it, with the rest of
the domain, and at 6.3.4 the project’s census finds a route for every property the Control Panel
writes but an account’s eight directory fields, which PUT /api/v1/accounts/{address}/directory
writes under names the census does not match. Section 24.15 has the detail. COM remains the richer interface on
Windows; the REST API is narrower, but it is reachable from any machine and any language with
an HTTP client, and it is the only one of the two that exists on Linux. Chapter 35 covers the
Linux build, chapter 36 the Deck and the portal.
24.1 Turning the API on
The API is off in a default installation on both platforms. Four keys in the
[Settings] section of hMailServer.ini control it, and only the first
has to be set. Since 6.3.4 that section is the database’s copy (chapter 21): once the server has
started, set them in the Control Panel (Monitoring & troubleshooting → API & monitoring),
over COM with Settings.SetIniSetting or, on Linux, with sudo hmailserver --set-setting
RestApiPort=8443 and a reload, because a line edited in the file afterwards is ignored in favour of
the stored value, named in the error log as HM5804 and, where the service may write the file,
put back. A [SettingsOverride] section of the same file, applied over the store at every start
and reported as HM5807 each time, is the other way through. The two examples below are the file
as edited before the server’s first start.
| INI key | Default | Effect |
|---|---|---|
RestApiPort |
0 |
0 is off; any positive port starts the listener. The only switch there is — a
server upgraded without touching its settings behaves exactly as 6.2.28 did. |
RestApiBindAddress |
127.0.0.1 |
The address the listener binds. |
RestApiCertificateFile |
(empty) | PEM certificate, for HTTPS. |
RestApiPrivateKeyFile |
(empty) | The matching private key. |
Loopback only, which needs no certificate; and reachable from elsewhere, which must have one:
[Settings]
RestApiPort=8443
RestApiBindAddress=127.0.0.1
[Settings]
RestApiPort=8443
RestApiBindAddress=0.0.0.0
RestApiCertificateFile=/etc/hmailserver/certs/fullchain.pem
RestApiPrivateKeyFile=/etc/hmailserver/certs/privkey.pem
What the server refuses at start
Four messages begin “RestApi: Refusing to start”, in three situations. Each writes one line to the application log and leaves the rest of the server running.
- An empty administrator password disables the API entirely — “RestApi:
Refusing to start – the administrator password is not set.” On Linux, set it with
sudo hmailserver --set-admin-passwordfirst. On Windows, set it on the Control Panel’s Sign-in & access → Administrative access page where the service may writehMailServer.ini; a new 6.3.4 installation runs the service asNT SERVICE\hMailServer, which may not, and there the page refuses the change: runhMailServer.exe --set-admin-passwordfrom an elevated prompt instead, the password inHM_ADMIN_PASSWORDor on standard input and never as an argument, then restart the service. - TLS is mandatory off loopback. TLS counts as configured only when
both the certificate and the key are set. If it is not, and the bind address is not
exactly
127.0.0.1,localhostor::1, the listener refuses to start: “RestApi: Refusing to start – TLS certificate is required unless bound to 127.0.0.1 or ::1. Set RestApiCertificateFile and RestApiPrivateKeyFile.” Those three literals are matched exactly:127.0.0.2, an IPv4-mapped form and a127/8range are none of them exempt. It is a security gate, and widening it is a separate decision. - A certificate the shared TLS configuration will not take. Two of the four messages: one when the shared configuration could not be applied to the configured certificate, one when building the context threw. The specific cause — an unreadable file, a key that does not match — is reported as HM5113.
If RestApiCertificateFile is empty the server looks for
fullchain.pem and privkey.pem in the ACME certificate directory and
uses them when both exist, so a server already renewing through ACME gets an HTTPS API without
a second copy of the paths.
The listener now goes through the same TLS initialisation as the mail protocols, so the
cipher list, the protocol toggles, the DH parameters and the TlsKeyExchangeGroups
post-quantum hybrids all apply to it. Before 6.3.0 it built its own context and took OpenSSL’s
defaults, so an administrator who configured post-quantum groups got classical-only key exchange
with nothing saying so. One thing is deliberately not shared: a TLS 1.2 floor,
applied afterwards so it can only tighten, and not configurable. Chapter 17 covers the rest.
Apply the change, then ask the server how it is. Since 6.3.4 all four keys wait for a reinitialise
rather than a restart: the Control Panel’s Apply now on Windows,
sudo systemctl reload hmailserver on Linux, or Application.Reinitialize over COM; a
service restart does it too. The first configuration above has no certificate,
so that listener speaks plain HTTP and says so at startup — “RestApi: Listening on
127.0.0.1:8443 (http, loopback only).”:
curl -s -u Administrator:your-admin-password http://127.0.0.1:8443/api/v1/status
{"version":"6.3.8","state":3,"processedMessages":18422,"spamMessages":903,
"virusesRemoved":4,"sessions":{"smtp":2,"imap":11,"pop3":0},
"build":{"script_engine":true,"script_languages":["VBScript","JScript"],...}}
state is a number, not a word: 0 unknown, 1 stopped,
2 starting, 3 running, 4 stopping. Once the listener has a
certificate the scheme becomes https, and the rest of this chapter uses it. Reach for
curl -k only while the certificate is self-signed, and drop it the moment there is a
real one: a client that ignores certificates on loopback and then keeps the habit over a network
has thrown the gate away.
On Linux this listener is the administration interface — chapter 35. The Deck is a file the
package installs at /usr/share/hmailserver/WebAdmin/index.html and the server serves
at GET /; if it is missing the server serves a built-in stub saying so, and
hmailserver --check-config will not warn you, because that page is not one of the
paths it prints. The portal, by contrast, is compiled into the binary. §36.2 is this same switch
seen from the two pages’ side, and says what turning it on exposes.
24.2 Authentication
Four credentials exist. Which one a request carries is decided once, before routing.
| Credential | Presented as | Reaches |
|---|---|---|
| Administrator password | Authorization: Basic, user Administrator; plus
X-hMailServer-OTP once a second factor is enrolled |
Everything except /api/v1/me and, since 6.3.4, the routes only an API key reaches:
/api/v1/send and SCIM at /scim/v2. Nothing narrows it. |
| API key | Authorization: Bearer <token> |
What its Scope, since 6.3.4 its role, and its optional Domains list allow;
tried first when present. |
| Account credential | Authorization: Basic, the mailbox address as user name |
/api/v1/me/* and the two /api/v1/session routes. Nothing
else, unless (since 6.3.4) the account holds an administrator role, by its admin level or an assignment,
and the password is its own rather than an app password. |
| Session cookie | Cookie: hmailsession=<token> |
Whatever the credential that minted it reaches. |
A Basic user name of administrator, compared without regard to case, is tried
against the administrator password and nothing else; any other user name is treated as a
mailbox address and is never tried against it.
Since 6.3.4 the credentials overlap in places. An account that holds an
administrator role (serveradmin, domainadmin, helpdesk,
auditor or a custom one, by assignment or, for the first two, through its admin level Server
or Domain) reaches, with its own address and password or its session cookie, the administrative routes
its role’s permissions allow, in the domains the role reaches; an app password or a bearer session never
carries the role. An API key holds a role too; a key
without one is what it always was, Scope=full acting as serveradmin and
Scope=readonly as auditor, still confined by its Domains list. A key is
the only credential POST /api/v1/send takes (a key that names a sender and whose role holds
send_through_api) and the only one
/scim/v2 takes (a key whose role holds provision). A one-time recovery code travels
in X-hMailServer-Recovery-Code wherever the authenticator’s code is asked for. A session
can also be minted from a passkey or a one-use OpenID Connect or SAML ticket, and
POST /api/v1/session?bearer=1 answers an account’s session as a token in the body, which a
page holding a second account sends as Authorization: Bearer; that is never minted for the
administrator. hmailserver/docs/Roles.md has the permissions.
Sessions
New in 6.2.28 for an account’s own sign-in (the portal) and in 6.3.0 for the administrator (the
Control Deck), so that a browser page need not hold a password.
POST /api/v1/session takes HTTP Basic once — an account’s address and
password, or Administrator and the administrator password, with
X-hMailServer-OTP when a second factor is enrolled — and answers 201
with:
Set-Cookie: hmailsession=<64 hex chars>; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200
; Secure is appended whenever the browser came over HTTPS: when the listener speaks TLS,
which is everywhere but loopback, and, since 6.3.4, when a reverse proxy on this machine in front of the
loopback listener says X-Forwarded-Proto: https. A proxy that terminates HTTPS should set that
header; one that does not leaves the cookie without Secure, and a browser would then send it
over plain HTTP too. From 6.3.6 the plain loopback port answers only a request whose Host is
localhost, an IP address, the server’s host name or the host of an address configured as the one the
webmail is reached at from outside, and refuses any other with 421 before authentication, so that a web
page that points a name of its own at this machine cannot reach the console through a browser on it: a proxy that
forwards the browser’s own Host needs that name set first, as the webmail’s outside address or as
the server’s host name, and must pass the Control Deck’s page through unchanged, since its script policy names
the SHA-256 of the page’s one inline script. A wrong administrator password now costs a growing delay, up to ten
seconds, from every source including the loopback address, so behind a proxy on the same machine one client’s
wrong guesses keep the password sign-in waiting for everyone while they last; API keys, passkeys and single sign-on
are not touched by it.
- The token is 32 random bytes in lower-case hex, and only its SHA-256 is kept in memory, so reading the session table gives an attacker nothing usable.
- Two lifetimes, both enforced on the server: by default 30 minutes idle, 12 hours
absolute. Since 6.3.4 they are session policies, set server-wide or per domain
(
session_idle_secondsandsession_lifetime_seconds, at most thirty days each; 0 means not set, and the defaults apply), beside two switches: one session per device, and a session bound to the address it began on. The policy is re-read at every request, and an account’s session is listed among its devices, where the account or the administrator can revoke it.Max-Agecarries only the absolute ceiling. - At most 1,000 live sessions; expired ones are swept first, then the least recently used is evicted.
- Sessions live in process memory only, so a restart invalidates every one —
including a restart through
POST /api/v1/server/reinitialize. - The session ends when the password behind it changes. Every request on an administrator session re-derives a stamp from the administrator credential and compares it in constant time with the stamp taken at sign-in; a different one, or an empty password, revokes every administrator session at once. Per account the same: a password change ends the account’s other sessions while keeping the one that made it, and an account deactivated or deleted mid-session is refused from its next request; since 6.3.4 so is a session whose domain has been switched off.
- An API key cannot mint a session (
403), and a session cannot mint another.DELETE /api/v1/sessionends the session it came with; a request that carried a password gets400, there being nothing to end.
Every write made with a session cookie must carry
X-Requested-With: hMailServer, compared case-sensitively. The check runs
before authentication is even tested; without it the answer is 403, “a request
that changes something must carry X-Requested-With: hMailServer when it is authenticated by a
session cookie”. That closes cross-site request forgery twice over:
SameSite=Strict stops the cookie travelling with another site’s request at all, and
no other origin could add that header without a preflight this server never grants. A client
using Basic, or an API key as Bearer, needs no such header — there is no cookie to abuse; a session in
the bearer form (since 6.3.4, POST /api/v1/session?bearer=1) must carry it to change anything,
as the cookie must. §36.3 shows the same
exchange as the Control Deck and the portal perform it, including what a second factor adds.
What each credential can and cannot do
Authorisation is decided in exactly one place, on the route’s identity rather than on a path string, before any handler runs — so an endpoint that forgets to check something cannot exist, because no endpoint does the checking. In order:
- The session routes: account or administrator only; an API key gets
403. - The self-service routes, everything under
/api/v1/me: an account credential only. The administrator password and API keys are refused403— “this endpoint answers to an account’s own credentials, not to the administrator password or an api key” — there being no mailbox behind either. - An account credential anywhere else:
403, “an account’s credentials reach only the account’s own endpoints under /api/v1/me” — unless, since 6.3.4, the account holds an administrator role, by its admin level or an assignment, and the request came with its own password or its session cookie rather than an app password or a bearer session. Then the role’s permissions decide, in the domains it reaches, and a route it may not call answers403naming the role and the permission it lacks: “the role ‘helpdesk’ does not hold the permission ‘manage_settings'”. - The administrator credential: allowed, always, except on the routes only an API key
reaches since 6.3.4 (
/api/v1/sendand/scim/v2). Including through a cookie, which is why an administrator session reaches/api/v1/apikeys. - An API key under
/api/v1/apikeys, or on another route no API key may call:401, not403— the API answering misleadingly on purpose, so that a key probing these routes learns neither “valid but not permitted” from “not a key at all” nor whether a verb exists there. The other routes are/api/v1/settings/ini(since 6.3.3) and, since 6.3.4, the audit trail, the alerts, the webhooks, the compromised-account review,/api/v1/settings/changed,/api/v1/server/settings/pending, an account’s export, the address erasure and the second factor’s enrolment, whatever the key’s scope or role. The key store, the ini routes,/api/v1/settings/changedand/api/v1/server/settings/pendinganswer to the administrator password alone; the audit trail, the alerts, the webhooks and the review also answer to an account whose role holds the permission. - A read-only key on a route that changes something:
403, “this api key is read-only”. Mutating is a property of the route, not the HTTP method, so a route that changed state under aGETcould not slip past. - A key with a
Domainslist: see 24.3.
Scope and Domains fail closed: an absent or
unrecognised scope leaves the key read-only, and a Domains entry that is not a real
domain matches nothing rather than everything.
At the front door there is a rate limit of 200 requests per 10 seconds per
credential — per credential, not per source address, so one leaked key cannot spend the
listener’s capacity by rotating addresses — answered 429 with
Retry-After. Failed credentials feed the auto-ban exactly as the SMTP, IMAP and POP3
front ends do. 401 responses conceal everything: no credential, a
wrong password, an unknown key, an expired key and a key refused by source address answer
identically, the sole exception being X-hMailServer-OTP: required when the password
was right and the one-time code was missing or wrong. And WWW-Authenticate: Basic
is suppressed for a page’s own fetch, so the Deck and the portal show their own message instead
of the browser’s credential box.
Since 6.3.4 a one-time code is taken once, over REST and COM alike: the same code, or one
from an earlier step, is refused with that 401 and not counted as a wrong one. A script that sent
the administrator password and a code with every request should sign in once with
POST /api/v1/session and use the cookie from there, or use an API key; otherwise only its first
request in each half minute is accepted. After five wrong codes in fifteen minutes every code for that
account, or for the administrator, is refused with 429 for fifteen minutes, even behind a
reverse proxy on this machine, which the auto-ban does not reach; on the administrator’s Basic sign-in
the answer carries code second_factor_locked and Retry-After.
24.3 Conventions
- The path is versioned. Everything is under
/api/v1/but the pages and what they load (the three page endpoints, the webmail’s language catalogues and scripts and, since 6.3.4, the Deck’s catalogues, the sign-in, reset and reading pages and the list manager’s pages) and, since 6.3.4, SCIM at/scim/v2, a provisioning API of its own. - JSON in, JSON out. Every response is
Content-Type: application/jsonwithCache-Control: no-store— except the attachment download and the three pages, which set their own media type and areno-storeas well, and the other pages and scripts served beside them and the downloads that are not JSON, such as a message’s raw source, a folder’s export, an account’s export as a zip and a report asked for withformat=csv. - Status codes are conventional:
200read or applied change,201created,202accepted but not yet done —server/reinitializeandPOST /api/v1/backupand, since 6.3.4, routes that queue their work, among themPOST /api/v1/password-reset, an eDiscovery search or export andPOST /api/v1/store/measure—400a body the server will not accept,401a credential it will not accept,403a credential it accepts but will not permit here,404an unmatched path or unknown id,409a conflict with what already exists or with the server’s state,413over the size limit,429over the rate limit. - The error shape is one field,
{"error":"..."}, to which a few 6.3.4 answers add acode(second_factor_locked,support_session,mailbox_key), and which SCIM at/scim/v2replaces with SCIM’s own; an unmatched method-and-path pair answers404 {"error":"not found"}. No stack trace, no SQL, no file path, and403responses say why in fixed sentences that never name a file, a query, a row or another domain. - Read-only keys are refused by route, not by verb.
POST /api/v1/update/checkis a write because it changes the recorded verdict and makes the server call out, so a read-only key cannot make it. - Writes are all or nothing. Most visible on the settings groups, where a request naming twenty keys applies twenty or none, but it is the general habit.
- Request size.
POST /api/v1/me/messagesandPOST /api/v1/me/draftsare allowed a body of up to sixteen megabytes. Since 6.3.4PUT /api/v1/me/settingsis a large request too, like sending a message, so that a formatted signature with its images is refused by the route’s own limit of 64,000 bytes rather than by the listener’s cap. Every other request is held to 64 KB unless its route is granted more: since 6.3.4 the SAML sign-in’sPOST /portal/saml/acstakes up to 256 KB. - One matching quirk. Prefix and suffix comparisons are case-insensitive in this codebase while exact ones are not, and the router preserves that deliberately. Rely on neither; send paths as written here.
Domain scoping
A key may carry a Domains list. What that does depends on the route, in three
cases.
- Refused outright. The delivery queue and the quarantine are refused
entirely rather than filtered, each in its own sentence — “the delivery queue is
server-wide” and “the quarantine is server-wide” — and, since 6.3.4, so are the
volumeandstoragereports, “that report is server-wide”. One queued message names recipients in any number of domains, and the listing shows the sender and every recipient of all of them, so there is no honest way to narrow it. - Refused as server-wide. Domain create and delete, every IP-range route,
certificate list, create and delete, every port route, every SMTP-route route, every global rule route (since 6.3.4 an account’s and a
domain’s own rules are checked against the list instead),
the log list and tail, backup start and status, every settings-group route,
server/reinitialize, all four update routes and, since 6.3.4, the account-group (/api/v1/groups) and remote-domain routes:403, “that resource is server-wide”. The other server-wide routes 6.3.4 adds, the warm-up schedules, the message store and its measurement, maintenance mode and the diagnostics among them, are refused to such a key too. - Checked against the list. Account list and create, domain update, alias list and create, distribution-list list and create and the DKIM read take the domain from the path. Account delete, account update, alias delete and list delete take it from the address — which is the whole reason the mechanism exists, since otherwise a key for one domain could delete an account in another by editing one path segment.
Everything else falls through to allowed and is filtered inside the handler: the domain
listing and /api/v1/srv drop domains the key is not entitled to, the archive
routes refuse an unnamed or wrong domain with their own 403, and, since 6.3.4, a report
asked for without a domain is confined to the key’s own domains. Every route table
below uses one vocabulary; a cell lists every credential the route accepts, and anything not
listed is refused. Since 6.3.4 an account that holds an administrator role, and a key’s role,
reach a route when the role holds its permission; the tables do not show that.
| Token | Meaning |
|---|---|
admin |
The administrator password over Basic, or the cookie minted from it |
key-ro |
Any API key, read-only ones included |
key-full |
An API key only when Scope=full; a read-only key gets 403 |
key-alldom |
A key with no Domains list; a domain-restricted key gets 403 |
key-dom |
A domain-restricted key is accepted, confined to its own domains |
account |
The mailbox’s own address and password, or the cookie minted from it |
key:401 |
Any API key refused with 401 rather than 403, deliberately |
key:403 |
Any API key refused with 403 |
none |
No credential required |
24.4 Server and status
| Method | Path | Auth | What it does |
|---|---|---|---|
GET |
/ |
none |
The Control Deck page, also served at /index.html; read from
WebAdmin/index.html, with a stub when absent |
GET |
/portal |
none |
The webmail markup, compiled into the binary; no inline script |
GET |
/portal.js |
none |
The portal’s script, separate so the page’s CSP can forbid inline script entirely |
GET |
/api/v1/status |
admin, key-ro, key-dom |
version, state (0 unknown, 1 stopped, 2 starting, 3 running,
4 stopping), the processed, spam and virus counts, live session counts per protocol and, since 6.3.3,
build: what this build can do, with script_languages since 6.3.4 (VBScript and
JScript on Windows, JavaScript on Linux) |
POST |
/api/v1/server/reinitialize |
admin, key-full, key-alldom |
Stop every service, reload the configuration, start again in the same process |
GET |
/api/v1/openapi.json |
admin, key-ro, key-dom |
The OpenAPI 3.0.3 description of every route (24.17) |
GET |
/api/v1/tlsa |
admin, key-ro, key-dom |
Recommended DANE TLSA records (3 1 1) for the configured certificates |
GET |
/api/v1/srv |
admin, key-ro, key-dom |
Recommended client-discovery SRV records (RFC 6186 and 8314, plus Outlook
_autodiscover) built from the ports actually enabled |
Reinitialize answers before it acts — 202
{"reinitializing":true}, then the work on a detached thread half a second later, because
the listener carrying the answer is one of the things that stops. Poll
GET /api/v1/status to find out when it is back; it holds its own lock, so two callers
restart in turn rather than at once. This route is what makes the write surface usable without
stopping the process: a new listener, a certificate binding, a settings-group key marked restart
(24.5) or a [Settings] key that waits for a reinitialise becomes live once it has run. Since 6.3.4
most saved [Settings] keys apply when they are saved and need no reinitialise. The 28 that do
(the REST, metrics, web-services and ManageSieve listeners, their certificates and the mail listeners’
TLS 1.3 ciphers, key-exchange groups and session options among them) are listed, once saved with a value the
server is not running on, by GET /api/v1/server/settings/pending until it has run; only the two
[Settings] keys marked restart and the bootstrap keys ([Database],
[Directories], [Security] and [GUILanguages]) wait for a service restart.
A reinitialise also ends maintenance mode: the answer is then {"reinitializing":true,"drained":true}, and
"drained":false otherwise.
/tlsa and /srv generate records and publish
nothing. /srv emits nothing for a disabled service, a loopback-bound port or
port 25 presented as submission, and skips inactive domains, so what it gives back matches the
server as it actually runs. Chapter 22 covers the ports.
24.5 Settings
Six routes and three groups — the largest single piece of the write surface 6.3.0 added, and
the counts in this section are 6.3.0’s. The groups have grown since, to nine at 6.3.3 and thirteen
at 6.3.4, each with a GET and all but the directories group with a PUT; the
OpenAPI document (24.17) lists them.
Everything known about a setting is one row of one table: its name, its type, whether it can be
read or written, when it takes effect, the words an enumerated value may take, its description,
and the getter, setter and pre-apply check that reach the same code COM reaches. The
GET walks that table, the PUT walks it and the OpenAPI description is
generated from it, so the three cannot disagree about a key.
| Method | Path | Auth | What it does |
|---|---|---|---|
GET |
/api/v1/settings |
admin, key-ro, key-alldom |
The 62 readable server-wide settings as one flat object — 63 rows less the write-only relayer password |
PUT |
/api/v1/settings |
admin, key-full, key-alldom |
Change any subset of the 63 writable server-wide settings. All or nothing |
GET |
/api/v1/settings/antispam |
admin, key-ro, key-alldom |
The 34 anti-spam settings as one flat object |
PUT |
/api/v1/settings/antispam |
admin, key-full, key-alldom |
Change any subset of the 34 anti-spam settings. All or nothing |
GET |
/api/v1/settings/logging |
admin, key-ro, key-alldom |
The 16 logging keys: 11 settings plus 5 read-only facts about where the log is written |
PUT |
/api/v1/settings/logging |
admin, key-full, key-alldom |
Change any subset of the 11 writable logging settings; naming a read-only key is a
400 |
| Group | Keys | Writable | Readable |
|---|---|---|---|
| server | 63 | 63 | 62 |
| anti-spam | 34 | 34 | 34 |
| logging | 16 | 11 | 16 |
| total | 113 | 108 | 112 |
113 keys described, 108 written, 112 readable. The gap on each side has
exactly one cause. The five read-only logging keys are directory,
current_default_log, current_error_log, current_event_log
and current_awstats_log — facts about where the log is being written rather than
choices anyone makes; a PUT naming one is a 400,
“<key> is read-only”. The one write-only key is
smtp_relayer_password in the server group: accepted by PUT, emitted by
no route at all. Nothing you can call will read a relayer password out of this server. Since 6.3.4 the
SNMP agent’s snmp_v3_auth_password, snmp_v3_priv_password and
snmp_v2c_community, in the same group, are write-only too.
A write applies only when every key is accepted. A PUT takes
any subset of its group’s writable keys and is all or nothing: every member of the body is
resolved to a row and a typed value, every row’s own check is run, and only then is anything
applied. An unknown key, a wrong type, a read-only key, or a value the setting refuses is a
400 naming the key — or carrying the setting’s own refusal sentence, the one the
Control Panel shows — with nothing changed. The three settings whose setters
can themselves fail — the IMAP hierarchy delimiter, which is refused while a folder or a rule
action still contains the new character, and the two tarpit values, which are
[Settings] keys, written to the settings store (since 6.3.4 in the database, with
hMailServer.ini as its copy; before it, to the file) — are applied in a first pass, so a
refusal there also leaves nothing else applied.
The setters are the ones COM calls. A property write reaches the settings store at once and
raises the configuration-change notification, which refreshes the logger, the work queues and the
delivery manager: a change made over REST is seen by the running server exactly as one
made in the Control Panel is. Each key declares when it takes effect — now,
restart (the network thread pool, which services start, the TLS contexts), or
stored, meaning written and read by nothing, kept because the Control Panel writes it.
Since 6.3.4 the rows kept in the [Settings] store, the two tarpit values and the forwarding
rewrite, also carry that store’s own mark, now, reinitialize or restart, as
x-applies in the OpenAPI document; that restart waits for a service restart, while a group’s
own restart is taken at a service start or a reinitialise (24.4).
The PUT also logs the keys and never the values, since a value may
be the relayer password, and enumerated values travel as words rather than numbers: connection
security is none, starttls_optional, starttls_required or
tls, the log device unknown, sql or file, the
log format default or csa.
The server group covers identity and defaults, capacity and threading,
which services run and what they say, the server-wide relayer including its write-only password,
delivery and retry policy, protocol behaviour, nine IMAP keys, four auto-ban keys, eight TLS keys
and three conversation-logging toggles. The anti-spam group covers the two score
thresholds and the marking options, the SPF, HELO, MX and PTR checks each with its score, DKIM
verification, DMARC and ARC, five SpamAssassin keys, tarpitting and six greylisting keys. The
logging group is enabled, seven log toggles,
keep_files_open, device and log_format, plus the five
read-only keys above. Since 6.3.4 the server group also carries, among others, the nine audit-trail and alert
settings, which take effect at once, the four session policies of 24.2 (session_idle_seconds and the
rest) and twenty-seven more keys (message_store_*, mailbox_key_idle_minutes,
pst_import_directory and the SNMP agent’s snmp_*, which are marked restart and
read when the agent starts, at a service start or a reinitialise), and the logging group the twelve syslog keys,
marked restart in the same sense. The OpenAPI document is the definitive list.
24.6 Domains, accounts and aliases
| Method | Path | Auth | What it does |
|---|---|---|---|
GET |
/api/v1/domains |
admin, key-ro, key-dom |
List domains; a domain-restricted key sees only its own |
POST |
/api/v1/domains |
admin, key-full, key-alldom |
Create a domain from name, active and postmaster;
every other setting takes the new-domain default |
PUT |
/api/v1/domains/{domain} |
admin, key-full, key-dom |
Change a domain: active (required) and any of its other properties — the postmaster,
the name (which renames the domain and every address in it), its size and count limits, plus addressing,
its signature, its DKIM signing and key rotation, its relay host, its out-of-office and quota texts and the
rest the Control Panel’s domain pages hold. The whole domain since 6.3.3 (the key rotation and
the quota texts since 6.3.4); before it, only the active flag and the postmaster |
DELETE |
/api/v1/domains/{domain} |
admin, key-full, key-alldom |
Delete a domain with its accounts and messages, aliases, lists, domain aliases and directories; since 6.3.4 refused while the domain or any account in it is under an eDiscovery legal hold |
GET |
/api/v1/domains/{domain}/dkim |
admin, key-ro, key-dom |
The DKIM signing configuration: enabled, selector, sign_aliases, private_key_file.
Read only: a PUT here is a 404. DKIM is written with the rest of the domain,
through PUT /api/v1/domains/{domain} |
GET |
/api/v1/domains/{domain}/accounts |
admin, key-ro, key-dom |
List the accounts in a domain |
POST |
/api/v1/domains/{domain}/accounts |
admin, key-full, key-dom |
Create an account: address and password required, optional
active, first_name, last_name, max_size_mb |
PUT |
/api/v1/accounts/{address} |
admin, key-full, key-dom |
Update an account: any subset of active, password, max_size_mb, the two name fields, the
forwarding fields, the three signature fields and admin_level, and the rest of what the Control
Panel’s account dialog writes: the automatic reply, the spam thresholds, message retention, the Sieve
script and restricted delivery among them. Since 6.3.4 also kind, which makes the account a
room or a piece of equipment. The eight directory fields go to
PUT /api/v1/accounts/{address}/directory (24.15) |
DELETE |
/api/v1/accounts/{address} |
admin, key-full, key-dom |
Delete an account and its messages; the domain is taken from the address. Since 6.3.4 refused with 409 while an eDiscovery legal hold covers the account |
GET |
/api/v1/domains/{domain}/aliases |
admin, key-ro, key-dom |
List the aliases in a domain |
POST |
/api/v1/domains/{domain}/aliases |
admin, key-full, key-dom |
Create an alias from name, value and active; in
effect for the next message |
DELETE |
/api/v1/aliases/{address} |
admin, key-full, key-dom |
Delete an alias, scoped to the address’s domain |
Up to 6.3.2 the only domain properties the API wrote were the name at creation, the active
flag and the postmaster, so a domain that had to sign its outbound mail with DKIM could not be
configured on Linux. Since 6.3.3 PUT /api/v1/domains/{domain} takes every other property
a domain has — its DKIM signing configuration, its size limits, its signature and its relay host
among them — and the Control Deck edits them, on both platforms (24.15). Since 6.3.4 a name the server
keeps its own files under, such as protected, quarantine, files,
lists, temp or database, is refused for a new domain and for a rename,
over REST as over COM.
Accounts. Create is under the domain, update and delete by address, and the
address must belong to the named domain on create; unknown fields on the update are refused by
name. admin_level is governed as COM governs it: the administrator password, or a key
issued for every domain, may set all three levels and may update an account that is itself a
server administrator, while a key restricted to named domains may set user or
domain only and may not touch a server administrator at all. Since 6.3.4 one rule covers
every route that changes an account named by its address (update, delete, move, a password reset, a role,
and what is in the mailbox): a credential may not change an account whose authority it does not take in, so
a key or role confined to a domain is refused, with 403, the server administrator’s own
mailbox in that domain, “that account holds authority this credential does not”.
Aliases. Judged as the Control Panel judges one — a name an account or a
distribution list already holds, or a domain already at its alias limit, is refused in the same
sentence. There is no PUT: an alias is changed by deleting it and
creating it again.
24.7 Distribution lists
| Method | Path | Auth | What it does |
|---|---|---|---|
GET |
/api/v1/domains/{domain}/lists |
admin, key-ro, key-dom |
List the distribution lists in a domain, with their members |
POST |
/api/v1/domains/{domain}/lists |
admin, key-full, key-dom |
Create a list: address and members, whether it is
active, its mode (public, membership,
announcement or domain_members), require_auth, the sender it
requires, the moderator and the bounce address; since 6.3.4 also its membership query
and the list manager’s fields |
PUT |
/api/v1/lists/{address} |
admin, key-full, key-dom |
Change any of those fields but the members, scoped to the address’s domain. Since 6.3.3 |
DELETE |
/api/v1/lists/{address} |
admin, key-full, key-dom |
Delete a distribution list, scoped to the address’s domain |
GET |
/api/v1/lists/{address}/members |
admin, key-ro, key-dom |
Who the list delivers to now, its membership query resolved, each address marked
row or query; 503 when the accounts cannot be read. Since
6.3.4 |
Every write is domain-scoped. Since 6.3.3 a list is changed in place with
PUT /api/v1/lists/{address}, so a list whose mode has to change no longer needs COM or
deleting and recreating it; aliases still have no PUT. Since 6.3.4 a list may also carry
a membership query (membership: domain, domain:,
pattern: or everyone, answered against the server’s active accounts when a
message is sent), which a key confined to named domains may set only within them, and the list
manager’s fields (managed, subscribe_policy, archive,
digest and the rest), with routes under /api/v1/lists/{address}/members to
approve a request to join and clear a member’s bounces, and a managed list’s archive at
GET /api/v1/lists/{address}/archive. Chapter 13 covers lists themselves.
24.8 Rules and routes
| Method | Path | Auth | What it does |
|---|---|---|---|
GET |
/api/v1/rules |
admin, key-ro, key-alldom |
List the global rules with their criteria and actions |
POST |
/api/v1/rules |
admin, key-full, key-alldom |
Create a global rule: name, active, all_criteria, criteria[] and actions[], in the order they run |
PUT |
/api/v1/rules/{id} |
admin, key-full, key-alldom |
Replace a rule whole, keeping its place in the order |
DELETE |
/api/v1/rules/{id} |
admin, key-full, key-alldom |
Delete a global rule with its criteria and actions |
POST |
/api/v1/rules/match |
admin, key-ro, key-alldom |
Try one criterion (match_value, match_type,
test_value) and answer what delivery would decide; nothing is read or written. Since
6.3.4 |
GET |
/api/v1/routes |
admin, key-ro, key-alldom |
List the SMTP routes the server delivers by; the relay password is never returned |
POST |
/api/v1/routes |
admin, key-full, key-alldom |
Create an SMTP route: domain_name and target_smtp_host required,
plus port, retry policy, relayer authentication, the local-domain flags, all_addresses,
addresses[] and connection_security |
PUT |
/api/v1/routes/{id} |
admin, key-full, key-alldom |
Replace a route whole; the address list is replaced by addresses |
DELETE |
/api/v1/routes/{id} |
admin, key-full, key-alldom |
Delete an SMTP route and its addresses |
Rules. Criteria fields are from, to,
cc, subject, body, message_size,
recipient_list, delivery_attempts and header — the
header’s name goes in the header field — and, since 6.3.4,
attachment_name, sensitive_content, attachment_type,
external_recipient and content. Matches are equals,
not_equals, contains, not_contains,
less_than, greater_than, regex and wildcard.
Action types are delete, forward, reply,
move_to_folder, script_function, stop,
set_header, send_using_route, copy and
bind_to_address, and, since 6.3.4, add_disclaimer (its text in
body), reject and warn_sender (their text in
value), require_tls and quarantine. A PUT replaces a
rule whole — old criteria and actions deleted, new ones created — which is what saving an edited
rule in the Control Panel comes to. These are the server’s rules only; an
account’s rule is 404 here. Since 6.3.4 a domain’s own rules, which run after the
server’s, are at /api/v1/domains/{domain}/rules and an account’s at
/api/v1/accounts/{address}/rules, each with /{id} for one rule, in the same
documents as the server’s, and a domain-restricted key reaches those of its own domains.
SMTP routes. treat_security_as_local_domain is the COM name for
treat_recipient_as_local_domain; both spellings are accepted and both emitted.
relayer_auth_password is write-only, and it is the one field a PUT
keeps when the body omits it — every other omitted field takes its default. So the safe
way to edit a route is to read it, change the one field, and send the whole object back. Since
6.3.4 a policy for one remote domain — TLS required on mail to it or from it, size and rate
limits, replies and forwards, the recipient callout — is a remote domain policy rather than a route, at
/api/v1/remote-domains (chapter 14).
24.9 Certificates and listeners
| Method | Path | Auth | What it does |
|---|---|---|---|
GET |
/api/v1/certificates |
admin, key-ro, key-alldom |
List the SSL certificates: names and file paths, never a private-key password |
POST |
/api/v1/certificates |
admin, key-full, key-alldom |
Add a certificate by name and the PEM certificate and key paths, with an optional
write-only private_key_password; both files must exist or the request is refused
naming the missing one |
DELETE |
/api/v1/certificates/{id} |
admin, key-full, key-alldom |
Delete a certificate; refused while a port binds it, and the refusal names the port |
GET |
/api/v1/ports |
admin, key-ro, key-alldom |
List every listener: id, protocol, address, port, connection_security, certificate_id, client_certificate_policy, CA file |
POST |
/api/v1/ports |
admin, key-full, key-alldom |
Add a TCP/IP port; tls and both STARTTLS values need a
certificate_id. Takes effect at the next reinitialise or service restart |
PUT |
/api/v1/ports/{id} |
admin, key-full, key-alldom |
Replace a listener whole — an omitted field takes its default, so send back what
GET returned with the change made |
DELETE |
/api/v1/ports/{id} |
admin, key-full, key-alldom |
Delete a listener; the row goes at once, the listener stays up until the next reinitialise or service restart |
Certificate paths are checked for existence at create time and refused with
the file named, rather than saved for a listener to fail on at the next start. That matters more
than it sounds on Linux, and since 6.3.4 on a new Windows installation, whose service runs as
NT SERVICE\hMailServer: the certificate is read by the service’s own account, so a path
readable to the administrator’s shell may not be readable to the server. On Windows, move a file
outside the folders that account is granted into Data\Certificates, or approve its
folder for Read (hMailServer.exe --approve-read "<folder>" /GrantServiceAccess,
run elevated). A port written here takes
effect when the server restarts — or at once after
POST /api/v1/server/reinitialize, the pairing that makes this write surface usable
without stopping the process. Chapter 22 covers what the ports are for, chapter 17 which should be
exposed.
24.10 IP ranges
| Method | Path | Auth | What it does |
|---|---|---|---|
GET |
/api/v1/ipranges |
admin, key-ro, key-alldom |
List the IP ranges |
POST |
/api/v1/ipranges |
admin, key-full, key-alldom |
Create a range: name, lower, upper, priority, the three protocol flags, the four relay flags, the four SMTP-auth requirements, require_tls_for_auth, spam_protection, virus_protection, expires and expires_time |
PUT |
/api/v1/ipranges/{id} |
admin, key-full, key-alldom |
Change a range in place: any subset of the create’s fields. Since 6.3.3 |
DELETE |
/api/v1/ipranges/{id} |
admin, key-full, key-alldom |
Delete an IP range |
Server-wide. Since 6.3.3 a range is changed in place with
PUT /api/v1/ipranges/{id}, which takes any subset of the create’s fields, so there is
no need to delete a range and create it again to change it. If you do replace one, create the new
range before deleting the old: deleting the range that permits your own relaying and then failing
to create its replacement is how a working server stops accepting mail from its own network, and no route restores the defaults — there is no equivalent
of the Control Panel’s reset. Chapter 17 covers what the ranges should say.
24.11 Queue, quarantine and archive
| Method | Path | Auth | What it does |
|---|---|---|---|
GET |
/api/v1/queue |
admin, key-ro, key-alldom |
List the delivery queue: id, created, from, recipients, next_try, locked, tries |
POST |
/api/v1/queue/{id}/retry |
admin, key-full, key-alldom |
Retry a queued message now; an unknown id is a 404, not a reported
success |
DELETE |
/api/v1/queue/{id} |
admin, key-full, key-alldom |
Remove a message from the delivery queue |
GET |
/api/v1/quarantine |
admin, key-ro, key-alldom |
List quarantined messages, bounded to the newest 1,000 |
POST |
/api/v1/quarantine/{id}/release |
admin, key-full, key-alldom |
Release a quarantined message to its original recipients |
DELETE |
/api/v1/quarantine/{id} |
admin, key-full, key-alldom |
Delete a quarantined message |
GET |
/api/v1/archive |
admin, key-ro, key-dom |
Search the archive index by domain, mailbox, sender, recipient, subject, since, until, hold and limit |
GET |
/api/v1/archive/{id} |
admin, key-ro, key-dom |
One archive entry |
POST |
/api/v1/archive/{id}/hold |
admin, key-full, key-dom |
Put an archived copy on legal hold: never removed by the retention sweep or an address erasure |
DELETE |
/api/v1/archive/{id}/hold |
admin, key-full, key-dom |
Lift the legal hold on an archived copy |
The queue and the quarantine are refused outright to a domain-restricted key
— refused, not narrowed — for the reason given in 24.3. A quarantine release delivers
directly rather than back through the filters, because a release is an administrator
overruling them, and sending the message back through them would simply quarantine it again. The
account’s own quarantine is a separate surface at /api/v1/me/quarantine, showing only
the entries that one address is a recipient of and not the other recipients.
The archive routes enforce their domain restriction in the handler rather
than at the authorisation choke point, as the 6.3.4 report routes also do, because there the domain is a query
parameter or a property of the row. A domain-restricted key must name one of its own domains in
domain= on the search, and is refused an entry whose domain is not one of its own.
24.12 Logs, metrics, backup and updates
| Method | Path | Auth | What it does |
|---|---|---|---|
GET |
/api/v1/logs |
admin, key-ro, key-alldom |
List the log files with size and creation time |
GET |
/api/v1/logs/{name} |
admin, key-ro, key-alldom |
The last lines of one log file; lines= defaults to 200, at most 2,000 |
GET |
/api/v1/metrics/history |
admin, key-ro, key-dom |
The recorded history of one metric; metric and range
(24h, 7d, 30d). Empty when
MetricsHistoryDays is 0 |
GET |
/api/v1/backup |
admin, key-ro, key-alldom |
The backup manager’s status text and the last lines of the backup log |
POST |
/api/v1/backup |
admin, key-full, key-alldom |
Start a backup with the configured settings; it runs on the maintenance queue, so poll the
GET |
GET |
/api/v1/update |
admin, key-ro, key-alldom |
The update check’s recorded verdict (state 0-5, availableVersion, releaseName, publishedAt, releaseUrl, installer). Fetches nothing itself |
POST |
/api/v1/update/check |
admin, key-full, key-alldom |
Read the release feed now and return the verdict. Since 6.3.4 the default feed is
https://updates.progressiverobot.com; a server up to 6.3.3 reads the former GitHub repository’s, which has
answered 404 since 18 September 2026, so its check fails until UpdateFeedUrl
names https://updates.progressiverobot.com/releases.json and the service has been restarted |
POST |
/api/v1/update/download |
admin, key-full, key-alldom |
Download the newer installer and its Sigstore bundle and verify it. Nothing is run |
POST |
/api/v1/update/install |
admin, key-full, key-alldom |
Re-verify the installer, fetch the running version as a rollback image, hand both to the update helper. The service stops and starts. Since 6.3.4 it enters maintenance mode first (listeners closed, delivery paused), refuses with “already draining” while another drain is in progress, and on Linux hands the package to a root helper |
Log names are strictly checked. A name is admitted only if it ends
.log, is at most 128 characters, contains no .., has no leading dot,
and is drawn from letters, digits, underscore, hyphen and dot — no path separators at all. The
list route is the only place a client should get a name from.
Metrics. metric is a name from the Prometheus exporter without
its hmailserver_ prefix, and range sets the bucket as well as the
window: 24h averaged per minute, 7d per ten minutes, 30d
per hour. Counters are totals; a rate is the difference between two samples.
Backups, since 6.3.4. GET /api/v1/backup/archives lists the
archives in the backup destination and GET /api/v1/backup/{archive}/mailboxes/{address}
one account’s folders and messages as an archive holds them; a POST to its
/restore puts back one mailbox or one message, merged or replacing, followed at
GET /api/v1/backup/restore-mailbox/{job}, and POST /api/v1/backup/restore
restores a whole archive. All of them are server-wide.
All three update POSTs are classed as writes, so a read-only
key is refused all three: a check changes the recorded verdict and makes the server call out, a
download writes a file the next step will run, an install runs it. The download verifies the
installer against its Sigstore bundle — the digest, the certificate chain as of the
transparency-log entry, the release workflow’s identity, the signature and the log’s own signature
— and a file that fails any of those is deleted rather than kept. The digest it checks is the one
the release feed states for the asset it fetched, which is worth knowing from 6.3.1 onwards: the
installer published on a release is now the signed file rather than the one the build produced, so
it has a different SHA-256 from the build’s. The feed and the download agree; a hash copied out of
a build log will not.
6.3.4 carries no Sigstore bundle. It was built and Authenticode-signed by hand, after
GitHub suspended the project’s account and before the GitLab pipeline that signs releases
existed, so a server of any version that is offered it says it cannot be verified and downloads nothing;
it is installed by hand (§18.2). The bundles of the releases up to 6.3.3 name GitHub’s release
workflow; from 6.3.5 they name this project’s GitLab CI pipeline: 6.3.5’s at the project’s former
path, https://gitlab.com/Progressiverobot/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.5, and from
6.3.6 at the group path the project moved to on 29 September 2026,
https://gitlab.com/hmailserver/hmailserver//.gitlab-ci.yml@refs/tags/v<version>. A server from
6.3.4 on trusts all three, the group path only when the certificate carries the project’s id,
86730559. A 6.3.4 server installs a later release without a rollback image: the rollback is the running version’s
own installer, fetched and verified before the install, and 6.3.4 has no bundle to verify it by.
A server from 6.3.4 on also holds a file to its release: a certificate made for another tag is
refused, a GitHub-signed file installs only as a release up to 6.3.3, and on Windows the installer’s
version resource must name the release it is offered as.
UpdateRequireAuthenticode, and what 6.3.1 changed about it. The
key is a [Settings] key, has existed since 6.2.28 and defaults to 0. Since
6.3.4 it is stored in the database: set it in the Control Panel, over REST
(/api/v1/settings/ini/UpdateRequireAuthenticode) or over COM, not in
hMailServer.ini, where an edited line is ignored in favour of the stored value and
reported as HM5804.
Set to 1 it adds a WinVerifyTrust check on the downloaded installer,
run after the Sigstore verification and before the file is moved into place; a file that fails
is deleted and the reason is reported in the verdict, so
POST /api/v1/update/download answers state 5 rather than state 3. Until 6.3.1 the
setting was unusable: no release of this project carried an Authenticode signature, so turning it
on refused every one of them. The Windows installer has been Authenticode-signed since
6.3.1 — signed with Azure Artifact Signing against a certificate profile issued to
Progressive Robot Ltd after Microsoft’s identity validation, with an RFC 3161 countersignature —
so the check now has something to find. Three things to know before turning it on.
Releases before 6.3.1 are unsigned and always will be, a published release
being immutable, so the check refuses every one of them — and the rollback image an install
fetches goes through the same download, so on a server still running a pre-6.3.1 version the
install proceeds without one and says so in the log: if the new version does not start, that
machine needs a reinstall by hand. The check is Windows-only: on Linux there is
no Authenticode to verify, and the server says so rather than reporting a pass —
“Authenticode signatures can only be checked on Windows” — which is the honest answer
and, up to 6.3.3, also the reason updating a Linux installation was the package manager’s job rather than this API’s; since 6.3.4 this route applies a signed package there through a root helper (§35.13). And the signature changes nothing about SmartScreen, which still flags a new installer as
unrecognised; it is the machine-checkable signature this setting wants, not a reputation.
A defect that affected this route before 6.3.2. The installer it hands over moves the database schema, and the 6029-to-6030 upgrade step has an ordering fault in the sweep that clears orphaned rows before it adds seventeen foreign keys: children are cleaned before the parents whose deletion re-orphans them, so the constraint that follows is refused. It fires only on an upgrade from a schema below 6030 — a database not yet taken through 6.2.25 — and only where orphaned rows are already present; a database without them upgrades correctly, and a fresh installation is never on that path. When it does fire it fails loudly with the database engine’s own words and rolls back rather than doing anything quietly, it affects all four database backends, and no installation has reported hitting it. The fix shipped in 6.3.2. A self-update is on exactly the same path as a hand-run installer, so read chapter 18 before calling this route on an old database.
24.13 API keys
| Method | Path | Auth | What it does |
|---|---|---|---|
GET |
/api/v1/apikeys |
admin, key:401 |
List the API keys. Administrator credential only — a key that could read the store is a step towards minting one |
POST |
/api/v1/apikeys |
admin, key:401 |
Create a key from label, expires, allowed_from, scope and domains, and since 6.3.4 role,
send_from, send_per_minute and send_per_day. The clear-text token exists once, in this
201 |
DELETE |
/api/v1/apikeys/{id} |
admin, key:401 |
Revoke a key. Administrator credential only, so a key cannot lock the administrator out |
Three routes and a deliberate catch-all: any other verb under /api/v1/apikeys
is treated as part of the same administrator-only set, so a key probing the prefix cannot learn
whether a verb exists there. The store is hMailServerApiKeys.ini, beside
hMailServer.ini, re-read on every authentication attempt, so a key added or revoked
takes effect with no restart. Only the SHA-256 of a token is ever stored — losing
the 201 means minting a new key. A record carries a label, an expiry date (90 days after
creation when the request names none; there is no “never expires”),
allowed_from (an address, a range or a CIDR block; empty means any),
scope (readonly by default) and domains (empty means every
domain). Both defaults fail safe: a create request naming no scope produces a read-only key, and one
naming no expiry a key that stops working after 90 days. A key
that does less than you expected is the failure mode; one that does more is not. Since 6.3.4
a key can hold an administrator role (role; a key without one is what it was,
full acting as serveradmin and readonly as
auditor) and can name the one sender it may send as through
POST /api/v1/send (send_from, with send_per_minute and
send_per_day ceilings; its role must hold send_through_api).
The key store itself still answers to the administrator password alone: not to a key of any role,
and not to an account holding serveradmin, which is refused 403.
24.14 The mailbox API
The table is the mailbox’s core: twenty-four operations across nineteen paths under
/api/v1/me/, plus the two session operations. This is the largest single group and it is
what the webmail at /portal is built on, and it has grown well past the table: 6.3.3
added the webmail’s own routes (preferences, identities, scheduled sending, app passwords, S/MIME
and more) and 6.3.4 calendars and tasks, shared address books, the organisation’s directory,
other accounts, recovery, devices, OpenPGP and more, all under /api/v1/me/ and all in
the OpenAPI document (24.17). The portal also reads routes that need no credential, such as
/api/v1/signin-methods and /api/v1/help. Every route under
/api/v1/me/ answers to the account’s own credentials and nothing else: the administrator
password and API keys are both refused 403, because neither is an account and there
is no mailbox behind either whose quota or vacation message could be meant.
| Method | Path | Auth | What it does |
|---|---|---|---|
POST |
/api/v1/session |
admin, account, key:403 |
Exchange a password for the hmailsession cookie; since 6.3.4 also a passkey
assertion or a one-use OpenID Connect or SAML ticket, and ?bearer=1 answers an
account’s session as a token in the body instead |
DELETE |
/api/v1/session |
admin, account, key:403 |
End the session the request came with and clear the cookie |
GET |
/api/v1/me |
account |
The account’s own state: address, domain, active, quota, vacation, password_changed, second_factor, directory_linked |
POST |
/api/v1/me/password |
account |
Change the account’s own password (current, new); the policy and
reuse history apply as when an administrator sets one |
PUT |
/api/v1/me/vacation |
account |
Set the automatic reply whole: enabled (required), subject, message, expires, expires_date |
GET |
/api/v1/me/settings |
account |
The account’s own name, forwarding and signature settings |
PUT |
/api/v1/me/settings |
account |
Change name, forwarding or signature; each one the body names is applied whole, one it does not name is left alone |
GET |
/api/v1/me/filters |
account |
The account’s active Sieve script |
PUT |
/api/v1/me/filters |
account |
Set the active Sieve script, checked as ManageSieve’s PUTSCRIPT checks it (max
256 KB); an empty script removes the filter |
GET |
/api/v1/me/quarantine |
account |
The messages held as suspected spam for this address only, without the other recipients |
POST |
/api/v1/me/quarantine/{id}/release |
account |
Deliver a held message to this address only; the entry stays for its other recipients |
DELETE |
/api/v1/me/quarantine/{id} |
account |
Give up this address’s copy of a held message; nothing is delivered |
GET |
/api/v1/me/folders |
account |
The folder tree as IMAP LIST gives it, plus a shared section for the public
namespace and delegated folders; since 6.3.4 each folder also carries uidnext beside
uidvalidity |
POST |
/api/v1/me/folders |
account |
Create a folder, judged as IMAP CREATE judges it; a name carrying the delimiter
creates every missing level |
PUT |
/api/v1/me/folders/{id} |
account |
Rename or move a folder, judged as IMAP RENAME judges it; subfolders follow by
parent id |
DELETE |
/api/v1/me/folders/{id} |
account |
Delete a folder with its subtree; the inbox and any special-use folder are always refused |
GET |
/api/v1/me/folders/{id}/messages |
account |
One folder’s messages newest first; limit 1-200, before_uid to
page back, q to filter; at most 2,000 message files opened, or about two and a half
seconds of reading, per request |
GET |
/api/v1/me/changes |
account |
An opaque token hashing, per folder, the message and unseen counts, the folder’s current
UID and its name; hand the previous one back as since= and the answer carries
changed |
GET |
/api/v1/me/search |
account |
Search every readable folder, newest first by message id; q required,
limit 1-200, at most 2,000 message files opened or about two and a half seconds of
reading per request; each hit names its folder. Since 6.3.4 an answer that is not the end says
complete: false with next_before_id, which before_id continues
from |
POST |
/api/v1/me/messages |
account |
Send as the signed-in account through the same pipeline as SMTP submission; a copy is filed
in \Sent. Since 6.3.4 a rule’s reject action refuses it first, 403 with
the code policy_rejected, and nothing is queued |
GET |
/api/v1/me/messages/{id} |
account |
One message with to, cc, text, html and attachment entries; over one megabyte it is
described with truncated true and no body |
DELETE |
/api/v1/me/messages/{id} |
account |
Delete one message — to \Trash where the account has one, final otherwise or
with ?permanent=1; since 6.3.4, when recover_deleted_days is set (it is off
by default), a final delete is kept for that many days and the account can put it back through
/api/v1/me/recoverable |
PUT |
/api/v1/me/messages/{id}/flags |
account |
Change any of seen, flagged, answered, draft, deleted; only the flags named change, and every IMAP session on the folder is told |
POST |
/api/v1/me/messages/{id}/move |
account |
Move a message to another folder of the account, as IMAP MOVE does |
GET |
/api/v1/me/messages/{id}/attachments/{index} |
account |
One attachment decoded, under the type the listing reported — except HTML, SVG, XML and
script, served as application/octet-stream |
POST |
/api/v1/me/drafts |
account |
Save a draft in Drafts, created when the account has none, optionally superseding
replace_id; flagged \Draft and \Seen |
Folder writes speak IMAP’s own sentences. Create, rename and delete judge a
name exactly as CREATE, RENAME and DELETE judge it and
refuse in those commands’ own words, so a page and a mail client are never told different things
about the same mailbox. Delete is stricter than IMAP on purpose: the inbox is
refused however it is reached, and so is any folder the server has designated for a special use,
because one mis-click on \Sent in a page with no undo takes every sent message with
it. Such a folder is not made undeletable — it stays deletable over IMAP — and a page can tell in
advance, because a folder whose special_use is anything but the empty string will be
refused.
The change probe is designed not to lose a message. A since
that was never this mailbox’s token answers changed: true — “something differs” is
the answer that cannot lose anything. The portal polls it every six seconds and stops dead while
the tab is hidden. Reading is wider than writing: the public namespace and the
folders a delegating owner has shared are readable through the message routes under the rights the
owner granted, but they are not this account’s to create, rename or delete.
Inline images, and why the mechanism looks odd. Attachment entries carry
content_type and content_id, but the portal cannot point an
<img> at the attachment route: the body is rendered in a frame sandboxed
without allow-same-origin, so its document has an opaque origin, so the browser counts
its subresource requests as cross-site, so the SameSite=Strict cookie never travels,
so the route answers 401. The only thing that would fix it is the attribute the frame
exists to withhold. So the page fetches the attachment itself, with its own credentials, and hands
the frame the bytes as a data: URL. What decides whether something is inlined is
the server’s own Content-Type on the download, not the message’s claim
— which is why the types the download route deliberately neuters are never inlined.
Sending has its own limits: at most 20 attachments and twelve megabytes of files together, in a request of up to sixteen megabytes, because base64 adds a third. The portal’s page and its script are served under one set of headers, and it is a strict set:
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'unsafe-inline';
img-src data:; connect-src 'self'; frame-src 'self'; form-action 'none';
frame-ancestors 'none'; base-uri 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Cache-Control: no-store
That set is older than 6.3.4, and its img-src has widened since: the
organisation directory’s photos are loaded by URL from the server itself, which a policy of
data: and blob: alone would forbid, so CI holds the directive to what the
script actually loads. Read the headers off a live response rather than copying these. The page
still allows no image from another host, fetches no font and no stylesheet from anywhere, and comes
out of the binary rather than out of files beside it. Its script is executed in CI against a small
DOM and a stubbed API. Chapter 36 covers the portal as a thing to use; §36.10 and §36.11 go
through the inline-image path and this header set in full.
24.15 What the API cannot write yet
This is the counterpart to the rest of the chapter, and the section to read before deciding where
to host a domain. What it says has changed: up to 6.3.2 the only domain properties the API
wrote were the name at creation, the active flag and the postmaster, and the table below was
the list of what that left out. Since 6.3.3 every row of it is written by
PUT /api/v1/domains/{domain}, and the Control Deck edits it, so a domain that
must sign its outbound mail with DKIM can be configured on Linux, where there is no Control Panel and
no COM. What is left is measured rather than asserted: build/check-deck-parity.py counts
every property the desktop Control Panel writes against the REST API and the Deck, and writes
hmailserver/docs/DeckParity.md. At 6.3.4 it counts 466, finds 458 of them writable over
REST and every one of those reached from a Deck view, and lists eight as missing: an account’s
directory fields — title, department, phone, mobile, office, note, photo and the opt-out. Those are
written too, by PUT /api/v1/accounts/{address}/directory and the Deck’s account
editor, under names the census does not match.
| Not writable over REST up to 6.3.2 | Since 6.3.3 |
|---|---|
| Per-domain DKIM signing — enabled, selector, private key file, whether aliases are signed, the signing algorithm, both canonicalisation methods, the secondary selector and key | The dkim_* keys of PUT /api/v1/domains/{domain}, and since 6.3.4 the
key rotation’s too. GET /api/v1/domains/{domain}/dkim is still a read only, and a
PUT there answers 404. |
| Per-domain size limits — maximum domain, message and account size, and the account, alias and list count limits | The max_* keys and their switches; a credential confined to a domain cannot lift its
own domain’s limits. |
| The domain signature — enabled, method, plain text, HTML, whether it is added to local mail and to replies | The signature_* keys. |
| A per-domain relay host — host, port, user name, password, whether authentication is required, connection security | The relay_* keys; the password is taken and never given back. |
| Other domain properties — the plus-addressing character and whether it is used, message retention days, the Active Directory domain name, the domain vacation fields, the domain anti-spam options and greylisting flag | The keys of the same route, and the greylisting flag as use_greylisting. |
The rest of what this section once listed as absent — among them the IP ranges, incoming relays,
groups, the administrator’s second factor and the [Settings] keys (kept in the database since
6.3.4, with hMailServer.ini as their copy) — has routes of its
own at 6.3.4 (/api/v1/ipranges/{id}, /api/v1/incoming-relays,
/api/v1/groups, /api/v1/administrator/second-factor,
/api/v1/settings/ini); the OpenAPI document (24.17) is the list the server itself
answers from. The route tables earlier in this chapter do not list every route the server has at
6.3.4.
How the gap was measured at 6.3.0: the Linux regression run skipped 649 of 1,405 tests — 756 passed, 0 failed — because a fixture that reached something no route could answer was skipped with a reason naming what was missing, rather than weakened until it passed.
24.16 Worked examples
All of these assume a loopback listener on port 8443 with a self-signed certificate, hence
-k. Over a real certificate, drop it. Where a second factor is enrolled on the
administrator credential, each call made with the administrator password also carries the code in
X-hMailServer-OTP, and since 6.3.4 a code is taken once: a script that makes more than one call
signs in once for a session (the browser-style example below) or uses an API key, or only its first
call in each half minute is accepted.
Create a domain and an account
curl -sk -u Administrator:secret -X POST \
-H 'Content-Type: application/json' \
-d '{"name":"example.com","active":true,"postmaster":"[email protected]"}' \
https://127.0.0.1:8443/api/v1/domains
curl -sk -u Administrator:secret -X POST \
-H 'Content-Type: application/json' \
-d '{"address":"[email protected]","password":"a-long-passphrase","max_size_mb":2048}' \
https://127.0.0.1:8443/api/v1/domains/example.com/accounts
The domain now exists and accepts mail. It does not sign its outbound mail until
DKIM is set on it: dkim_enabled, dkim_selector and
dkim_private_key_file on PUT /api/v1/domains/{domain} (24.15). Since 6.3.4 the
server can make the key too: with dkim_rotation_enabled on, a domain with no key at all is
given its first one, and GET /api/v1/domains/{domain}/dkim/rotation shows the record to
publish; signing still waits for dkim_enabled.
Change settings, safely
Read first, change only the keys you mean, and send only those back. The write is all or nothing, so a typo costs the whole request and changes nothing.
curl -sk -u Administrator:secret https://127.0.0.1:8443/api/v1/settings
curl -sk -u Administrator:secret -X PUT \
-H 'Content-Type: application/json' \
-d '{"max_message_size_kb":51200,"smtp_no_of_tries":5,"auto_ban_on_logon_failure":true}' \
https://127.0.0.1:8443/api/v1/settings
That one applies, and the answer is the whole server group as it now stands. Name a key the group does not have, or one it will not let you write, and nothing at all is applied:
curl -sk -u Administrator:secret -X PUT \
-H 'Content-Type: application/json' \
-d '{"enabled":true,"current_error_log":"D:\\logs\\error.log"}' \
https://127.0.0.1:8443/api/v1/settings/logging
{"error":"current_error_log is read-only"}
The enabled in that body was perfectly valid and was not applied either.
Add a listener and make it live
{
"protocol": "imap",
"address": "0.0.0.0",
"port": 993,
"connection_security": "tls",
"certificate_id": 3
}
curl -sk -u Administrator:secret -X POST \
-H 'Content-Type: application/json' -d @port.json \
https://127.0.0.1:8443/api/v1/ports
curl -sk -u Administrator:secret -X POST \
https://127.0.0.1:8443/api/v1/server/reinitialize
The second call answers 202 {"reinitializing":true,"drained":false} (since 6.3.4
drained is true when the reinitialise ended maintenance mode). Poll
GET /api/v1/status until it answers again, and note that it drops every session
cookie, so a browser signed in to the Deck will have to sign in afresh.
Mint a scoped API key
curl -sk -u Administrator:secret -X POST \
-H 'Content-Type: application/json' \
-d '{"label":"provisioning","scope":"full","domains":"example.com","allowed_from":"10.0.0.0/24"}' \
https://127.0.0.1:8443/api/v1/apikeys
curl -sk -H 'Authorization: Bearer hmapi_4f3c...' \
https://127.0.0.1:8443/api/v1/domains/example.com/accounts
curl -sk -H 'Authorization: Bearer hmapi_4f3c...' https://127.0.0.1:8443/api/v1/queue
The first response carries the clear-text token once — it begins hmapi_, and
the whole string including that prefix is what goes in the header. Store it before you close the
terminal. The second call works. The third does not:
{"error":"this api key is restricted to named domains, and the delivery queue is server-wide"}
A browser-style session
What the Control Deck does: exchange the password once, then use the cookie — and remember the header on every write.
curl -sk -c jar.txt -u Administrator:secret -X POST \
https://127.0.0.1:8443/api/v1/session
curl -sk -b jar.txt https://127.0.0.1:8443/api/v1/domains
curl -sk -b jar.txt -X PUT \
-H 'X-Requested-With: hMailServer' \
-H 'Content-Type: application/json' \
-d '{"active":false}' \
https://127.0.0.1:8443/api/v1/domains/example.com
curl -sk -b jar.txt -X DELETE -H 'X-Requested-With: hMailServer' \
https://127.0.0.1:8443/api/v1/session
Leave the header off that PUT and the answer is 403 — the single
most common surprise for anyone writing a client that keeps cookies by default.
A mailbox reading its own mail
curl -sk -u [email protected]:a-long-passphrase \
https://127.0.0.1:8443/api/v1/me/folders
curl -sk -u [email protected]:a-long-passphrase \
'https://127.0.0.1:8443/api/v1/me/search?q=invoice&limit=25'
curl -sk -u [email protected]:a-long-passphrase -X POST \
-H 'Content-Type: application/json' \
-d '{"to":"[email protected]","subject":"Re: invoice","text":"Attached."}' \
https://127.0.0.1:8443/api/v1/me/messages
The search answers at most limit hits, newest first across every folder. Since 6.3.4 an
answer that is not the end says complete: false with next_before_id, and
before_id continues from it; up to 6.3.3 a search with more hits than limit
answered complete: true, more: true and the rest could not be reached.
Try the same three with the administrator password and every one answers 403:
there is no mailbox behind the administrator credential.
24.17 The OpenAPI document
curl -sk -u Administrator:secret \
https://127.0.0.1:8443/api/v1/openapi.json > hmailserver-openapi.json
GET /api/v1/openapi.json returns an OpenAPI 3.0.3 document
describing every route under /api/v1/ and, at 6.3.4, the /scim/v2 routes, the
sign-in, reset and reading pages, the list manager’s pages and the static catalogues and scripts beside
them: 441 paths and 621 path-and-method operations, 413 and 585 of them under /api/. The three
page endpoints of 24.3 are not in it, and the webmail page at /portal itself is mentioned only in prose.
It is valid JSON as of 6.3.0, and it was not before — anything that tried to
generate from it in an earlier release, a client, a form generator or a schema validator, would
have failed to parse it. If you have a tool that gave up on this document in the past, try it
again.
- It lives beside the router it describes, so a route change and its
documentation change land in the same commit. It is assembled from a static head, a contribution
from each unit of the router — at 6.3.0 the settings, rules, certificates, routes and mailbox units; at
6.3.4 every
RestApi*.cppfile, withRestApiSettings.cppwriting the settings groups’ paths from its tables — and a static tail, each contributor returning either nothing or a run of entries that each begin with a comma. That is what keeps the document well-formed however many units contribute. - The settings sections are generated from the same row tables the
GETand thePUTwalk, so every setting appears with its type, its permitted words, whether it is read-only and when it takes effect: since 6.3.4 a row kept in the settings store carriesx-applies—now,reinitializeorrestart— which the Deck shows as a badge beside the field. - The Control Deck generates its settings forms from this document. That is why the Deck gained editors for the entire settings surface at once rather than field by field: nobody hand-wrote the form controls of the thirteen settings groups 6.3.4 has, and nobody has to hand-write the next one. Chapter 36.
- Several regression fixtures hold the document to its own contract: each carries the paths it owns and fails the moment the document stops naming one of them. Those lists are the tests’ copy of the contract rather than a complete inventory of the router, so extending the API means extending the document and the list together — which is what the failure message says.
components.securitySchemesdeclaresbasicandbearer; the session cookie is described in prose under/api/v1/sessionrather than as a scheme, so a generated client will hold the password rather than the cookie unless you write that part yourself.
One thing the document does not tell you is the subject of 24.15. It describes every route
that exists and says nothing about a property no route writes. Up to 6.3.2 that left a generated
client looking complete with no way to turn on DKIM signing for a domain; since 6.3.3 that is the
dkim_* keys of PUT /api/v1/domains/{domain}, and at 6.3.4 the census of 24.15
lists only an account’s directory fields, which PUT /api/v1/accounts/{address}/directory
writes under other names. Not every description in it is current at 6.3.4 either: its text for
/api/v1/settings/ini still calls hMailServer.ini the store and says a
domain-restricted key is refused, where the value goes to the database first and every API key is
answered 401.
More: The REST API: turning it on, credentials, roles, one-time codes, OpenAPI and examples on the forum.
25. Version history
6.3.8 — security fixes for every earlier version, and the Helm chart starts a server
- Read this first: upgrade every server running 6.3.7 or earlier. Released 2 November 2026, 6.3.8 is a
security release: it fixes vulnerabilities the project found in its own code, which between them reach back to
6.0.0’s first beta, each a work item, public from the day of the release, with a CVE request through
GitLab’s CNA. They are in the database layer’s test for the marker that lets a statement’s error be
ignored (CVE requested,
work item #100);
in the auto-ban’s naming of the IP range it adds (CVE requested,
work item #102);
in the match of a sign-in’s name to its account (CVE requested,
work item #104);
in the IP ranges at the web services listener (CVE requested,
work item #105),
and in the address a failed sign-in at that listener is charged to (CVE requested,
work item #106);
in the address a failed sign-in at the REST listener is charged to (CVE requested,
work item #107);
in the cost of the REST API’s reading of a request’s JSON body (CVE requested,
work item #110;
CVE requested,
work item #111);
in the database client’s check of the database server’s certificate (CVE requested,
work item #112),
and in its check of a database server on this machine (CVE requested,
work item #113);
and on Windows in the database clients’ reading of the root certificate file named (CVE requested,
work item #114),
in the server’s reading of the files it hands to OpenSSL by name (CVE requested,
work item #115)
and in the folder the bundled database client loads its authentication plugins from (CVE requested,
work item #116).
The four narrower cases of work item #78 that 6.3.7’s notes listed as still open are fixed too: on Windows,
setup makes or hardens the program folder,
BinandControlPanelbefore it copies a file or runs a program of the installation, and refuses a folder or a link on the way to them, or a program, library or script in them, that another account controls (four CVE requests, work item #78). The Security section of 6.3.8’s CHANGELOG has every one, with what changed and how it is tested. The advisory for a flaw in the auto-ban on MySQL and MariaDB that 6.3.4 fixed is published with 6.3.8 (CVE requested, work item #103). - Read this too: no schema step. 6.3.8 needs 6108, as 6.3.6 and 6.3.7 do, so an upgrade from either changes no table, and the live update’s rollback, should 6.3.8 not start, meets no newer schema. From 6.3.5 or older the upgrade runs 6.3.6’s three one-way steps first, and the backup is the only way back (§18.2).
- Read this too: why there is a 6.3.8, and what else it carries. It was first decided, while
releases pause, because the published Helm charts of 6.3.6 and 6.3.7 did not start a server, which only a release
puts right, and that reason stands beside the fixes. 6.3.8’s chart starts a server: it runs the pod as the
image’s user by number, 999:999, fills new volumes in an init container and secures a new installation at its
first start. The container image reaches a MariaDB database. On Windows the installer refuses an installation of the
original hMailServer’s 5.7 builds of August and September 2026 before it changes anything, where the installers
of 6.3.7 and earlier replaced its programs. A certificate that does not name every host in
AcmeDomainsis due at once, a save of the list in the running server starts the check, and a check can be asked for (POST /api/v1/acme/run,GET /api/v1/acme, COMUtilities.RunAcmeCheckandGetAcmeStatus, the Control Deck’s Certificates page; work item #98). Rspamd learns from the Junk folder, beside SpamAssassin (work item #108), and scripts have an event after an IMAP move,OnIMAPMessageMoved(work item #109), each off until it is switched on. - A Helm release of the 6.3.6 or 6.3.7 chart is moved with
helm upgrade --reset-then-reuse-values(Helm 3.14 or later) and thenkubectl delete pod, since the StatefulSet replaces no pod while one is not Running and Ready;--reuse-valueskeeps the old chart’sfsGroup1000 anddatabase.port5432. In a cluster whose policy refuses every sysctl, setunprivilegedPortsSysctl: false. - The live update. Every file is signed as the release’s pipeline at the project’s path in
the group
hmailserver,https://gitlab.com/hmailserver/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.8, with the project’s id in the certificate. A 6.3.7 or 6.3.6 server installs it with its own file as its rollback image: on Windows a service running asNT SERVICE\hMailServerleaves the install to an administrator, whose Install update on the Server status page installs it with one Windows prompt; on Linux the installed version’s update helper applies it to the generic.deband.rpmand to both AppImages, and a server installed from a per-release package is upgraded with its package manager and its release’s own package of 6.3.8. 6.3.4 and 6.3.5 are as in 6.3.7’s entry below; a server from 6.2.28 to 6.3.3 is upgraded by hand, once (§18.2). A server whoseUpdateSigningIdentity,UpdateSigningIssuerandUpdateSourceRepositorystill name the project’s former path refuses 6.3.8 until the three are cleared. - What changes on the upgrade without a switch (§18.5), twenty-nine things, is in 6.3.8’s
section of the CHANGELOG, and its Before you upgrade names what most likely needs something done first. On
MySQL and MariaDB through MariaDB Connector/C,
TlsMode=verify-fullwithoutTlsCaFileis refused with HM7535 on a connection to a database server on this machine: name inTlsCaFilea CA that issues nothing but the database’s certificates, the one that issued its certificate, or setTlsMode=require, before upgrading. On Windows aTlsCaFile(withTlsMode=verify-full) or aPostgreSQLSslRootCert(HM7534), or a TLS certificate or key file or another file the server hands to OpenSSL by name (HM7536), whose path holds characters outside ASCII may be refused: give each such file, and the folders above it, an ASCII name before upgrading. On Linux file names outside ASCII are UTF-8 whateverLANGsays: on a server that ran under aLANGthat is not UTF-8, rename each such file and folder to its UTF-8 name before upgrading. On Windows setup refuses, with exit code 7, a program folder,BinorControlPanel, or a folder or a link on the way to them, that another account than SYSTEM, the Administrators and TrustedInstaller controls, naming theicaclsthat deals with it, and asks for a restart of Windows before it runs again. In a Compose deployment made from an earlier Compose file, keep itsHM_REST_CERTIFICATE_FILE,HM_REST_PRIVATE_KEY_FILEand./certsmount in the new file, the key readable by 999, or its REST API, and with it the Control Deck, the webmail andhmctl, does not start. A host inAcmeDomainsthat the CA cannot validate fails the whole order and holds the renewal of the other hosts with it: take such a host off the list before upgrading. A mail client that signs in with another spelling of the account’s address than the stored one, in more than the case of the letters A to Z, or with white space at either end, may no longer sign in; a REST request body that is not one JSON object, or passes the bounds, is answered 400; and an administrator’s IP range that allows an address none of SMTP, POP3 and IMAP closes the web services listener to it as well. - Platforms. The release carries 161 files in all, the Sigstore bundles among them: the files 6.3.7
carried, at 6.3.8, and
hmailserver-6.3.8.intoto.jsonl, the provenance as one line of JSON. The container image,registry.gitlab.com/hmailserver/hmailserver:6.3.8, also tagged6.3andlatest, for linux/amd64 and linux/arm64, is pushed from the release’s own pipeline by a job started by hand once the release is published; the package channels take 6.3.8 by jobs started by hand after that (§35.2). - Verification. As for 6.3.7, with
--certificate-identity 'https://gitlab.com/hmailserver/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.8'; both installers are Authenticode-signed by Progressive Robot Ltd (§4.1). More: the release’s page, with its notes and every file.
6.3.7 — security fixes for every earlier version, and the first security release made while releases pause
- Read this first: upgrade every server running 6.3.6 or earlier. Released 17 October 2026, 6.3.7 fixes
vulnerabilities in every earlier version, each found by the project’s own review unless the release notes say
otherwise. On Windows, where hMailServer was installed in a folder that took its permissions from the root of a drive,
any account that could sign in to the computer could replace the programs the service runs, in every release from
6.0.0 to 6.3.6; the default folder under Program Files was never affected, and
hMailServer.exe /ProtectFolders, which setup runs at every installation and upgrade, now hardens the program folder,BinandControlPanel(CVE-2026-107571, work item #78). Mail from outside whoseFromnamed a domain hosted here was signed with that domain’s DKIM key (CVE-2026-107567, work item #84); an account’s Sievenotifywas sent from whatever its:fromsaid (CVE-2026-107568, work item #82), and itseditheadercould rewrite who a message was from ahead of aredirect(CVE-2026-107566, work item #83); and any sender could lift the rule loop limit off a message (CVE-2026-107569, work item #81). Others, whose CVE identifiers GitLab assigned after the release (CVE-2026-114470 to CVE-2026-114479, CVE-2026-114489 and CVE-2026-114490), concern what a credential confined to named domains may change or read over the REST and COM APIs, a state-changing request that a browser holding a REST credential could be made to send from another site, the ACME challenge responder, the envelope sender of forwards and copies of mail from outside, the anti-virus action that removes attachments, mail to an SRS address and the relay rules, and credentials in the SMTP log. The Windows installers carry the .NET Desktop Runtime 10.0.13, where those of 6.3.4 to 6.3.6 carried 10.0.12, and the x64 installer, as the MySQL and MariaDB client, MariaDB Connector/C 3.4.11, where those of 6.2.3 to 6.3.6 carried 3.4.9; the ARM64 one carries no MySQL or MariaDB client. Setup installs the runtime only where no .NET 10 Desktop Runtime is installed, so an upgraded server keeps the one it had: bring it up to date through Microsoft Update. The Security section of 6.3.7’s CHANGELOG has every one, and What 6.3.7 does not fix, above that section, the four narrower cases of work item #78 that this release does not fix. - Read this too: no schema step. 6.3.7 needs 6108, as 6.3.6 does, so an upgrade from 6.3.6 changes no table, and the live update’s rollback to 6.3.6, should 6.3.7 not start, meets no newer schema. From 6.3.5 or older the upgrade runs 6.3.6’s three one-way steps first, and the entries below apply (§18.2).
- Read this too: a security release, cut from
master. 6.3.6 was the last release planned by date; the releases since are security releases, each cut frommasterwith whatever finished workmasterholds then, and 6.3.7 is the first. So it also carries the work that was under way on 13 October 2026: the Control Panel’s corrections to its pages, texts, translations and keyboard and screen-reader behaviour, and its regrouped sidebar; IMAP and POP3 taking the client’s address from a PROXY header; a link’s host that is not ASCII read by UTS 46; and the first units of the active-active work, which is still not built. It carries the first step of the Control Deck’s and the webmail’s rebuild planned for 6.4 as well, with no product code. - The live update. Every file is signed as the release’s pipeline at the project’s path in
the group
hmailserver,https://gitlab.com/hmailserver/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.7, with the project’s id in the certificate, as 6.3.6 was. A 6.3.6 server installs it with 6.3.6’s own file as its rollback image: on Windows a service running asNT SERVICE\hMailServerleaves the install to an administrator, whose Install update on the Control Panel’s Server status page, on the server, installs it with one Windows prompt; on Linux 6.3.6’s own update helper applies it to the generic.deband.rpmand to both AppImages, and a server installed from one of 6.3.6’s per-release packages is upgraded with its package manager and its release’s own package of 6.3.7. A 6.3.5 Windows server running as LocalSystem installs it with 6.3.5’s file as its rollback image, a 6.3.4 one without; that image is no way back once the upgrade’s schema steps have run, since 6.3.5 refuses a database they have moved to 6108, so from 6.3.5, as from 6.3.4, the backup taken before the upgrade is the way back (§18.6). UnderNT SERVICE\hMailServer, and on Linux, 6.3.4 and 6.3.5 are upgraded by hand, once (§18.2). A server whoseUpdateSigningIdentity,UpdateSigningIssuerandUpdateSourceRepositorystill name the project’s former path refuses 6.3.7 until the three are cleared. A server from 6.2.28 to 6.3.3, or one from 6.3.3’s AArch64 packages or AppImages, is upgraded by hand, once (§18.2). - What changes on the upgrade without a switch (§18.5) is in 6.3.7’s section of the CHANGELOG.
An account that sends over authenticated SMTP as an address of another domain hosted here, with no alias or grant that
lets it, has that mail sent unsigned as that domain. A domain’s or an account’s rule and a Sieve script may no
longer rewrite who a message is from, and one stored before the upgrade runs without that action or edit, which the
application log names.
RuleLoopLimit0 stops out-of-office replies too. An account given theserveradminrole with a list of domains is confined to those domains over COM, as over REST. A database account that signs in with MySQL Enterprise’s Windows authentication can no longer sign in. SMTP, POP3 and ManageSieve refuse a SASL PLAIN sign-in that asks to act as another account. - Platforms. The release carries 160 files in all, the Sigstore bundles among them: the x64
installer and, for Windows on ARM,
hMailServer-6.3.7-arm64.exe, a preview that has never run on ARM64 hardware; the generic.deband.rpmfor x86-64 and AArch64 and each Debian 12 and 13, Ubuntu 24.04, Fedora 44 and 43, Enterprise Linux 10 and 9, openSUSE Leap 16.0 and Tumbleweed release’s own, the AArch64 ones cross-compiled and tested under emulation; both AppImages, with the source of the libraries linked into their runtime and of those they carry; the source tarball, with nothing non-free in it; the Helm chart; the PowerShell module; an SBOM for each compiled file and the tarball; the sums of every Linux file; and the release’s SLSA build provenance. The container image,registry.gitlab.com/hmailserver/hmailserver:6.3.7, also tagged6.3andlatest, for linux/amd64 and linux/arm64, is pushed from the release’s own pipeline by a job started by hand once the release is published. On its release day no channel published it; on 21 October 2026 the Ubuntu PPA, Fedora Copr, the openSUSE Build Service, Docker Hub, PyPI and the PowerShell Gallery took it, Chocolatey listed it on 23 October, once its moderators had approved it, and the Snap Store took no snap of it. - Verification. Every file but the provenance has
<file>.cosign.bundlebeside it, and every one of those but a compiled file’s or the tarball’s SBOM (<file>.cdx.json) the same bundle again as<file>.sigstore.json:cosign verify-blobwith--certificate-identity 'https://gitlab.com/hmailserver/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.7'and--certificate-oidc-issuer https://gitlab.com, or on Linuxhmailserver --verify-update <file>;hmailserver-6.3.7.intoto.sigstore.jsonis the release’s SLSA build provenance, checked withcosign verify-blob-attestation; both installers are Authenticode-signed by Progressive Robot Ltd (§4.1). Known issue, as in 6.3.6: an AppImage on Fedora, Enterprise Linux or openSUSE runs on the host’s glibc and cannot require its extra character-set converters: installglibc-gconv-extra(glibc-gconv-modules-extraon openSUSE) by hand (§35.2). More: the release’s page, with its notes and every file.
6.3.6 — security fixes for every earlier version, every platform again, and the last release planned by date
- Read this first: upgrade every server running 6.3.5 or earlier. 6.3.6 fixes the security flaws
the project’s own reviews of the code found. The code of the legacy Blowfish scheme of stored secrets that the
project inherited from the original hMailServer had two heap buffer overflows: the one in its encryption is in
every version up to 6.3.5, and 6.3.6 fixes it; the one in its decryption is in every version up to 6.3.3, and was
fixed in 6.3.4, whose notes did not say so. They are CVE-2026-103011 (the encryption) and CVE-2026-103010 (the
decryption), assigned through GitLab
(work item #49);
6.3.6 replaces that code with the project’s own, which writes and reads every value as before. The same release
fixes the rest, each with a CVE identifier from GitLab, the CVE Numbering Authority for projects on
GitLab.com (CVE-2026-103647, CVE-2026-103649, CVE-2026-104658 to CVE-2026-104660, CVE-2026-104704 and
CVE-2026-107572 to CVE-2026-107587), and its work item public from the day of the release: on Windows, COM objects that carry no credential
were not refused what only a signed-in caller may do, and the installer left the data, log, temp, database and event
folders and
hMailServer.INIreadable by every account on the computer; on Linux, the live update’s apply step, which runs as root, trusted what the service account wrote, and some network waits had no timeout; the REST listener’s loopback port answered a request whatever itsHost, and a wrong administrator password from the loopback address was not slowed down; the webmail handed the browser a decrypted attachment under the type the message named, and kept a stranger’s S/MIME certificate for encrypting replies; outbound DANE used DANE-EE records alone, and read a lookup that did not complete as “unsigned”; the REST listener’s tables of browser sessions and pending sign-ins could be emptied by anybody who could sign in, or start a sign-in; and a message’s HTML or entities, a header field, an IMAPFETCH, a returned report, a MIME parameter, a DKIM or ARC signature, an SPF macro, a TLS report’s JSON or a Sieve script could hold a thread for minutes, or until the service was restarted. The Windows installers carry OpenSSL 4.0.3, where 6.3.5’s carried 4.0.2: CVE-2026-84783 (Moderate) applies to the Windows server on a port that asks for client certificates, and every Windows installer from 6.0.0-B1 to 6.3.5 carried a 4.0.x it affects. The Security section of 6.3.6’s CHANGELOG has every one. - Read this too: the schema moves from 6105 to 6108 in three steps, one way, so back up first: 6106
gives external accounts’ days to keep messages one meaning for POP3 and IMAP, 6107 finishes the
greylisting key, and 6108 adds the never-ban list by reverse name and each SMTP listener’s choice of what it does
with
AUTH(§18.2). From 6.3.4 the upgrade runs 6.3.5’s step first, and from 6.3.3 or older 6.3.4’s sixty-four, and the entries below apply. - Read this too: releases pause after 6.3.6. 6.3.6 was the last release planned by date: no release
is planned by date until the maintainers say otherwise in the security policy. A release is still made when a
confirmed vulnerability needs one, 6.3.7, 6.3.8 and so on, and the releases since are security releases, each cut
from
masterwith whatever finished workmasterholds then; the 6.3 line receives security fixes until 30 September 2027. - The live update. Every file is signed as the release’s pipeline at the project’s path in the
group
hmailserver,https://gitlab.com/hmailserver/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.6, with the project’s id in the certificate, which every server from 6.3.4 on trusts: a 6.3.5 server installs it with 6.3.5’s own file as its rollback image, a 6.3.4 server without one. On Windows a service running asNT SERVICE\hMailServerstill leaves the install of 6.3.6 to an administrator, who runshMailServer-6.3.6-x64.exeby hand; from 6.3.6 on, Install update in the Control Panel on the server installs the next release with one Windows prompt. On Linux, install 6.3.6 with the package manager: the update to 6.3.6 is applied by the apply step of the version it replaces, the one fixed here. A server whoseUpdateSigningIdentity,UpdateSigningIssuerandUpdateSourceRepositorystill name the project’s former path refuses 6.3.6 until the three are cleared. A server from 6.2.28 to 6.3.3, or one from 6.3.3’s AArch64 packages or AppImages, is upgraded by hand, once (§18.2). - What changes on the upgrade without a switch (§18.5), forty-six things. Mail can stop flowing if
the resolver strips DNSSEC: an MX or TLSA lookup that cannot tell is no longer read as “unsigned”, and the
delivery is deferred. External accounts’ days to keep messages means one thing for POP3 and IMAP. The
eight helpers of COM
Utilitiesthat do work, and aMessageorFetchAccounta program creates itself, answer only a program that has signed in. Setup makes the data, log, temp, database and event folders andhMailServer.INIprivate to SYSTEM, the Administrators and the service. The administrator’s commands write nothing to the error log, the store commands refuse a link the administrators do not alone control, and the installer exits 10 or 11 when a step after its files did not complete. The live update’s five trust settings are read fromhMailServer.inialone. The REST loopback port answers only aHostit expects, and a wrong administrator password costs a growing delay. Port 25 still takesAUTHuntil you change it. The URI block lists are asked about every domain a message links to, and a list’s error answer is never a listing. ManageSieve is held to deadlines. An IP address in text is read the same way on Windows as on Linux. The webmail keeps a correspondent’s S/MIME certificate only when its chain is trusted. The MX-record and SURBL tests cut a host name by the Public Suffix List; backups leave out the live update’s folder, and a destination inside the data directory works;tlds.txtis removed; a calendar message delivered by mail changes a stored event only from a verified sender; the Control Panel’s text can be selected and copied; on Linux the backup and event logs are UTF-8; scripting off runs no script engine. - New. A never-ban list by reverse name, forward-confirmed (work item 56); each SMTP
listener’s
AUTHOn, Off or Honeypot, a new installation’s port 25 a Honeypot (work item 54); an auto-ban widened to its reverse-name neighbours in the same IPv4/24(AutoBanWidenByReverseName, off by default; work item 55); a HELO name or a sender’s domain that is not a name refused (RefuseHeloNotFqdnandRefuseMailFromDomainNotFqdn, off by default; work item 51); one-click install of an update from the Control Panel on a Windows server whose service is not an administrator; a new Windows installation set up against SQL Server, PostgreSQL or MySQL with no window (/dbtype=and the other switches); and the last inherited code whose licence a distribution would have had to settle — the legacy Blowfish scheme, the backup index’s XML and the date class — is the project’s own. - Platforms. The release carries every file its pipeline makes, 62 files with a Sigstore bundle
beside each: the x64 installer and, for Windows on ARM,
hMailServer-6.3.6-arm64.exe, a preview that has never run on ARM64 hardware; the generic.deband.rpmfor x86-64 and AArch64 and each Debian 12 and 13, Ubuntu 24.04, Fedora 44 and 43, Enterprise Linux 10 and 9, openSUSE Leap 16.0 and Tumbleweed release’s own, the AArch64 ones cross-compiled and tested under emulation; both AppImages, with the source of the libraries linked into their runtime and of those they carry; the source tarball, with nothing non-free in it; the Helm chart; the PowerShell module, Authenticode-signed; an SBOM for each compiled file and the tarball; the sums of every Linux file; and the release’s SLSA build provenance. The container image,registry.gitlab.com/hmailserver/hmailserver:6.3.6, for linux/amd64 and linux/arm64, is pushed from the release’s own pipeline by a job started by hand once the release is published; until then the registry holds no image. A Nix flake and an Alpine APKBUILD are in the tree; no apt or dnf repository or other channel is live yet. - Verification. Every file has
<file>.cosign.bundlebeside it, and every file but a compiled file’s SBOM the same bundle again as<file>.sigstore.json:cosign verify-blobwith--certificate-identity 'https://gitlab.com/hmailserver/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.6'and--certificate-oidc-issuer https://gitlab.com, or on Linuxhmailserver --verify-update <file>;hmailserver-6.3.6.intoto.sigstore.jsonis the release’s SLSA build provenance, checked withcosign verify-blob-attestation; both installers are Authenticode-signed by Progressive Robot Ltd (§4.1). Known issue: an AppImage on Fedora, Enterprise Linux or openSUSE runs on the host’s glibc and cannot require its extra character-set converters,glibc-gconv-extra(glibc-gconv-modules-extraon openSUSE), without which text in BIG5, ISO-2022-JP, Windows-1251 or ISO-8859-2 is not converted: install the package by hand. The.rpms require it wherednfis installed on Fedora and Enterprise Linux and recommend it elsewhere, and a server without it whosednfhas no repository enabled has the live update refused until it is installed; the server says at its start what it cannot convert (§35.2). More: the release’s page, with its notes and every file.
6.3.5 — the first release its GitLab pipeline signed, and the quarantine grouped by domain and HELO
- Read this first: a 6.3.4 server can install 6.3.5 by its live update; an older one is upgraded by
hand. 6.3.5 is the first release built and signed by the project’s GitLab CI pipeline for its tag:
every file has a Sigstore bundle naming
https://gitlab.com/Progressiverobot/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.5and the issuerhttps://gitlab.com, which a 6.3.4 server trusts, and the Windows installer is Authenticode-signed as before. On 29 September 2026 a 6.3.4 server whose service runs as LocalSystem installed it by its own live update, with about 24 seconds without mail; one whose service runs asNT SERVICE\hMailServer, as a new 6.3.4 installation does, downloads and verifies it and leaves the install to an administrator (HM7463). A 6.3.4 server installs it without a rollback image, because 6.3.4 carries no Sigstore bundle. A server from 6.2.28 to 6.3.3 trusts only the GitHub signer: install 6.3.4 or 6.3.5 by hand, once, and keepUpdateAutoDownloadoff until then (§18.7). - Read this too: the schema moves from 6104 to 6105 in one step, one way, adding a column to the quarantine’s table, so back up first. From 6.3.3 or older the upgrade runs 6.3.4’s sixty-four steps first, and the 6.3.4 entry below applies.
- What changes on the upgrade without a switch. The Control Panel’s sidebar is in a new
order, with a new group, Set up this server, holding the Setup checklist (which was External setup),
Setting profiles and DNS records, and two groups renamed: TLS & certificates is Certificates &
encryption, Access & abuse protection is Sign-in & access (§5.3); the quarantine shows the HELO name and
groups by the sender’s domain; the webmail’s message list is one Tab stop, and Enter, Escape and the
formatted signature’s keys do what they say; an invitation sent the way Outlook sends one is shown as one;
and
GET /api/v1/quarantinegives three more fields, withQuarantine.Bulkand five newQuarantinedMessagemembers over COM. - Fixed. A rule with a Readable text or Sensitive content found condition read a message’s
HTML part in time that could rise with the square of its size, so that 400,000
&held a delivery for about a minute; and an invitation whose calendar part carries parameters in its Content-Type and no.icsname was never marked as one. 6.3.4 has both. - The quarantine reviewed: grouping by the sender’s domain, by the HELO name’s domain or not at all; release, delete, whitelist then release, and blacklist then delete on a message, a group or the checked ones, with a dry run first; a held message’s headers and a plain-text preview without releasing it; the Control Deck level with the Control Panel.
- Administration. Every database setting that should have an editor has one in the Control Panel: 137 that needed one and had none, on new tabs and a new Webmail page, 103 of them saved over the REST API, which is off as shipped. The Control Panel and the webmail were audited in both themes, by keyboard and, for the webmail, with NVDA in English and German. The Deck reaches 498 of the 508 properties the Control Panel writes.
- Platforms. As 6.3.4: the Windows x64 installer, and on Linux an x86-64
.debfor Ubuntu 26.04 and 24.04 and an x86-64.rpmfor Fedora 43 and 44 and Enterprise Linux 10, with 6.3.4’s dependencies. The pipeline builds the packages for Debian, Ubuntu, Fedora, RHEL and openSUSE on x86-64 and AArch64, the AppImages and the ARM64 installer as CI job artifacts only, not signed and not in the release, so 6.3.3’s AArch64 packages and AppImages are still the latest; the ARM64 installer’s job stops on a toolchain check that looks in the wrong place. No container image, no apt or dnf repository and no SLSA provenance. - Verification. Every file has
<file>.cosign.bundlebeside it:cosign verify-blobwith--certificate-identity 'https://gitlab.com/Progressiverobot/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.5'and--certificate-oidc-issuer https://gitlab.com, or on Linuxhmailserver --verify-update <file>;hmailserver-linux-6.3.5-SHA256SUMS.txtgives the two packages’ SHA-256; the installer, the.deband the.rpmeach have an SBOM of their own; andGet-AuthenticodeSignatureon the installer should answer Valid, signed by Progressive Robot Ltd (§4.1). linux-repro rebuilt the.deband the.rpmbyte for byte before the release. The project moved to gitlab.com/hmailserver/hmailserver on 29 September 2026, after this release; the old addresses redirect, and 6.3.5’s bundles keep the former path. More: hMailServer 6.3.5 on the forum.
6.3.4 — the settings in the database, calendars, roles and eDiscovery, installed by hand
- Read this first: install 6.3.4 by hand, on every server. It is the first release built
after GitHub suspended the project’s account on 18 September 2026. Its Windows installer is
Authenticode-signed by Progressive Robot Ltd and it carries no Sigstore bundle, so the live update of any
version that is offered it says it cannot be verified and downloads nothing. Run
hMailServer-6.3.4-x64.exe; on Linux, install the.debwith apt on Ubuntu 26.04 or 24.04, or the.rpmwith dnf on Fedora 43 or 44 or Enterprise Linux 10. A server from 6.2.28 to 6.3.3 still looks for releases at the former GitHub repository, which answers 404:UpdateFeedUrl=https://updates.progressiverobot.com/releases.jsonunder[Settings], and a restart, make its check work again. 6.3.4 reads that feed by itself and trusts both the GitHub signer of the releases up to 6.3.3 and this project’s GitLab pipeline, which signs the releases from 6.3.5 on (§18.7). - Read this too: the schema moves from 6040 to 6104 in sixty-four steps, one way, so back
up first. On MySQL and MariaDB step 6102 rebuilds every table as utf8mb4, which takes as long as copying the
database and needs MySQL 5.7.9 or MariaDB 10.2.2 or later and the ALTER privilege on the database (on an
older database server the upgrade stops at 6041 with error 1071 and changes nothing), and step 6101 adds two
columns to
hm_messages; §18.2 has both. A silent upgrade of a server with an administrator password takes it with/adminpassword=or, new in 6.3.4, inHM_ADMIN_PASSWORD(§4.2). - What changes on the upgrade without a switch. The database is the settings store and
[Settings]inhMailServer.iniits copy (chapter 21); most saved settings take effect at once; SPF is RFC 7208; the audit trail and alerts are on; compromised-account detection watches; a destination that asks the server to slow down is paused; a backup carries an account’s address book, calendars, preferences and keys beside its mail; IMAP answers as the RFCs say where imaptest found it did not; MySQL and MariaDB tables become utf8mb4; the database connection is encrypted when the database server offers TLS; the administrator password is hashed again at sign-in; a COM change to a shared configuration object takes effect atSave(); ISO-8859-1 is decoded as windows-1252; a restore refuses an unsafe archive whole; a password hash made before 6.3.4 of a password with a character above U+00FF is refused until the password is set again; a one-time code is taken once; the outbound relay sends its OAuth2 token over TLS only; and a domain may not be named like one of the server’s own directories. §18.5 says what to do where anything needs doing. - Security: a password could run code inside the server. On a Windows server scripting in
JScript with an
OnClientValidatePasswordhandler, the server escaped the apostrophe and not the backslash in the values it built each call from, so any client that could reach a sign-in and name an existing, active account could have JScript of its own run insidehMailServer.exebefore it had authenticated — withScriptAllowedObjectsat its shipped*, any command the service’s account may run. The same shape was open throughOnExternalAccountDownloadandOnDeliveryFailed, and a rule’s Run function action wrote its name into the call unescaped in either language. The flaw is in every release from 6.0.0 to 6.3.3: the escaping was checked at every one of those tags (GitLab issue 27), so treat any server older than 6.3.4 with event scripting on and JScript as its language as affected. VBScript servers were not open through a password, and the Linux builds had no script engine. Every JScript value is now escaped in one place, and a Run function name must be a plain identifier. The flaw is CVE-2026-100741; the CVE record was not yet published on 27 September 2026. Until you can upgrade, switch event scripting off. A server that cannot do without its scripts should take theOnClientValidatePassword,OnExternalAccountDownloadandOnDeliveryFailedhandlers out of a JScriptEventHandlers.jsand reload the scripts, and, in either language, let nobody but the server administrator make or edit a rule with a Run function action. More: hMailServer 6.3.4: a password could run code on Windows servers with JScript scripting on the forum. - Fixed before release. 6.3.4 was audited area by area before it was cut, and its code
scanned by GitLab’s SAST, and the defects they found are fixed in it; several were in 6.3.3 as well. A
password of Cyrillic, Greek or CJK letters could be opened by any other password of its length, because
every hash was made over a conversion that lost those characters; hashes are now made over UTF-8.
SCRAM-SHA-256 took an account’s own password past its second factor and its maximum age; one-time
codes could be used again within their window and, at several places, guessed without limit; a single
sign-on ticket could be redeemed in another person’s browser; a domain administrator could become the
server administrator through an app password on the server administrator’s own mailbox where it is
in that domain; an app password or a support session could read the account’s wrapped keys, and
replace some of them; fetch accounts could reach inside the network or collect a Microsoft 365
mailbox with the server’s own token; and two IMAP sessions meeting on one message’s keywords
could abort the server. Seven of the SAST analyzer’s High findings were real and are fixed — the
crash-dump helper’s DLL search, the Control Panel’s elevated restart, the administrator password
on
DBUpdater.exe’s command line (two), the backup index parser (two) and a MIME parser test that compared pointers into two arrays — and the security exceptions file accepted 455 findings at the release as not exploitable, each with its reason. Found beside them and fixed too: the MySQL client’s DLL search, the password onDBSetupQuick.exe’s command line, and a custom virus scanner handed a path cut at its first space, which scanned no file and passed the message as clean. The installer’s 7-Zip is 26.03. - A new installation is secure before anyone configures it — a self-signed certificate,
STARTTLS on 25, 587, 110 and 143, TLS required for authentication from the Internet — and on Windows it
runs as
NT SERVICE\hMailServerrather than LocalSystem (§17.3). Administrator roles over 27 permissions, passkeys and OpenID Connect and SAML sign-in, SCIM provisioning, account recovery, sessions and devices, and a check of new passwords against published breaches. - Mail. Remote-domain policies, throttling per destination and per tenant, IP warm-up, a send API for applications, DKIM key rotation, a domain’s external-sender tag and disclaimer, restricted delivery, message recall, journaling, quota and out-of-office policies, and mail in to a webhook. Against spam: greylisting hardened, SMTP policy a score cannot express, allow and block lists with quarantine digests, mail flow rules and an outbound content policy, impersonation protection, attachments opened in a sandbox, spam traps and country blocking.
- The webmail. Calendars over CalDAV beside CardDAV, with shares, invitations by mail, free-busy, and rooms and equipment that answer their own invitations; contact groups, a directory and shared address books; recover deleted items, messages that expire, offline mail and Web Push; a second account in the same page; masked addresses and sending as another address; OpenPGP in the browser; and Arabic, Hebrew and Persian with the layout mirrored.
- Administration and compliance. An audit trail that can be verified as a hash chain,
alerts on eleven conditions, one value set for the server, a domain or an account, profiles,
hmctland a PowerShell module, a setup checklist the server judges, the Control Panel on one design system with 65 pages, the Control Deck in twenty languages besides English, eDiscovery with legal holds, and a person’s data exported and erased. - Storage and encryption. Messages on an S3-compatible store or Azure Blob Storage; storage
compression and attachment de-duplication; mailbox encryption at rest, zero-access mailboxes and gateway
encryption to partner domains; an organisation CA for S/MIME; and the TOTP secrets and the
[Settings]secrets sealed under a stored-secrets key. - Migration and operations. Mailboxes migrated from another IMAP server, Google Workspace or
Microsoft 365 as jobs that resume, and PST import with a reader of the project’s own. Event scripts on
Linux, in JavaScript (§28.2); the live update applies on Linux through a root helper (§35.13); a read-only
SNMPv3 agent, syslog, outbound webhooks and per-domain reports; upgrades that apply the schema at start
(
[Database] UpgradeOnStart=1); and encrypted backups. - Platforms. The Windows x64 installer, and on Linux an x86-64
.debfor Ubuntu 26.04 and 24.04 and an x86-64.rpmfor Fedora 43 and 44 and Enterprise Linux 10; both need glibc 2.38 or later, so neither installs on Debian 12 or Enterprise Linux 9. There are no AArch64 packages and no AppImage in 6.3.4 — the project has no ARM machine and the tool the AppImage is made with is published only on GitHub — so 6.3.3’s are the latest of those. Windows on ARM compiles but has never run, and 6.3.4 has no ARM64 installer. No container image of 6.3.4 is published, and there is no apt or dnf repository yet. The update feed ishttps://updates.progressiverobot.com, served by GitLab Pages, and its/download/links lead to the newest stable release’s files. - Verification.
hmailserver-6.3.4-SHA256SUMS.txtgives the SHA-256 of every file of the release but itself and its signature, and is signed with the release tags’ SSH key (hmailserver-6.3.4-SHA256SUMS.txt.sig, checked withssh-keygen -Y verify); the installer, the.deband the.rpmeach have an SBOM of their own (<file>.cdx.json); andGet-AuthenticodeSignatureon the installer should answer Valid, signed by Progressive Robot Ltd. There is no Sigstore bundle to verify (§4.1).
6.3.3 — large messages over BDAT, a webmail rebuilt, CardDAV
- Read this first if you are upgrading from anything older than 6.3.1 (GitHub issue 263). 6.3.2 could not upgrade a database whose schema was older than its own: the guard added in 6.3.2, which makes a database the server has refused answer COM with the refusal instead of crashing, covered the whole Settings object, and DBUpdater reads the script directory from it before it runs the first script — so every upgrade from 5.x or 6.2.x stopped with The server has not loaded its configuration and left the database untouched. 6.3.3 upgrades such a database in one go; nothing in the database was changed by the failed attempt. A regression test now walks DBUpdater’s path over COM against a database the server has refused.
- Read this too if you run 6.3.1 or 6.3.2 and use the Control Panel’s live update. The first real update, 6.3.1 to 6.3.2, failed with the installer’s exit code 5 because the Control Panel that started it kept its own files open. That is fixed here, but the helper that runs a live update is the one already installed, so an installation on 6.3.1 or 6.3.2 has to be updated by hand once: close the Control Panel, run this installer. Live updates worked from then on until GitHub suspended the project’s account on 18 September 2026; every server installs 6.3.4 by hand (above).
- Delivery: an outbound BDAT chunk larger than one send buffer no longer stalls (GitHub
issue 261). Every message over 60,000 bytes to a server advertising CHUNKING — Gmail, iCloud, Outlook.com,
Postfix — had stalled after its first buffer and timed out at the remote, ever since outbound BDAT
arrived in 6.2.28; a message that fit one buffer went, which is why short ones did. The operation
queue now runs once more after a read has started, so a write behind it goes at once. Two regression
tests relay a 222 KB message over BDAT, pipelined and not. Until you upgrade,
OutboundChunking=0inhMailServer.iniis the workaround. The full-text indexer also no longer reports an error for a message deleted before its terms were saved. - The webmail, rebuilt to the shape of Gmail and Outlook.com. The page is real files now
(
Portal.html,Portal.js), embedded into the binary at build time: a top bar with one search box, a navigation column, a list with star, avatar, snippet and hover actions, a reading pane on the right, below or off, a toast with Undo after every move, the keys a mail client has, light and dark. Compose docks, minimises or pops out; replies are written under the message they answer; an attachment reminder asks once. The list gains drag-to-folder, a right-click menu, pin ($Pinned), block sender, sweep, shift-click and ctrl-click selection. Mute ($Muted) and an inbox in tabs — Primary, Social, Promotions, Updates and Forums, or Focused and Other — sorted by the server from the headers alone. Search offers the last ten searches, completes a contact’s name tofrom:, and understands fourteen more operators (cc:,bcc:,filename:,larger:,smaller:,older_than:,newer_than:,is:muted,is:pinned,category:,has:link,in_reply_to:,-word,OR). Follow-up dates ($FollowUp,$Due-YYYY-MM-DD), nudges, quick steps and clean-up conversation round it off, in twenty languages, with 224 CI checks on the page’s script. - CardDAV (RFC 6352): the account’s address book on phones and desktop clients. On the
web services listener
/.well-known/carddavredirects to/dav/; the principal answers the address-book home, which holds one book, Contacts — the one the webmail keeps. The card a client sends is the card it reads back (stored beside the contact, schema 6040, vCard 3.0 or 4.0). HTTP Basic as the account or an application password, over HTTPS only, with the IMAP logon’s lockout and auto-ban; addressbook-query, addressbook-multiget and sync-collection reports; ETags with If-Match and If-None-Match. iOS, macOS Contacts, DAVx5 and Thunderbird speak it. The web services HTTPS listener has to be on (WebServicesHttpsPort). - The Linux Control Deck, measured and moved.
build/check-deck-parity.pycounts every field the desktop Control Panel writes against the REST API and the Deck;hmailserver/docs/DeckParity.mdis its report. Of 330 properties, 240 were writable over REST and 153 reachable from a Deck view at the start of 14 September; 328 and 322 by its end, the two not writable being groups and their members. New write routes for the anti-virus group, sixteen more account and nine more domain fields, distribution-list settings, DNS blacklists, SURBL servers, white-list addresses, blocked senders, incoming relays, blocked attachments, the greylisting white list, the cache group’s ceilings, an IP range’s expiry, and — under an account, for the administrator — its application passwords, folder permissions and messages. The Deck gained its own harness (292 checks, in CI), full domain editing, an IP-ranges view, fetch-account and backup views, the account editor in full, distribution lists and aliases, and the scripting, cache and indexing groups. - The Linux regression suite’s route backlog is largely lifted. A REST route for every
fixture family the Linux run had skipped, the fixtures rewritten so that one skips only against an
older server whose OpenAPI lacks the route. The hosted Linux run on the release tree: 1,520 tests,
1,289 passed, 0 failed, 231 skipped. Three server defects found on the way (a vacation save PostgreSQL
refused,
ncsarefused as a log format, a negative thread id on Linux) — and one on every Windows installation: a domain’s relay password could not be saved, because the column was 255 characters and a DPAPI envelope is 314. Schema 6039 widens it. - Security, from the release’s own review. The
[Settings]section ofhMailServer.iniover REST answered to any API key — a read-only key could read the OAuth2 HMAC secret, the password pepper and the service account’s password, and a write key could setAutoBanCommand. The four ini routes answer to the administrator password only now. A key restricted to a domain could lift that domain’s limits throughPUT /api/v1/domains/{domain}; it is refused with 403. In CardDAV, a REPORT body of a million ampersands cost a worker thread tens of seconds (the entity scan is bounded), a multiget answered the same href as often as asked (a repeated href is answered once; a multistatus over 64 MiB is refused), and on MySQL the card column could not hold the megabyte the collection advertises (it can). Two tests hold the two key-scope findings. - Database. On PostgreSQL a backslash is stored as one backslash: the escaper doubled it
unconditionally, which is right only while
standard_conforming_stringsis off, and it has been on by default since PostgreSQL 9.1. Values already stored doubled stay as they are — edit and save them once. Schema 6039 (relay-password column) and 6040 (the CardDAV tables) both upgrade in place. - Build and supply chain. One licence header on every source file, checked by
build/add-license-headers.py; the container image job of a tag run asks Docker for the image by a lower-case name (at 6.3.2 the image was pushed but its smoke test was refused the capital letter); the Linux suite’s shims learned the anti-virus group and the nine domain properties. - Verification. The full regression suite ran on the stamped binary: 2,302 tests,
2,294 passed, 0 failed, 8 skipped; the assertion build ran the same suite first with nothing
asserted; three clean Release builds produced the same
hMailServer.exe6.3.3.42, SHA-2562c57f78050ecc43ee6d69ac3a2dc42b1dd802cc35ec369c09457d8119d53510a. The installer has two hashes, deliberately: as built, 79,926,537 bytes,45a9d7f1a78bb31031dde27849fa21828a60aff178f32cbc2b0d9cd93f6b33f2; as published, after Authenticode signing,3188be2fa16a4c43d3b43ddbd7274e4cfe878c082343ef25ffbd00489610a057. The published value is the one to check a download against.
6.3.2 — a webmail people would choose, Kerberos single sign-on, auto-ban at the firewall
- The webmail became a mail client. The self-service portal at
/portalshipped in 6.3.0 with a mailbox page; fifteen waves later it reads conversations grouped by thread, archives, junks and deletes with one key, searches the way a reader writes a search (from:,to:,subject:,has:attachment,before:,after:,is:unread,label:and the rest), and keeps labels as IMAP keywords (schema 6036). Writing has a formatting bar and an editor whose HTML is rebuilt from an allowed subset, a From picker offering exactly the addresses the server would accept from the account, a signature and templates. S/MIME in the browser: the page signs, verifies, encrypts and decrypts with the Web Crypto API; the private key is wrapped under a key derived from the account password and stored on the server so wrapped (schema 6037) — the server cannot open it. It installs as an app, keeps the inbox listing and the last thirty opened messages for reading offline, imports.emlfiles, exports a folder as mbox, takes a name, logo and announcement from an administrator (PUT /api/v1/portal/branding), speaks the Control Panel’s seventeen languages plus Greek, Korean and European Portuguese, and works with a screen reader. Everything is over/api/v1/me, described in the OpenAPI document. - Kerberos single sign-on — SASL GSSAPI (RFC 4752) on SMTP, IMAP and POP3, on Windows.
A client presents a ticket for this server’s service principal, the server proves itself back, and
the client’s principal names the account. Off unless
GssapiEnabled=1in[Settings]; the credentials are the process’s own on a domain-joined host, or a service account named byGssapiServiceAccountandGssapiServicePassword. Proven end to end against a Windows Server domain controller. - An auto-ban reaches the operating system’s firewall. With
AutoBanFirewall=1, Windows gets an inbound block rule per banned address in Windows Defender Firewall, in a rule group of its own; Linux gets a packaged hook that keeps an nftables set, and a fail2ban filter and jail.AutoBanCommandruns anything else, andAutoBanNeverBanlists the addresses that are never banned. All three are off as shipped. Making an app password now requires the account’s own password. A SURBL server’s answers are judged the way a DNSBL’s are, against an expected result (schema 6038) — until now a server resolving through a public resolver tagged every message carrying a link as spam. - The Linux packages install on the next distribution release. The 6.3.1
.debdepended on the builder’s exact Boost sonames and would not install on Ubuntu 26.04; Boost is linked statically now, and the package-install job installs the.debon Ubuntu 26.04 in a container on every run. A container image is built from every tag, run as its own user, smoke-tested beside a PostgreSQL over TLS. The Linux build is green on every push. - Database. The 6029 → 6030 upgrade step sweeps parents before children — the defect
6.3.1 named as known and unfixed is fixed in all four backends, and a gate now executes a schema upgrade
on a seeded database on every push, winding a created database back to 6029 and forward again. Schema
6032 to 6037: contacts, account preferences, scheduled sends and snoozes, files sent as links, message
keywords, S/MIME keys; 6038: SURBL expected results. The fresh-schema create scripts drop dependent
tables before
hm_accounts, and every statement in a MySQL script is followed by a blank line, with a check that keeps it so. - Build and supply chain. Thirty-seven of the forty committed binaries left git; a
manifest (
hmailserver/docs/third-party-binaries.json) says how each is obtained andbuild/get-installer-binaries.ps1fetches them from the build-inputs-1 release against the manifest’s SHA-256. Beside every cosign bundle a.sigstore.jsontwin, and SLSA build provenance for the assets as attached, verified with slsa-verifier after upload. Every Linux job runs behind Harden-Runner with its service images pinned by digest. - Quality and tests. Code Quality is at zero findings and stays there; native coverage can now be measured; the regression suite can drive a console server; the pre-flight refuses to pass while WSL is running. A server holding a database it refused answers COM with the refusal, not an access violation (HM5011) — a guard that reached too far: it covered the whole Settings object, so 6.3.2 could not upgrade a database whose schema was older than its own. Upgrade straight to 6.3.3 or later, which puts it right. The Control Panel’s first live update, 6.3.1 to 6.3.2, also failed with the installer’s exit code 5; 6.3.3 fixes that too.
- Documentation and governance.
SECURITY.mdstates the vulnerability management policy the automation enforces and an OpenVEX statement accompanies it;GOVERNANCE.mdnames two maintainers; the release checklist says that releases are immutable once published. - Verification. The full regression suite ran on the stamped binary: 2,219 tests,
2,211 passed, 0 failed, 8 skipped; the assertion build ran the same suite first with nothing
asserted; three clean Release builds produced the same
hMailServer.exe6.3.2.41, SHA-2564c7d5a2c7fa6537e460eadd6dcc85d5aedb3559fe349e2dec9547fa99086d3e8. The installer as built, 79,683,220 bytes:746f17303d219930aaed03d836f3c2c55e367bcd59620ba60e16eae7efb5f589; as published, after Authenticode signing:d10eb643c7d4717655da19b8facbd4916d393024ba7d18ac5ccde492f2140de9. The three MIME fuzzing harnesses ran thirty minutes each on this release’s source: no crash, no hang, no violated assertion.
6.3.1 — a signed installer, an unchanged server
- The Windows installer carries an Authenticode signature — the first release of
this project that does. It is signed with Azure Artifact Signing against a certificate profile
issued to Progressive Robot Ltd after Microsoft’s identity validation, and it is
countersigned (RFC 3161). On the downloaded file
Get-AuthenticodeSignaturereads Valid, signerCN=Progressive Robot Ltd, O=Progressive Robot Ltd, L=Chester, S=Cheshire West and Chester, C=GB, issued by Microsoft ID Verified CS EOC CA 03 and countersigned by Microsoft Public RSA Time Stamping Authority — which is what keeps the signature valid after a signing certificate that lives about seventy-two hours has expired. What it changes for a reader: the elevation prompt names the publisher instead of reading Unknown publisher, and an enterprise policy that refuses unsigned binaries outright no longer refuses this one. - SmartScreen still warns. Said plainly, because the signature is easy to overread: Microsoft’s own comparison puts a signed installer in the same row as an unsigned one, flagged unrecognised until reputation accumulates, and reputation attaches to a file that does not change — which a new 80 MB installer every few weeks never is. An EV certificate would not help; Microsoft removed EV’s SmartScreen bypass in 2024. Expect the same Windows protected your PC screen, now carrying the real publisher name.
- Only the Windows installer is Authenticode-signed, and Sigstore is unchanged.
There is no Authenticode for a
.deb, an.rpmor an AppImage, so Sigstore remains the check that means something for every asset on the release: each carries a.cosign.bundlebeside it, andcosign verify-blobagainst that bundle is the verification to run. Releases before 6.3.1 are unsigned and will stay unsigned — a published release here is immutable, so 6.3.0 could not be signed retroactively and never will be. UpdateRequireAuthenticodebecomes usable. The setting has existed since 6.2.28 and defaults to0; until this release, setting it to1made the server’s own update path refuse every release of this project, because none carried a signature — the check isWinVerifyTruston the downloaded installer, and an unsigned file is refused with the file carries no Authenticode signature. From 6.3.1 the installer it downloads carries one. The check is Windows-only: on Linux there is no Authenticode to verify, the server says so rather than reporting a pass, and updating is the package manager’s job.- Nothing in the server changed. The compiled server differs from 6.3.0 by its
version stamp and one comment line. Everything else in the release is the path a release travels
— the workflows that sign and verify it, found wanting while preparing to use the certificate for
the first time — plus three documentation corrections: the README offers the Linux packages on its
download line rather than 140 lines further on; two files that named a version
6.2.29, which does not exist and never will — the
[Directories]header comment in the server source and the relocating guide’s note on when a relative[Directories]path began to resolve against the program folder (a 6.3.0 change, described below) — now say 6.3.0, where both shipped; and the release checklist givesARTIFACT_SIGNING_ENDPOINTas a full URI rather than a bare host, which is the form the signing action documents. Comment and prose only. Among the release-path repairs, each of which could have put out a permanently wrong release: the gate that guards signing checked the wrong four of the six settings it protects and let five half-configured states through; the release is now verified as it stands on the release page rather than as it left the signing job, in both directions, an asset with no bundle and a bundle whose asset has gone; both SBOMs are a hard failure before a code-signing certificate is spent on the release; the countersignature is proved before the release asset is replaced; a verification step that matched nothing can no longer pass having checked nothing; and runs are serialised per tag rather than interleaved. So do not read the version bump as new server behaviour — there is none. - Verification, and a download that now has two hashes. The suite ran on the
stamped binary: 2,175 tests, 2,166 passed, 0 failed, 9 skipped (the seven
explicit IMAP stress tests and two ignored), in 40 minutes, with no ERROR log written. The
assertion build ran the same suite first with nothing violated, and two clean builds produced a
byte-identical
hMailServer.exe6.3.1.40, SHA-2568b0ab1a2d1f3645f1e12c3bca1315023474bb3a63abf0efb44f60ba0b2d83235. No parser changed, so 6.3.0’s fuzzing run stands, which the checklist allows for a patch release. The schema is still 6031 and nothing new is on by default. The installer, though, now hashes two ways, deliberately: as built it is 79,408,192 bytes, SHA-2563986fbcccda9e46fee29c1e538a79f2423ebc4d5e3f6e59306ffba4cc7bd8cba; as published it is 79,423,896 bytes, SHA-25668c72a60e7a8530bdd32d28b60eaadab5b9c90505e174c03196e5dfa58ac9b70. The signing job replaces the installer asset with the signed one before cosign runs, so the bytes on the release are not the bytes the build produced — this is the first release for which that is true, and the published hash is the one to check a download against. - Known and not fixed here: the orphan sweep in the 6029-to-6030 upgrade step runs children before parents (chapter 18). Before that step adds its seventeen foreign keys it deletes rows whose parent is gone; three of the parent tables are themselves pruned by those deletes, and each one’s children are cleaned earlier, against a parent that has not yet lost its rows — so pruning an orphaned account, fetch account or distribution list re-orphans rows nothing revisits, and the constraint that follows is refused. The scope, stated completely: it fires only on an upgrade from a schema below 6030 on a database that already holds orphaned rows; a database without them upgrades correctly and a fresh installation is never on this path; it is in all four database backends; when it does fire it fails loudly, in the database engine’s own words, and rolls back rather than doing anything quietly; and no installation has reported hitting it. The fix is a reordering of seventeen delete statements and shipped in 6.3.2, with the gate that executes a schema upgrade on a seeded database on every push — the gate that was missing when the defect got in. It matters here because an installation still on schema 6011, which is where 6.2.19 to 6.2.21 sit, crosses this step on the way up: take the database and data-directory backup chapter 18 asks for.
- The report that led to that review — a SQL Server Compact upgrade from schema 6011 refused at the same step — turned out to be GitHub issue 114, already fixed in 6.2.26: the upgrade had succeeded, the verification probe crashed the database provider afterwards, and the tool then printed a cause it had no way to know. That installation upgrades on 6.3.0 and on 6.3.1.
6.3.0 — Linux, a REST write surface, the Control Deck, webmail
- hMailServer runs on Linux (chapter 35). A server that had been Windows-only since 2002 now builds, installs, runs and delivers mail on x86-64 and AArch64 Linux from the same source tree — which is the whole reason this is 6.3 and not 6.2.29. Every core translation unit is counted as it compiles, one file at a time, under clang on x86-64 and on a native AArch64 runner rather than a cross-build, and the job fails when a single file stops compiling; a third job builds and links the whole core with GCC on x86-64, which is what stops the port quietly becoming clang-only. The Windows build is the same MSVC project it was — nothing in this release changes what it compiles or how it behaves.
- Installed the way a Linux service is installed. A
.deband an.rpmfor both architectures, aPKGBUILDand an AppImage, carrying the binary, the schema scripts, a systemd unit that runs the server as its own user, a logrotate rule, the configuration under/etc/hmailserver, and the Control Deck.hmailserver --create-database,--upgrade-database,--set-admin-passwordand--check-configdo the setup. Proven rather than assumed: against PostgreSQL 18 and MariaDB 11.8 the schema is created, a domain and account made over the API, a message submitted over SMTP, delivered and read back over IMAP; directory authentication runs against a real slapd over StartTLS and LDAPS. Stored secrets are AES-256-GCM under a key file the installation owns, which is what DPAPI does on Windows. The AArch64 packages are built and installed by CI on an ARM runner; nobody has installed one on ARM hardware by hand yet. - The REST API writes what only COM could write (chapter 24).
PUT /api/v1/settingsand its anti-spam and logging groups write 108 settings, each through the same setter the Control Panel calls and applied only when every key in the request is accepted — the three groups describe 113 keys in all, of which five are read-only facts about where the log is written rather than settings, and the relayer password is written and never read back. Also global rules with their criteria and actions, SMTP routes with their address lists, aliases, accounts including the password and administrator level, certificates and the listeners that bind them, andPOST /api/v1/server/reinitialize— what the Control Panel’s Reinitialize does, so a new listener takes effect without stopping the process. Every route is refused to a read-only key, refused to a key issued for other domains, and scoped to its own domain where a domain owns it. The OpenAPI document is now valid JSON; it never was before this release. What it still does not write is said as plainly: a domain’s own properties beyond its active flag and its postmaster, and above all DKIM signing — a read route and no write route — and with it the per-domain size limits, the domain signature and a per-domain relay host. - The Control Deck writes, and signs in properly (chapter 36). The page the
REST listener serves at
/gained editors for most of that: rules, routes and listeners are created, edited and deleted, a certificate is added and deleted, an account is created and deleted under a domain, and a button restarts the services in place. (The release notes say certificates are edited too; there is no route that edits one, on the page or off it — replace a certificate by adding the new one, repointing the listeners and deleting the old.) Its settings forms are generated from the server’s own OpenAPI document, so every setting the API accepts appears with its type, its permitted words, whether it is read-only and whether it takes effect on restart. It no longer holds the administrator password in the browser:POST /api/v1/sessionexchanges it once for anHttpOnly,SameSite=Strictcookie that ends when the administrator password changes, a write must carryX-Requested-With, and an API key cannot mint a session. Four write surfaces the API has are not on the page yet — domains, aliases, distribution lists, and changing an account that already exists. (The release notes count three, omitting distribution lists; the page carries no control for those either. Chapter 36 lists all four with the routes that do the work.) - The
/portalpage gains what a webmail needs beyond reading and sending (chapter 36). It has addresses —#/f/<id>,#/m/<id>,#/search,#/compose— so a folder or a message can be linked to, Back and Forward work, and a half-written reply survives a reload. Folders are created, renamed and deleted from the page itself (POST /api/v1/me/folders,PUTandDELETE /api/v1/me/folders/{id}), a name judged exactly as IMAP’s CREATE, RENAME and DELETE judge it and refused in IMAP’s own sentences; the page offers no delete for the inbox or for a folder the server has designated for a special use, because a page has no undo.GET /api/v1/me/changeshashes the per-folder message and unseen counts into an opaque token and answerschangedagainst the one handed back, which the page polls every six seconds and stops polling while the tab is hidden. Inline images work by a route that was measured rather than assumed: the body is rendered in a frame sandboxed withoutallow-same-origin, so its subresource requests are cross-site, so theSameSite=Strictsession cookie never travels and the attachment route answers401— the page therefore fetches the attachment with its own credentials and hands the frame the bytes as adata:URL. What is inlined is decided by the server’s ownContent-Typeon the download rather than by the message’s claim, and remote images stay off. The markup and the script are two responses,/portaland/portal.js, so the page’s Content-Security-Policy can allow no inline script at all; both still come out of the binary rather than out of files beside it, and the page fetches no font, no image and no stylesheet from anywhere.build/check-portal-script.pynow lifts the two out of the C++ literals and runs the script against a small DOM and a stubbed API — 88 checks, where what stood there before was a test that asserted certain substrings appeared. - Three Linux-only defects fixed before anyone could meet them: a non-ASCII folder name was
mangled in both directions by the modified UTF-7 conversion, a command line with a byte above
127 was cut short (which truncated an IMAP
SEARCH CHARSET UTF-8and aMAIL FROMunder SMTPUTF8), and a TLS key-exchange group list OpenSSL rejects was logged once per listener and per outbound delivery — hundreds of medium errors an hour on the OpenSSL Debian and Ubuntu ship, for a condition the fallback already handled. On Windows, the administrator’s user name is no longer translated in the Control Panel’s sign-in box, which had made a fresh Chinese, German or Swedish installation refuse the credential it had just asked for (GitHub issues 156 and 177), and a diagnostic lists every directory the server was configured with and whether it exists (GitHub issue 158). - A relative path in
[Directories]now resolves against the program folder. The five directories other thanProgramFoldermay be written relative from 6.3.0, so a new installation can be made movable without seven absolute strings that have to agree. An existing installation is untouched — its absolute values are left exactly as they are, because rewriting a configured server’s paths during an upgrade is not something an upgrade may do. (Two files described this as arriving in “6.2.29”, a version that does not exist; 6.3.1 corrected them. It shipped in 6.3.0.) - No schema change — 6031, as 6.2.28 — and nothing new is on by default. The
REST API still needs
RestApiPort, which is what the Control Deck and the portal are served on, so a Windows server upgraded without touching its settings behaves exactly as 6.2.28 did. The suite reports 2,175 tests, 2,166 passed, 0 failed, 9 skipped, run first on an assertion-enabled build with nothing violated; it also ran against the Linux server on PostgreSQL, 260 of its own files compiled in place under a REST-backed fixture layer, for 756 passed, 0 failed, 649 skipped of 1,405 — and those skips are the API’s gaps, not the server’s. Three limitations the release states plainly: per-domain DKIM cannot be configured on Linux (a read route, no write route, aPUTanswers404), so a domain that must sign its outbound mail is not one to run there today; moving a Windows installation to Linux is not a supported migration — same schema, same on-disk message format, no tested path; and its installer carried no Authenticode signature, leaving the Sigstore bundle — the check that meant something on every asset of every release from 6.2.19 to 6.3.3 — to do that work alone. Two of those have since changed. 6.3.1’s installer is signed, though a published release is immutable, so 6.3.0’s own installer stays unsigned; and since 6.3.3 a domain’s DKIM signing is written withPUT /api/v1/domains/{domain}or on the Control Deck (§36.5). 6.3.4 carries no Sigstore bundle at all; the 6.3.4 entry above says how it is verified.
6.2.28 — self-update, webmail, and seventeen languages
- The server can update itself, and every step of it is opt-in.
UpdateCheckEnabled=0is the default and nothing happens at all until it is set — no request, no identifier, no counts. Turned on, a scheduled task reads the project’s release feed everyUpdateCheckHours(24) and the Server status page reports what it found; fetching an installer and applying it are two further opt-in steps. What is applied is what the project signed: the installer is verified against its Sigstore bundle — chain to Fulcio, identity and issuer the release workflow, entry in the public transparency log — before it runs. The apply survives its own failure, reinstalling the previous version if the service does not come back withinUpdateServiceWaitSeconds(180), and the database upgrade authenticates with a single-use token revoked at every service start rather than the administrator password. The upgrade to 6.2.28 is manual for everybody. - An account can use its mailbox in a browser —
/portalon the REST listener, with/api/v1/mebehind it. It answers to an account’s own credentials and nothing else, no administrator password and no API key, and signing in exchanges the password for a session cookie so the password is sent once. Folders with counts, threads, flags, move, delete, search over the account’s own mail, attachments served under a type a browser will not execute, and shared and public folders alongside its own; it sends text or HTML with attachments, every address through the checks a submitted message meets. It also carries the account’s quarantine, forwarding, signature, vacation reply, active Sieve script and a password change — which is why the password-expiry text now says an expired password is renewed by an administrator or by the person themselves, if they hold an app password. - A real HTTP server underneath. The REST API and the web services
(autoconfiguration, autodiscover, the mobileconfig profile, the CalDAV and CardDAV redirects)
had been running on a single-threaded HTTP/1.0 loop. They now run on HTTP/1.1 over Boost.Asio
with its own
io_contextand four workers, keep-alive, chunked bodies, header and body limits, separate header and body deadlines, andCache-Control: no-storeon every API answer. That is what made the other two possible. - The Control Deck arrives as the page the REST listener serves at
/, reading the server’s settings, the log files, the certificates, and the global rules in evaluation order. At this release it reads only — writing is the Control Panel’s and COM’s, and because a browser session for the administrator was still owed it held the administrator password insessionStoragewhile open. Both are fixed in 6.3.0. Also IMAP COMPRESS=DEFLATE (RFC 4978), with STARTTLS refused once a session is compressed, and the Control Panel in seventeen languages beside English — every page, dialog, message, hint and verdict sentence, 3,376 texts and not a subset, with CI checkers that fail the build on an untranslated new caption. - A masked password was typed backwards from the second character (GitHub issue 156):
12345678became18765432with the password hidden, while revealed text was fine — the box restored the caret to the index it had read before the keystroke. Saving the SSL/TLS page dropped the post-quantum key-exchange groups and saving the Scripting page took every COM object from every script, both because a settings editor shipped without the server’s absent-key default and saving a page writes every field. Five defects in the live update and ten in the portal were found by reading them back against the tree before either had shipped. Schema 6031, unchanged; nothing new is on by default, and a server upgraded without touching its settings behaves exactly as 6.2.27 did.
6.2.27 — DNSSEC proof of absence, IMAP folder mirroring
- A missing DS record is now proved missing, not assumed (RFC 4035 §5.2, RFC 5155 §8). The validating resolver that guards DANE and the SPF, DKIM and DMARC lookups treated a DS query answered with nothing as an unsigned delegation — which is exactly what an attacker stripping the DS from the answer in transit presents, so a signed zone quietly became an unsigned one and took DANE and validated TXT with it. The resolver now keeps the authority section of a negative answer and requires the parent’s proof there: an NSEC at the delegation name with NS set and DS clear, an NSEC3 whose hashed owner matches with the same bits, or an Opt-Out NSEC3 covering the hash, each signed by the parent’s key. A proof that fails to verify, has expired, claims a DS exists or belongs to another name is no proof, and a delegation without one under a signed parent is Bogus rather than Insecure. An unsigned parent still makes everything below it Insecure, and an unreachable resolver still degrades to Insecure rather than lying. Twelve tests run against a chain the suite signs itself.
- An external IMAP account can mirror every folder
(
FetchAccount.MirrorFolders, schema 6031; in the Control Panel, Mirror every folder). 6.2.25 collected the remote INBOX and nothing else. With mirroring on, the server lists the remote mailboxes and collects each into the local folder of the same name: every message byte for byte, its\Seen\Flagged\Answered\Draft\Deletedflags, its internal date, the remote hierarchy delimiter mapped to the local one. Nothing is delivered — no header, rule, anti-spam or anti-virus touches a copy, because it is mail the other server already accepted. Each folder keeps its own collection record, so a second poll takes only what is new. This is theimapsyncshape, and the migration route the project never had. - The Import Tool reads a Maildir — the directory with
cur,newandtmp— importing the INBOX and every Maildir++ folder beside it, with the flags the file names carry and line endings made CRLF.hmailserver/docs/Migration.mddocuments every route: from another IMAP server, from mbox, from a Maildir, from Outlook through IMAP, from the upstream hMailServer in place, and accounts in bulk. PST is deliberately not parsed, and the page says why. - A message imported into a folder a client has open now appears at once; the import told
nobody the folder had changed, so a session with the list cached saw the copy only after a
restart. Schema 6030 → 6031 is one column on
hm_fetchaccounts, added on every backend.
6.2.26 — the Compact upgrade that reported its own success as failure
- Every SQL Server Compact upgrade through schema 6030 was reported as failed after it
had in fact succeeded, and the false verdict took the service down (GitHub issue 114). The updater
proves each step by running a probe statement; the four probes for schema 6030 — the foreign
keys — were written with
case when exists (subquery)in the SET expression, which is valid on SQL Server, MySQL and PostgreSQL and an access violation inside the OLE DB provider on SQL Server Compact, on a correct database with every constraint present. The server reported it asHM10045 Unknown error, the updater declared thatUpgrade6029to6030MSSQLCE.sqlhad not createdfk_hm_accounts_domain, the installer said the database could not be upgraded, and two seconds later the crash oracle — which treats a memory-safety fault as fatal — ended the service. The probes are now written so that a present constraint matches no row and an absent one fails by division by zero, on every backend, leavinghm_dbversionuntouched. - If the 6.2.25 installer failed on your database it is at schema 6030 with its foreign keys in place, and this installer finds nothing left to upgrade; if you restored a backup from before, the whole chain runs and verification passes.
- The verification itself was tightened as part of the fix: the create-script check now runs every probe against the database it builds, through the provider the server uses, with a negative control that must fail, and a regression fixture runs the probes through the same COM path the updater takes. Both fail against the 6.2.25 statement. Nothing else in the server changes, and the schema stays 6030 — the release changes the verification probe, not the schema.
6.2.25 — the ACME crash, IMAP fetching, schema 6030
- ACME issuance and renewal ended the process (GitHub issue 93). Two calls in the ACME
client handed the OpenSSL DLL a
FILE*opened by the server’s own C runtime — the DANE TLSA computation logged straight after issuance, and the private-key re-read at the start of every renewal, which is the default. OpenSSL routes stdio back into the executable through anOPENSSL_Applinkexport this executable has never provided, and its answer to the missing export is not an error return: it logs “OPENSSL_Uplink(…): no OPENSSL_Applink” and callsTerminateProcess. The whole symptom set was an OpenSSL Application-log event whose message looks blank, a 7031 from the service control manager in the same second, no crash dump, and no “ACME (automatic)” certificate record. Both calls now use OpenSSL’s own file I/O, and deployment runs before the TLSA line. If 6.2.24 issued a certificate before dying, the files underData\ACMEare valid and 6.2.25 deploys them at its first ACME check after start-up. Two simultaneous restarts — one over COM, one from an ACME deployment or a backup restore — could also end in an access violation; restarts now run one after the other. - Mail flow. External accounts can be collected over IMAP by UID, with LOGIN or
XOAUTH2 over STARTTLS or TLS — the INBOX only at this release; folders are not
mirrored until 6.2.27. Message retention by stored creation time and never by the sender’s
Date; an archive index with one row per copy, searchable over COM andGET /api/v1/archive, with a legal hold that survives retention and address erasure. Outbound pipelining and chunking are on by default where the remote advertises PIPELINING and CHUNKING.SEARCH BODYandTEXTnow read text-bearing attachments, index on or off. - Authentication. SASL EXTERNAL on SMTP, IMAP and POP3 — and with it the AUTH
line limit raised from 500/510 octets to 12288, RFC 5034’s figure, so a real RS256 bearer token
fits. OAuth2 JWKS and token introspection, both off unless configured; scrypt as a password
hash, with schemes compared by strength rather than by number; tarpitting on the connection’s
own timer, off by default; and the administrator’s second factor is now the
server’s — COM needs
AuthenticateWithCode, REST needs anX-hMailServer-OTPheader.SmtpAuthenticatedSenderCheckconfines an authenticated session to addresses its account owns, andScriptAllowedObjectsbounds what an event script mayCreateObject. - Schema 6025 → 6030 in five steps, one way: a settings row, two retention
columns,
hm_metricsamples,hm_archiveindex, and then seventeen FOREIGN KEYs with ON DELETE CASCADE, which removes the orphan rows they would refuse and reads every child table once on a large database. The REST API reaches what was COM-only for reading — IP ranges, distribution lists, certificates (never the private-key password), DKIM, global rules, logs, backup, a settings snapshot — while writing settings, rules and certificates deliberately stays COM-only until 6.3.0, and writing DKIM until 6.3.3. - OpenSSL 4.0.2 (a security patch release closing eleven CVEs, the most severe Moderate) and
Boost 1.92.0; every TLS context built with SSLv2, SSLv3, TLS 1.0 and TLS 1.1 off; all fourteen
open CodeQL C++ findings dealt with and the weekly C++ analysis running again on a hosted
runner. Eight behaviours change on upgrade without a switch, of which two are worth knowing:
the Apple
.mobileconfigprofile is served over HTTPS only, and the four HTTPS clients — ACME, the OAuth2 token client, the MTA-STS policy fetch and the introspection/JWKS client — require TLS 1.2 or later whatever the mail protocol toggles allow.
6.2.24 — the two pre-release lines land, schema 6011 to 6025
- The first stable release since 6.2.21, and the vehicle for everything the two pre-release lines carried. No stable release ever shipped as 6.2.22 or 6.2.23: the work was done in 6.2.22 pre1, pre2, pre3 and pre6 and in 6.2.23 Alpha 1 and Alpha 2, and 6.2.24 is the code of Alpha 2, cut the same day, plus NuGet lock files beside all nine .NET tool projects with every CI restore held to them. Nothing compiled into the server, the tools or the installer changed otherwise. (pre4 was withdrawn — its installer could not create its own database — and pre5 was republished as pre6 so the SBOMs and Sigstore bundles could be attached.)
- Mail that was silently lost is not any more. A delegated
APPEND,COPYorMOVEfiled bytes under the wrong account, and aMOVEdestroyed the only readable copy. Local delivery could lose a message outright: the recipient row and the queued message were deleted after a failed account-level copy, with the sender already told250. Every Thunderbird Sent copy was refused onceUTF8=ACCEPTwas enabled (GitHub issue 53), a kept-open log was never flushed for up to forty minutes (GitHub issue 33), and the built-in ACME client had never once succeeded against real Let’s Encrypt (GitHub issue 34) — Boulder pretty-prints its JSON, and the challenge locator searched for the compact spelling. - Three anti-spam bypasses closed. A sender could un-mark their own mail by
forging a header into a per-account spam override; a sender chose which address the DNSBL, SPF
and HELO tests ran against, because the
Received-header parser took the first bracketed value afterfrom— the client’s own HELO literal — rather than the address the receiving server observed; andRSETbeforeEHLO, or STARTTLS without a freshEHLO, opened a transaction with an empty HELO host, skipping the HELO-host test and theOnHELO/OnEHLOscript events. - Features, all of them inherited from the pre-release lines. Shared and
delegated IMAP mailboxes, a full-text index, RFC 3464 machine-readable bounces, BINARYMIME,
per-account spam thresholds, a sender blacklist, domain-wide out-of-office, distribution-list
moderation, Windows Event Log integration, OTLP metrics and logs, a PROXY-protocol front for
SMTP, an external HTTP filter hook, eleven IMAP extensions (APPENDLIMIT, LITERAL-, LIST-STATUS,
UNAUTHENTICATE, QUOTA, PREVIEW, MULTIAPPEND, REPLACE, SAVEDATE, METADATA, BINARY), Microsoft
365 XOAUTH2 in both directions, app passwords, per-account TOTP enforced by the server, an
admin-reviewable quarantine, password
expiry and history, a queryable message trace, per-domain outbound relay, DMARC aggregate
reporting, the DMARCbis DNS tree walk, an ACME renewal window sized as two thirds of the
certificate’s lifetime plus ARI, and a disk-space floor. More than fifty new INI settings, 23 of
them from 6.2.23 Alpha 1; most shipped off or inert, but not all. Among those that act as shipped,
MinimumFreeDiskSpaceMBrefuses mail below 100 MB free (the last item below),WindowsEventLogEnabledat 1 sends serious errors to the Windows Event Log as well,DatabaseStatementTimeoutsets a 30-second statement timeout on PostgreSQL and MySQL, andIMAPExpungeRetentionRecordsprunes QRESYNC tombstones to 5,000 per mailbox. - The Control Panel and the database tools have run on .NET 10 since 6.2.19,
which was worth restating here because this was the release most upgrades landed on. The installer
bundles the .NET 10 Desktop Runtime and installs it when it is missing. The server itself is
native code with no .NET dependency, but
DBSetup,DBSetupQuickandDBUpdaterare .NET applications, this release must move the schema, and the server refuses to start against an old schema — so coming from 6.2.18 or earlier the runtime is load-bearing on upgrade: a failed runtime install leaves no running mail server, not a working one without its administration tool. The repair by hand is the Desktop Runtime (x64) fromdotnet.microsoft.com/download/dotnet/10.0, thenDBUpdater.exefrom the installation’sBinfolder, then start the service (chapter 18). - The schema moves from 6011 to 6025 in fourteen steps, one way, and two are
worth a maintenance window: 6024 → 6025 rewrites
hm_messages.messageflagsfromtinyinttosmallinton MS SQL, SQL Server Compact and MySQL/MariaDB — a table rewrite on the largest table you have, holding locks (PostgreSQL is unaffected, its column was alreadysmallint) — and 6022 → 6023 creates the full-text index tables, empty and inert. An older server refuses a newer database rather than misreading it, and there is no downgrade, so take a database and data-directory backup first. Seven behaviours change without a switch, the two most likely to be noticed being503 Bad sequence of commandsfor a client that sendsMAIL FROMorAUTHstraight after STARTTLS without a secondEHLO, and a temporary refusal of mail below 100 MB free (MinimumFreeDiskSpaceMB, set 0 to restore the old behaviour). Control Flow Guard on the server, a reproducible build, signed release tags, SBOMs and Sigstore bundles on every asset, and the COM vtable repaired to be binary compatible with 6.2.21.
6.2.21 — the Postfix hang, dot transparency, Sieve body
- The Postfix relay hang is fixed — found by running a real Postfix. When Postfix
has nothing further for a connection it sends the end of the body, the terminating dot
and
QUITin one TCP segment — and every end-of-data check this server ever shipped examined only the end of the received buffer, so a terminator withQUITbehind it was never seen and the session waited forever. The receive path now finds<CRLF>.<CRLF>anywhere in what has arrived and hands the rest back to the command parser. Verified against Postfix 3.10, now part of the repository’s test rig. The bare-LF spellings are still recognised only at the end of the buffer — that is the CVE-2023-51764 SMTP-smuggling rule, pinned by its own test. - SMTP dot transparency across buffer boundaries, in both directions. A line-leading
dot falling just inside a chunk could be sent unprotected — so
.Xsilently becameX, and a lone-dot body line truncated the message at that point. - Sieve scripts now follow renames and deletes. Renaming a domain or account left every filter behind (mail was delivered to INBOX with no error); deleting one left the script on disk, where recreating the address silently reactivated the previous holder’s filter — redirects included.
- The Sieve
bodytest (RFC 5173, §12.2) with all three transforms; anything unimplemented is refused at upload with an error naming the construct. - An empty-criteria
SORTno longer spins a connection thread forever at 100% of a core; the folder message-list cache can now actually evict (it never could, and its accounting only counted upwards); a DNS answer Windows rejects as malformed is retried once over TCP; the MX-query tool asks the server’s own resolver (Utilities.ResolveMXRecords, chapter 31); and seven things the retired Administrator could do are restored in the Control Panel, including domain rename and pause/resume.
6.2.19 – 6.2.20 — directory provisioning, schema 6011, IMAP THREAD
- Active Directory as an account source (§6.4): preview-then-apply directory sync with a Control Panel page and an optional unattended schedule. Opt-in per domain; nothing is ever deleted; verified against a live Windows Server 2025 domain controller.
- The database schema moves from 6005 to 6011 — the first schema change since
6.2.1 (6004 to 6005). INI
[Settings]values are also stored in the database for remote administration; the file won where both carried a value until 6.3.4, which made the database the settings store and[Settings]inhMailServer.iniits copy (chapter 21). - The custom-DNS-server regression in 6.2.16–6.2.18 is fixed. With one configured, every lookup through it failed — MX, DNSBL, SPF, SURBL — because the rewritten resolver set a destination port the Windows DNS client requires to be zero. Pinned by a negative-control regression test.
- IMAP THREAD (RFC 5256, §34.3), both algorithms, with a pre-ship adversarial review
that caught a stack overflow reachable through a deep reply chain. Sieve
imap4flagsactions now reach the stored message instead of being discarded at delivery. - 6.2.19 fresh installations failed — multi-statement batches in the create scripts
broke SQL CE and MySQL, and a silent install hung on a hidden dialog. 6.2.20 fixes
both (upgrades were never affected), makes all 28 installer dialogs take their default
under
/SUPPRESSMSGBOXES, and installs every release on a clean machine before promotion. Release assets are signed.
6.2.17 – 6.2.18 — bounded waits, and the relay stall bounded
- The relay-stall mechanism, reproduced and bounded. For a trusted incoming relay
the whole spam battery runs after end-of-data, on a bounded pool of 15 threads —
and SpamAssassin’s wait had no ceiling, so a stalled scanner under concurrent load
meant total silence after
354. SpamAssassin now has a hard ceiling, a newFinalizationTimeout(240 s) answers451 4.3.1so the sender retries instead of timing out, and acceptance is timed per stage with slow stages named in the log. - A database that did not answer no longer bounces valid mail. A recipient lookup
that failed was indistinguishable from one that found nothing, so a database briefly
locked by a backup made the server tell senders a valid mailbox did not exist —
mail loss with a delivery receipt. A failed lookup now answers
451, which is what made it safe forDBConnectionAcquireTimeoutto default to 60 s. - Every remaining shared-pool wait is bounded (chapter 21): an absolute outbound session ceiling defeats one-byte-at-a-time peers, and ClamAV, DNS queries, event scripts and external processes all have deadlines. A saturated work queue reports which task holds each thread, with session and peer IP.
- Pre-authentication IMAP command buffering is capped; backup restore validates the
archive before deleting what it is restoring over; a first
/analyzestatic-analysis pass fixed a buffer overrun on long paths and a log-rotation defect. Roadmap.mdrewritten — what the fork is ahead on, the gaps that cost users, and reasoned refusals — including the correction that ARC sealing did not cover relayed third-party mail, because a seal was added only to mail already DKIM-signed for a domain hosted here. That has since been fixed: sealing no longer depends on author-domain signing, and it stays off untilArcSealingEnabledis set (chapter 26).
6.2.15 – 6.2.16 — IMAP sequence sets, files with no owner
- IMAP sequence sets now behave as RFC 3501 defines.
*was parsed as the number zero anywhere except the end of a range, and descending ranges (3:1) matched nothing — soFETCH *returned nothing, whileUID STORE *:* +FLAGS (\Deleted)flagged every message in the mailbox andUID EXPUNGE *expunged them all. All four sequence-set parsers fixed; twelve regression tests pin the behaviour. - A failed restore could destroy the data directory. Restoring messages deleted the live data directory first and only then looked for the backup’s message store. It now confirms the replacement exists before deleting anything, and if a later step fails the extracted copy is kept and its location logged.
- Files with no owner, deliveries that could never succeed. Failed header rewrites,
rejected external-account fetches and unsendable bounces each stranded orphan files;
an unreadable message file hung its outbound connection for up to ten minutes; and a
truncated
BDATchunk could be NUL-padded and delivered. All fixed — a message merely locked by a scanner stays queued rather than bouncing. - ManageSieve now limits authentication attempts — three failures, then disconnect and auto-ban registration, like every other protocol.
- Control Panel pages for settings that previously existed only in the INI — authentication, administrative access (with two-factor reachable after logon), DNS resolver, web services, archiving, and the consistency scan’s findings. 6.2.16 fixed the settings palette raising an error dialog on close. For the recurring “what did this fork change?” question: of 980 server source files shared with official master, 936 are byte-identical; 44 differ and 30 are new.
6.2.14 — 21 defect fixes, and settings you can find
- An adversarial audit found 21 defects, several of which could lose or corrupt
mail: a failed message-file copy crashed delivery and wedged the message in the queue;
IMAP
APPENDansweredOKfor messages never written (Sent Items lost while the client showed them saved); every string SQL parameter on MS SQL and SQL CE was bound from freed memory; a failed transaction start leaked its pooled connection until the pool starved. - Security. DKIM test mode (
t=y) no longer turns a failed signature into a pass that satisfies DMARC; IMAP SASL credentials are masked in the protocol log; every key record at a DKIM selector is tried, so a sender rotating keys no longer loses half its mail; MTA-STS enforcement and MX failover no longer degrade for large recipient sets. - Settings you can find. The Ctrl+K palette searches all 227 settings by label and INI key (chapter 5); settings moved to the page where they are used; “Advanced hardening” became “Advanced INI settings” under Maintenance.
- Multiple smart hosts documented at last (§14.1). Community fixes: backup start was blocked by a call to a method that does not exist (GitHub issue 19), and log retention existed but was unfindable (GitHub issue 16).
6.2.13 — community bug fixes and protocol hardening
- The relayed-mail DATA stall, first pass: the reverse-DNS lookup for the
Receivedheader moved off the network I/O thread, where an internal relay with no reverse zone could stall the whole session. - SMTP robustness: data pipelined with
DATAis consumed correctly; a rejectedBDATdrains its in-flight payload instead of desyncing the session (Exchange uses BDAT whenever CHUNKING is advertised); a parse exception can no longer wedge a connection permanently. - IMAP: a malformed partial-fetch range (
BODY[]<0.-1>) could crash the server or send heap memory from before the buffer to an authenticated client — clamped and normalised.OnClientLogonnow fires from every IMAPAUTHENTICATEmechanism. - SpamAssassin: a malformed
spamdresponse could spin a core, write the raw response header into the message, or replace it with a zero-byte file. The client now parses defensively and the original message is preserved on any failure. - DBUpdater labels schema versions 6002–6005; a failed database create/upgrade
propagates a real exit code instead of reporting success against a broken schema; a
customised
EventHandlers.vbssurvives uninstall/reinstall.
6.2.12 — .NET 8 tools, Import Tool, installer hardening
- Every C# component now targets .NET 8. DBSetup, DBSetupQuick, DBUpdater and the Data Directory Synchronizer move off .NET Framework 4.8.1; the silent command-line behaviour the installer drives at post-install is unchanged. No server-core changes; no database change (the schema stays 6005).
- The VB6 migration wizards are replaced by the Import Tool (§33.8): accounts from a comma-separated text file, messages from mbox files — with every defect documented in the old tools fixed.
- Installer hardening. The .NET 8 Desktop Runtime is now a prerequisite of the server component — installed, with its exit code checked, before the database tools run — not just of the Control Panel. The obsolete .NET Framework 4.5 check is gone, and the supported-OS floor is Windows 10 1607, the runtime’s own minimum.
- CI now builds every C# project with warnings as errors, and the test suite runs on NUnit 4.
6.2.11 — Control Panel accessibility
- Two Control Panel lists announced their class name to screen readers — every row
of the alias list on the Domains page, and every line of Live logs. A
ListViewItem‘s accessible name falls back toToString()on the bound object; both now expose the text a sighted user sees. - The regression suite now runs in full. 27 of its 1026 tests had always reported inconclusive because SpamAssassin and ClamAV were not installed on the release machine. With both installed, two tests failed — defects in the tests, not the server — and all 1026 now execute and pass on every release.
6.2.10 — security and housekeeping
- Fifteen COM methods reported success on calls they had refused. They returned
falsefromHRESULTfunctions, andfalseisS_OK. FiveInterfaceCachegetters returned before writing*pVal, handing an unauthorized caller success plus uninitialized memory;SetAdministratorPasswordand five siblings reported a password change that never happened. Found by running CodeQL’s C++ suite by hand — the workflow had only ever analysed C#, leaving the entire protocol surface unscanned. - The PHP WebAdmin is removed. It stored the administrator password in plaintext in a PHP session and required DCOM to be opened up for the web server account.
- The retired Administrator is removed from the repository.
Interop.hMailServer.dllnow comes fromShared, so the packaged output is unchanged. - Code-quality findings on shipped, hand-written code taken to zero.
6.2.9
- LiveCharts upgraded; dashboard charts no longer render as a white block on the dark theme.
- First automated tests for the Control Panel.
6.2.8
- Fixed list editors rendering every row blank.
- Fixed the Control Panel becoming unusable after an hMailServer service restart.
6.2.7
- Control Panel usability: DKIM key generation with the DNS record shown and copyable, password reveal toggles and a strong-password generator, date pickers, Enter/Esc in dialogs, window state remembered.
6.2.6
- IMAP4rev2 (RFC 9051) as an opt-in session mode.
- Control Panel visual redesign; complete settings coverage; real QR code for 2FA.
6.2.5
- Fixed two defects that broke default fresh installs: DPAPI database-password truncation, and the create-table scripts stamping the wrong schema version.
6.2.0 – 6.2.4
- The Control Panel replaces the classic Administrator.
- SCRAM-SHA-256 and SCRAM-SHA-256-PLUS, Argon2id, OAuth2 bearer authentication.
- Sieve (RFC 5228) and ManageSieve (RFC 5804).
- SMTPUTF8/EAI, PIPELINING, ENHANCEDSTATUSCODES, DSN, SRS, CHUNKING/BDAT, BATV.
- Health probes (
/livez,/readyz,/healthz) and more Prometheus metrics; slow-query log. - Active Directory account pickers.
6.1.0
- The Web Control Deck, served by the REST API listener (
RestApiPort): a live dashboard, domain and account management, queue retry and delete, and the DANE TLSA records to publish. - The classic Administrator gains light and dark themes, a Ctrl+K command palette and a modernised dashboard.
- The installer’s wizard is rebranded and ships the Web Control Deck’s page (the
WebAdminfolder). - The server core is 6.0.0 build 4’s, apart from the endpoint that serves the page; schema 6001.
6.0.0
- Visual Studio 2026 toolchain, OpenSSL 4.0, Boost 1.91, PostgreSQL 18, 64-bit only.
- PBKDF2-HMAC-SHA256 password hashing; TLS 1.2/1.3 defaults.
- MTA-STS, DANE with in-process DNSSEC validation, TLS-RPT.
- DMARC evaluation, ARC sealing, Ed25519 DKIM.
- ACME v2 (Let’s Encrypt) built in; REST API; Prometheus metrics; JSON logging; web services server.
- IMAP MOVE, ID and SPECIAL-USE; SMTP 8BITMIME.
26. How mail flows through the server
When a message goes missing, scores strangely, or turns up somewhere unexpected, the explanation is almost always obvious once you know the order in which hMailServer does things. This chapter follows a message through the server — from the first TCP packet to the mailbox file or the outbound queue — showing where each decision is made, which setting drives it, and where you can intervene.
26.1 The journey at a glance
Sending server hMailServer
────────────── ───────────
│ connect │
│───────────────────────────────▶ │ IP ranges, auto-ban, connection limits
│ EHLO, MAIL FROM, RCPT TO │
│───────────────────────────────▶ │ recipient resolution (§26.4)
│ │ envelope spam tests, greylisting (§26.5)
│ DATA (message content) │
│───────────────────────────────▶ │ content spam tests (§26.5)
│ 250 accepted │
│ ◀───────────────────────────────│ ◀── the message is now hMailServer's problem
│ │
│ anti-virus (chapter 11),
│ rules and Sieve (chapter 12)
│ │
│ ┌──────────────┴───────────────┐
│ ▼ ▼
│ local recipient remote recipient
│ written from the queue into delivery queue ─▶ retries (§26.7)
│ the account's mailbox ─▶ bounce (§26.8)
The most important line in that diagram is the 250 after DATA. Before it,
hMailServer can simply refuse — the sending server still holds the message and the
duty of telling its user. After it, the message is hMailServer’s responsibility, and
anything that goes wrong from then on must be handled by retrying or reported with a
bounce. hMailServer therefore rejects as early in the conversation as it can: it is
cheaper, and it never generates backscatter.
26.2 Two senders, two recipient lists
Every message carries two independent sets of addressing, and confusing them is behind a surprising number of “impossible” support cases.
| Row | The envelope (SMTP) | The headers (MIME) |
|---|---|---|
| Sender | MAIL FROM — where bounces go |
From: — what the reader sees |
| Recipients | RCPT TO — where the message is actually delivered |
To: / Cc: — what the reader sees |
The two do not have to agree, and legitimately often do not:
- A BCC recipient appears in the envelope but nowhere in the headers — the server knows where to deliver, the other readers never see it.
- A forwarded message keeps its original
To:header while the envelope recipients change — the content is unchanged, the destination is not. - A forged
From:header costs a spammer nothing. Seeing your own address inFrom:proves only that the sender typed it there. - Spam apparently “addressed to someone else” was delivered to you because you were the envelope recipient; the headers were dressing.
hMailServer delivers on the envelope, always. Rules, Sieve scripts and event scripts
that test To: or From: are testing the headers — reliable for filing legitimate
mail, worthless as proof of origin or destination. Keep this distinction in mind
throughout the rest of this chapter: “recipient” below means the envelope recipient.
26.3 The SMTP conversation
An inbound delivery is a fixed sequence of commands, and hMailServer attaches specific work to each one:
| Stage | What hMailServer does |
|---|---|
| Connection | Looks the client IP up in IP ranges and the auto-ban list; enforces the connection-count limit; sends the welcome banner |
EHLO / HELO |
Records the client’s stated host name (checked later by the HELO spam test); on EHLO, advertises extensions — PIPELINING, SMTPUTF8, ENHANCEDSTATUSCODES, DSN, CHUNKING, 8BITMIME, STARTTLS. New in 6.x — every extension in that list except STARTTLS is a fork addition |
STARTTLS |
Upgrades the connection to TLS — 1.2/1.3 by default in 6.x. Since 6.3.4 a new installation (not an upgrade) offers it, optional, on ports 25 and 587 from the start, with a self-signed certificate made for the machine: the Windows installer and the Linux packages’ first start do this, and anywhere else (a server started by hand rather than by the packages’ unit) you run --apply-new-install-defaults by hand, with the service stopped |
AUTH |
Authenticates a submitting user; failures count towards auto-ban. On a new 6.3.4 installation the Internet IP range requires TLS for authentication, so a client on another machine is not offered AUTH before STARTTLS and is refused with 530 if it sends it; the My computer range, and mail from other servers on port 25, are unaffected. An upgraded server keeps its ranges as they were |
MAIL FROM |
Accepts the envelope sender. An empty sender (<>) is allowed by default — see §26.8 for why it must be. The submission rate limit (§17.5; new in 6.x), if set, refuses excess senders here with a 421. As shipped, the envelope spam tests (§26.5) also run here, before any recipient has been named (DNSBLChecksAfterMailFrom is 1; set to 0, it moves them to the first RCPT TO) |
RCPT TO |
The busiest moment of the session: the recipient is resolved (§26.4), the relay decision is made, and greylisting runs (§26.5) — preceded by the envelope spam tests only if they have been moved here from MAIL FROM. Each recipient is accepted or refused individually |
DATA |
Receives the content; enforces the maximum message size (default 20 MB, which a domain and, since 6.3.4, an account may lower but not raise; a size the client declares with SIZE is already checked at MAIL FROM) and, optionally, strict line endings; then runs the content spam tests before the final reply. Anti-virus runs after acceptance (§26.6) |
QUIT |
Ends the session |
Since 6.3.4 the session has more places to say no, each off until an administrator sets it: a country policy when the connection is accepted (with a country database you supply), the domain white and black list at MAIL FROM, restricted delivery and a null sender naming a second recipient at RCPT TO, and a mail flow rule whose action is to reject, at the end of DATA.
Two protocol-hygiene settings are worth knowing about. hMailServer can reject messages with malformed line endings (the standard requires CRLF; some spamware cannot manage it, but neither can some buggy legitimate software), and it can disconnect a client after too many invalid commands — the classic signature of a spammer probing your server for deliverable addresses. Both live with the other SMTP behaviour on the SMTP tab of Connections & protocols → Protocols.
How commands are processed internally
hMailServer does not dedicate a thread to each connection. Instead, the service starts a fixed pool of worker threads, and each command that arrives — on any session — is picked up by whichever worker happens to be free, handled, and released. A mail client spends almost all of its time idle, so one worker can serve a great many sessions, and thousands of simultaneous connections do not translate into thousands of threads — or the context-switching cost that would come with them.
The trade-off is that no more commands can run at once than there are workers; anything beyond that waits briefly for a worker to come free. In normal operation the wait is invisible, because individual commands finish in milliseconds. The pool size is tuned on the Threads tab of Maintenance → Performance.
This is why a slow event script (chapter 23) is so damaging: it occupies a worker for its whole running time, and the pool is small. One sluggish script slows every session on the server.
26.4 Resolving recipients
When RCPT TO arrives, hMailServer must answer one question: is this address mine, and
if so, whose mailbox is it? Since 6.3.4 one question comes first: an address, or a whole domain, routed to a webhook is accepted at RCPT TO whatever follows, because the route decides that the address exists (nothing is routed until an administrator makes a route). Otherwise the resolution runs as follows.
1. Domain-alias rewrite. If the recipient’s domain matches a domain alias of a
local domain (chapter 13), the address is rewritten to the primary domain name first:
with example.net set up as an alias of example.com, mail for [email protected]
becomes mail for [email protected] before anything else happens.
2. The domain is local. If a domain matching the recipient exists on the server:
- If the domain is marked inactive, the recipient is refused.
- An account matching the full address wins: the message will be delivered to that mailbox. An inactive account is refused.
- Failing that, an alias matching the address is looked up. If one is found, the whole resolution restarts from step 1 with the alias’s target address — which may be another local account, another alias, or an address on a completely different server. An inactive alias is refused.
- Failing that, a distribution list matching the address is looked up. If one is found (and the sender is entitled to post to it — chapter 13), the list’s members are added as recipients, and each member address is resolved through this same process. Members may themselves be aliases, other lists, or remote addresses.
- Since 6.3.4, failing that, a masked address (chapter 13) matching the address is looked up. One that is switched on delivers to the account that made it; one switched off, or removed, is refused as an unknown address is, and never handed to the catch-all.
- If nothing matched and the domain has a catch-all address, the message goes there.
- If nothing matched and there is no catch-all, the recipient is refused as unknown —
after the
OnRecipientUnknownevent script (chapter 23), if you have one, has been told (it is a notification only: nothing it does changes the answer).
3. The domain is not local. If no local domain matches, hMailServer looks for a route (chapter 14) matching the recipient’s domain and, failing that, falls back to ordinary DNS-MX delivery. Route lookup uses the address as the client gave it — the domain-alias rewrite in step 1 only applies to local domains. Either way, accepting a non-local recipient means agreeing to relay, so the session must be entitled to it: an authenticated user, or an IP range that explicitly permits external-to-external delivery. Everything else gets the “relay denied” refusal — which is exactly the behaviour that keeps you off the blacklists (chapter 17).
Worked example
[email protected] arrives; example.net is a domain alias of example.com;
[email protected] is an alias pointing at [email protected], which is a real account:
[email protected]
└─ domain alias ─▶ [email protected]
└─ alias ─▶ [email protected]
└─ account ─▶ delivered to bob's mailbox
Three lookups, one 250, one copy of the message.
Catch-all
The catch-all address receives everything sent to non-existent addresses in the domain — a safety net for misspelt local parts. It is the domain’s Postmaster address (General tab of the domain dialog) and can be any address at all, including one on another server; leave it empty and unknown recipients are refused instead.
A catch-all means your domain accepts mail for every possible local part. Spammers notice, and dictionary-attack traffic that would have been refused at
RCPT TOnow has to be received, scanned and stored. Use one deliberately or not at all.
Plus addressing
With plus addressing enabled on a domain (Accounts & domains → Domains → Properties → Limits, alongside
a choice of separator character, + by default), the tag between the separator and the @ is ignored
during resolution: [email protected] lands in [email protected]‘s mailbox.
Users can hand out a distinct tagged address to every service they sign up to, then file
— or identify leaks — with a rule or Sieve script that tests the recipient address
(chapter 12).
26.5 The anti-spam pipeline
Chapter 10 covers configuring the spam layers; this section covers when each one runs. The principle is the one from §26.1: judge the message as early as the available evidence allows, so bad mail can be refused before it costs bandwidth — and so the refusal reaches a legitimate sender honestly, via their own server, instead of as backscatter.
The first checkpoint is before the body — at MAIL FROM as shipped, or at the first RCPT TO if DNSBLChecksAfterMailFrom is set to 0, which leaks less to a client probing for addresses but accepts more of the conversation first — and the second is
after DATA (or the last BDAT); mail handed over by a configured incoming relay has both phases run after DATA. For every other session the checks fall as follows:
| Check | Runs | Judges |
|---|---|---|
| Blocked senders | before the body | The envelope sender, against the blocked-senders list; runs first, and never matches a bounce’s empty sender |
| DNS blacklists | before the body | The connecting IP address |
| SPF | before the body | Envelope sender’s domain vs the connecting IP. Only a hard fail (-all) scores, unless SoftFail, TempError and PermError are given scores of their own (since 6.3.4, off by default). Since 6.3.4 the check follows RFC 7208, and a client on 127.0.0.0/8 or ::1 no longer passes before any policy is read |
| HELO host check | before the body | Whether the HELO/EHLO host name resolves to the connecting IP |
| Sender MX check | before the body | Whether the envelope sender’s domain has MX records at all |
| Reverse-DNS (PTR) check | before the body | Whether the connecting IP has a PTR (reverse-DNS) record whose forward lookup comes back to it |
| Greylisting | at RCPT TO |
Whether this (IP, sender, recipient) triplet has been seen before |
| SURBL | after DATA |
The links inside the message body |
| DKIM verification | after DATA |
Whether a DKIM-Signature header, if present, matches the content |
| ARC | after DATA |
Whether a chain sealed by a sealer you trust recorded an original pass; it can only cancel a DMARC failure score, and does nothing until trusted sealers are named (off by default) |
| DMARC | after DATA |
New in 6.x. The From: domain’s published policy, using the SPF and DKIM results |
| SpamAssassin | after DATA |
The whole message, against hundreds of content rules |
| Filter hook | after DATA |
The whole message, sent over HTTP to an external engine such as rspamd; runs last, and is off until FilterHookUrl is set |
Mail fetched from external accounts by POP3 or IMAP (§6.2) goes through the same tests before the message is saved — with one exception: a fetch account set to mirror every folder copies messages in verbatim and delivers nothing, so no header, rule, anti-spam or anti-virus pass touches a mirrored copy.
How the score accumulates
Every test that fires adds its configured score to a running total for the message, and the total is compared against the two thresholds from §10.1. What “delete” means in practice depends on when the threshold is crossed:
- Delete threshold reached during the envelope stage — the command is refused
immediately with
550 5.7.1and the first failing test’s text (atMAIL FROMas shipped, before any recipient is named), andDATAnever happens. - Delete threshold reached only after the content tests — the message is refused in
the final reply to
DATA, with554 5.7.1and the first failing test’s text. Either way the sending server is told plainly, during the session, that the message was not accepted — nothing is stored, and there is nothing to bounce. The exception is the quarantine (QuarantineEnabled, off by default): with it on, a message condemned afterDATAis answered250and held on Spam & virus filtering → Quarantine for review, so a false positive can be released without any bounce; a verdict reached before the body has no message to hold and stays a refusal. - Mark threshold reached (but not delete) — the message is delivered tagged, exactly
as §10.1 describes:
X-hMailServer-Spam: YESand theX-hMailServer-Reason-*headers are added, and the subject is prefixed with[SPAM]if you have switched that on (it is off by default), so rules and Sieve can file it.
For externally fetched mail there is no session and nobody to refuse, so a message over the delete threshold is simply deleted.
A message is only scored at all if at least one spam test is enabled, the session is not authenticated (your own users’ mail is never spam-tested), the sender or connecting IP is not on the white list, and the connecting IP falls in an IP range with anti-spam enabled. The content tests are also skipped for a message larger than the maximum message size to scan (1024 KB by default) — spam is overwhelmingly small, and content-scanning very large messages buys little for its CPU cost.
Greylisting
Greylisting is the odd one out: it does not score, it stalls. The first time a given
(IP, sender, recipient) triplet appears, hMailServer records it and answers with a
temporary error: 451 at RCPT TO or, when the envelope sender is empty, 450 at the end of DATA, because an empty sender at RCPT TO is often another server checking that the address exists. A real mail server retries and is accepted once the initial delay has passed (30 minutes by default); most
spamware never comes back. Because the rejection is temporary and happens before
acceptance, greylisted messages are never scored, delivered or counted — they simply
have not arrived yet.
You control how long the deferral lasts, how long unused triplets are kept, and how long
a successful triplet remains valid (so established correspondents are never delayed
again). Two bypasses take most of the sting out of the delay: skip greylisting when
SPF passes (on by default, but it acts only when the SPF test itself is switched on, which it is not as shipped; the big providers all publish SPF, so with the test on their mail flows straight
through), and skip it when the connecting IP appears in the A or MX records of the
sender’s domain (off by default; it costs an A and an MX lookup of the sender’s domain per message). The greylisting white list (§10.4) handles the genuinely
incompatible senders — services that retry from a different IP each time and so never
complete a triplet. Since 6.3.4 there is also a greylisting bypass list (Spam & virus filtering → Greylisting bypass list), which lets a named domain or HELO suffix past only when every result the entry requires (SPF, DKIM or DMARC, at least one) passes for that name (an entry that requires DKIM or DMARC is judged after DATA, once the body has arrived), and two switches for senders that retry too soon, a sliding wait and an abuse lock, both off by default.
Greylisting must be switched on in two places: globally on the Greylisting tab of Spam & virus filtering → Anti-spam settings, and per domain with Enable greylisting for this domain on Accounts & domains → Domains → Properties → Limits — which is what lets you greylist a back-office domain while sparing a customer-facing one.
26.6 Between acceptance and the mailbox
Once the final 250 is sent, the accepted message passes through the remaining
machinery on its way to a mailbox or the outbound queue:
- Anti-virus (chapter 11) — an infected message is deleted or has its attachments
stripped; before the scan, while attachment blocking is switched on (off as shipped), an attachment whose name matches the blocked attachments list is renamed with
.txtand its content replaced by a notice (§11.2), so the message still arrives. Since 6.3.4 a message from outside whose attachment is of a risky type (an executable, a script, an archive, an office document, a PDF and the like) can also be held until a sandbox service you name has answered (off by default). - Server-wide rules, then (since 6.3.4) the rules of a domain the message is for or from, then the recipient account’s own rules and Sieve script (chapter 12; Sieve support is new in 6.x) — filing, forwarding, replying, deleting, or handing the message to a script.
- Account features — auto-reply, forwarding, and quota enforcement. Since 6.3.4 the recipient’s allow and block lists are read here too, the account’s own before its domain’s and the server’s: an allowed sender’s mail is delivered unmarked, and a blocked sender’s is held in the quarantine (marked and delivered when the quarantine is off).
Two guards operate here. The rule loop count (RuleLoopLimit, 5 by default, on the Rules tab of
Mail flow & delivery → Delivery of e-mail) caps how long a chain of messages made by rules can grow: a
forward, a reply or a copy carries X-hMailServer-LoopCount one higher than the message that caused it, and at the
limit nothing more is generated, so a pair of rules forwarding to each other cannot ping-pong forever. And
when forwarding sends a message back out to the world, SRS (§17.6; new in 6.x),
if enabled, rewrites the envelope sender so the forwarded copy still passes SPF at its
destination. ARC sealing (new in 6.x; ArcSealingEnabled, off by default, on
Certificates & encryption → Transport security) seals mail this server relays onward, a forwarded copy included, as well as mail from a hosted domain. The seal is made with a hosted domain’s DKIM key, so on a server with no domain DKIM key the switch reads as enabled and seals nothing; a message that cannot be sealed is still delivered.
One quirk to remember when writing rules: the Delivered-To header, if you have enabled it, is added after all rule and script processing — a rule can never match on it.
This is also where the event scripts of chapter 23 fire: OnAcceptMessage just before
the final reply to DATA (its Result can still refuse the message), then
OnDeliveryStart as delivery processing begins — before anti-virus and the
server-wide rules — and OnDeliverMessage after them, just before the message is
handed to local and remote delivery. OnDeliveryFailed reports each failed remote
delivery attempt described next.
26.7 The delivery queue and the retry schedule
What happens next depends on where each recipient lives:
- Local recipients get the message written into their mailbox folder in the
data directory by the delivery thread that picks it up. Every accepted message is written to the queue before the
250is sent and taken from there moments later, so a message for local recipients only leaves the queue again almost at once — this is why the test in §6.3 leaves the queue empty. - Remote recipients keep the message in the delivery queue, visible at Monitoring & troubleshooting → Delivery queue, where you can retry or delete individual messages by hand (and do the same over the REST API — chapter 24; the API itself is new in 6.x).
For each remote domain, hMailServer picks the destination in a fixed order: a rule’s Send using route action, then a matching route, wins; otherwise a relay host set on the sending domain (the Relay tab of its domain dialog); otherwise the smart host, if one is configured, carries the rest; otherwise a DNS-MX lookup names the destination, and the MX hosts are tried in priority order — up to a configurable maximum number of hosts, 15 by default. Large recipient lists are split into batches (the maximum recipients per delivery is configurable), because many receivers treat a single message with hundreds of recipients as spam in itself.
Outbound connections use STARTTLS whenever the remote server offers it. New in 6.x. With MTA-STS and DANE enforcement on (their defaults — chapter 9), a domain that has published a policy must present verified TLS or the mail is held back rather than sent unprotected. Since 6.3.4 a remote-domain policy (Mail flow & delivery → Remote domains) can also demand encrypted, verified or DANE-verified TLS of one destination or pattern; a delivery that cannot meet it is deferred, never sent in the clear. The outbound rate limit (§17.5) also acts here, deferring — not bouncing — anything over the per-destination cap.
Since 6.3.4, and on after an upgrade too, a destination that asks this server to slow down — a 421, or a 4xx with a provider’s rate-limit code or wording — is given a back-off: nothing more is sent to it for OutboundBackoffMinutes (2 by default), doubling on each repeat up to OutboundBackoffMaxMinutes (60); OutboundBackoffMinutes set to 0 turns it off. A wait this server chose — the back-off, and the connection ceilings per destination and per tenant or an IP warm-up schedule where you set them — does not spend a message’s retries until the message has been queued for longer than its retry schedule spans, so a destination that never stops asking still ends in a bounce.
The reply to each attempt decides what happens next:
| Outcome | Meaning | hMailServer does |
|---|---|---|
2xx |
Accepted | Done. Counted in hmailserver_messages_delivered_total (§16.3; the Prometheus metrics are new in 6.x) |
4xx, or no connection |
Temporary failure | Back in the queue for the next retry |
5xx |
Permanent refusal | No retry — a bounce is generated now |
The retry schedule is set on the Delivery tab of Mail flow & delivery → Delivery of e-mail: the number of retries, and the minutes between them (a route may override both for its own domains). The defaults are 4 retries at 60-minute intervals — five attempts in total, spread across roughly four hours — after which the message is abandoned and bounced. A rebooting server, a full mailbox that gets cleared, a DNS blip: all of these heal within that window, which is why a growing queue is a warning sign (§16.1) but a briefly non-empty one is routine.
26.8 Bounces
A bounce — formally a delivery status notification — is generated when delivery fails for good: a remote server refused the message permanently, the retry schedule ran
out, or a local delivery could not be completed (a message that would take a mailbox past its quota — a mailbox already at or over its quota is, by default, refused at RCPT TO with a temporary 452 instead — or a Sieve reject). It is sent to the envelope sender, and for a remote failure it quotes the remote server’s last
response, which is usually all the troubleshooting you need. The wording of the
notification is yours to edit under Mail flow & delivery → Server messages.
Bounces are sent with an empty envelope sender (MAIL FROM:<>), as the standard
requires — a bounce that could itself bounce would loop forever. This is also why the
allow empty sender address option (on the SMTP tab of Connections & protocols → Protocols) should stay on: disable it and
you refuse every delivery report the internet tries to send you. To refuse only the null senders that cannot be delivery reports, 6.3.4 adds RejectMultiRcptNullSender (off by default): a MAIL FROM:<> that names a second recipient is refused, because a real report has one.
Bounce as little as possible. Because spam and viruses forge their senders, any bounce you generate after accepting such a message lands on an innocent third party — backscatter, and a fast route onto blacklists. This is the deeper reason the whole pipeline rejects during the SMTP session wherever it can: an in-session refusal makes the sending server responsible for informing its user, which for forged mail means the bounce goes nowhere. BATV (§17.6; new in 6.x) closes the loop from the other side, tagging your outgoing envelope senders so that forged bounces arriving at your server can be recognised and dropped.
26.9 What hMailServer caches
Almost every step above needs objects from the database — the domain at the alias rewrite, the account at resolution, the list at expansion. Reading them fresh for every recipient of every message would make the database the bottleneck, so hMailServer keeps an in-memory object cache for four object types: domains, accounts, aliases and distribution lists. Only the object itself is cached — caching a domain does not cache its accounts.
Each cached object carries a time-to-live (default 60 seconds). A lookup checks the cache first; an entry younger than its TTL is used as-is, an older one is discarded and re-read from the database. The hit rate per object type tells you how well this is working: 90% means nine lookups in ten never touched the database. Raising a TTL raises the hit rate.
Invalidation is the part that matters operationally:
- A change made through the Control Panel, the COM API or the REST API updates the cache immediately. Administer the server only through these, and the cache is always coherent — you can set the TTLs as high as an hour and collect the benefit for free. (Older documentation lists the classic hMailServer Administrator and the PHP WebAdmin here too; both have been removed from the fork — the Control Panel replaces them.)
- A change made directly in the database is invisible until the TTL expires. A password changed by a third-party tool, or an account deactivated by hand-written SQL, simply does not exist as far as the running server is concerned — for up to a full TTL.
If a change you made “didn’t take”, ask yourself whether you made it behind the server’s back. Direct database edits and a long TTL are a reliable recipe for ghost-chasing.
The cache is configured on the Cache tab of Maintenance → Performance — enabled or
disabled as a whole, with a TTL per object type — and is scriptable through the COM
Cache object:
$app = New-Object -ComObject 'hMailServer.Application'
$app.Authenticate('Administrator', 'your-admin-password') | Out-Null
$cache = $app.Settings.Cache
'Domains: {0}% hit rate' -f $cache.DomainHitRate
'Accounts: {0}% hit rate' -f $cache.AccountHitRate
'Aliases: {0}% hit rate' -f $cache.AliasHitRate
'Distribution lists: {0}% hit rate' -f $cache.DistributionListHitRate
# The safe pattern for direct database work:
$cache.Enabled = $false # ...make the database changes...
$cache.Enabled = $true # coherent again, nothing stale survives
$cache.Clear() # or: flush everything and reset the statistics
Run 6.2.10 or later for this kind of scripting: earlier builds had COM methods that reported success on calls they had actually refused, and
Cachegetters that could return uninitialized values. On current releases, still check return values.
If some external tool must write to the database routinely, either shorten the TTLs to match how quickly those changes need to be seen, disable the cache around each batch of changes as above, or — bluntest of all — stop the service while the surgery happens. For everyone else, the right answer is the default one: leave the cache on, administer through the proper interfaces, and let it quietly absorb the database load of every message that flows through the server.
27. Rules in practice: a cookbook
Chapter 12 introduced the rules engine in a paragraph. This chapter is the working manual: how matching actually behaves, every criterion and action, the order things run in, eleven recipes you can copy, and the reasons rules mysteriously fail to fire.
Everything here concerns the built-in rules engine — the Rules page and each account’s Rules tab. Sieve — the per-user standard filtering language, new in 6.2 — is a separate system covered in chapter 12.
27.1 Where rules live, and when they run
Rules exist at three levels; the domain level is new in 6.3.4:
| Level | Where | Applies to |
|---|---|---|
| Global | Mail flow & delivery → Rules (on screen, Global rules) | Every message the server delivers, to any recipient |
| Domain (since 6.3.4) | The same page, with the domain chosen at the top | Messages with a recipient in the domain or a sender it hosts, after the server’s rules. A recipient domain’s rules act on the whole message only when every recipient is its own; otherwise only on its own recipients |
| Account | Accounts & domains → Domains → your domain → Accounts → the account → Rules tab | Messages delivered to that one account |
Rules run during delivery — after a message has been accepted, as it is being placed into mailboxes or sent onward. Since 6.3.4 there is one exception: when a server rule, or a rule of a domain whose rules act on the whole message, has a Reject with a text action, those rules are also asked at the end of DATA, before the message is accepted (§27.3). Three consequences follow, and they explain most rule surprises:
- A rule acts on the recipient’s copy. If a global rule moves messages into a
Spamfolder and User 1 mails User 2, the message lands in User 2’s Spam folder. Nothing happens in User 1’s mailbox — the message was never being delivered to User 1. - Global rules run first. If a global rule deletes a message, the recipient’s own account rules never see it.
- Account rules get the last word on anything both levels touch. A global rule that
files a message into
Filed, followed by an account rule that files it intoClients, leaves the message inClients.
Within each level, rules run top to bottom in the order listed, and each rule’s actions run in their listed order. Reorder with Move up and Move down. Order is not cosmetic — it is half of rule debugging (§27.5).
AND and OR
A rule combines its criteria in exactly one of two ways, chosen in the box beside the word IF:
- Match ALL criteria (AND) — every criterion must match.
- Match ANY criterion (OR) — any single criterion is enough.
There is no mixing within one rule. If you need “(A and B) or C”, write two rules.
27.2 Criteria
A criterion is a field, a search type and a value.
| Field | What is examined |
|---|---|
| From | The From header |
| To | The To header — not who the message is actually delivered to |
| CC | The CC header |
| Recipient list | The actual recipients, taken from the SMTP envelope |
| Subject | The Subject header |
| Body | The message body — both the plain-text and the HTML part |
| Message size | The size of the message (numeric — see the unit note below) |
| Delivery attempts | 1 on the first delivery attempt, 2 on the second, and so on |
| Custom header | Any header you name — List-Id, Precedence, X-Mailer, anything |
| Attachment name (since 6.3.4) | Every attachment’s file name, with ; between them, so Contains .exe or Wildcard *.zip reads “any attachment is” |
| Sensitive content (since 6.3.4) | For the outbound content policy: what its detectors found in the readable text — a card number by its Luhn check digit, an IBAN, or an account number by a pattern of your own |
| Attachment type (since 6.3.4) | Every attachment’s type, read from the file’s first bytes rather than its name, so a renamed executable is still an executable |
| External recipients (since 6.3.4) | Every recipient this server does not host |
| Readable text (since 6.3.4) | The plain and HTML parts and every attachment whose bytes are text, for a pattern of the rule’s own. A message sealed end to end has no readable text, so this and Sensitive content do not match it |
Headers are not the envelope. Every message carries two recipient lists: the
To/CCheaders that a mail client displays, and the SMTP envelope — the addresses the message is actually delivered to. They routinely differ. A BCC address exists only in the envelope and appears in no header at all. Filter on To and BCC’d mail will never match; filter on Recipient list and it will.Recipient list only works in global rules. When a message is placed into an account, the envelope recipient list is cleared. A Recipient list criterion in an account rule therefore matches nothing, ever. Use it in global rules only.
Missing equals empty. An absent header and an empty header are treated identically. A criterion of “Subject Equals (empty value)” matches messages with a blank subject and messages that have no Subject header at all — which may be more than you intended.
Message size units. The upstream project’s documentation was inconsistent about the unit for this criterion — bytes in one example, megabytes in another. The source settles it: the criterion compares the raw message size in bytes (the size of the message file). Do not confuse it with the COM API’s
Message.Sizeproperty, which reports kilobytes. If in doubt, send yourself a message of known size before you attach a destructive action to a size criterion.
Search types
| Search type | Matches when |
|---|---|
| Equals | The field matches the value exactly |
| Not equals | The field does not match the value exactly |
| Contains | The value appears anywhere in the field |
| Not contains | The value appears nowhere in the field |
| Less than / Greater than | Numeric comparison — for Message size and Delivery attempts |
| Wildcard | A pattern using * (any sequence of characters) and ? (any single character) matches the field |
| Regular expression | The expression matches the field — see below |
Every search type except Regular expression compares case-insensitively —
Equals, Contains and Wildcard all ignore case. Regular expressions are
case-sensitive unless you say otherwise (below).
Regular expressions
Regular expressions are evaluated by Boost.Regex using Perl syntax. One fact matters more than all the others:
The expression must match the entire field value. There is no partial matching.
invoicewill not match the subject “Your invoice is attached” — write.*invoice.*. Forgetting the leading and trailing.*is the single most common regex-rule mistake.
Prefix (?i) for a case-insensitive match. For syntax details, the Boost.Regex
documentation is the authority.
Test before you save
The retired classic Administrator’s criteria editor had a built-in Test field; the
Control Panel’s criteria dialog does not. The same check is still one call away through
the COM API (chapter 23): Utilities.CriteriaMatch(pattern, matchType, sampleValue)
returns whether the sample would match — pass match type 5 for a regular expression,
8 for a wildcard. Since 6.3.4 the same check is also a REST call (chapter 24):
POST /api/v1/rules/match with match_value, match_type (regex,
wildcard or another of the words the rule listing uses) and test_value; nothing is read or written. Use it every time you write a wildcard or a regular expression — it
is far faster than sending yourself test messages, and it catches the entire-value
regex mistake on the spot.
27.3 Actions
| Action | What it does |
|---|---|
| Delete | The message is not delivered. Deletion takes effect once rule processing has finished |
| Forward | Send a copy to another address, local or external. The original is still delivered normally |
| Reply | Send an automatic reply, with a from-address, subject and body you set, to the sender |
| Move to IMAP folder | File the message into the named folder. Local deliveries only; the folder is created if it does not exist |
| Set header value | Add a header to the message, or overwrite one that already exists |
| Run function (on screen, Run script function) | Call a named function in your event scripts, passing the message object. Since 6.3.4 the name must be a plain identifier (below) |
| Send using route | Deliver via a named route instead of the normal MX lookup (chapter 14). Server and domain rules only |
| Create copy | Put an independent copy of the message into the delivery queue, addressed to the original recipients |
| Bind to address | Deliver the message outbound from a specific local IP address. Server and domain rules only |
| Stop rule processing | Skip every remaining rule in this rule’s set. The rest of this rule’s own actions still run, since a matching rule always runs all of its actions, so make Stop the last action |
| Add a disclaimer | Since 6.3.4. Append a text to the plain and HTML parts, with an X-hMailServer-Disclaimer header naming the rule. The domain’s own disclaimer is separate and stays unconditional |
| Reject with a text | Since 6.3.4. Refuse the message with 550 5.7.1 and your text at the end of DATA, before it is accepted, so the sender is told. A message the rule meets only at delivery is dropped as a delete is, and a sender this server hosts is told it was not sent |
| Require TLS | Since 6.3.4. Every external delivery of the message needs STARTTLS; a destination that offers none is deferred with a reason naming the rule, never sent in the clear |
| Quarantine | Since 6.3.4. Hold the message in the quarantine under Rule: <name>, for an administrator to release or delete |
| Warn the sender | Since 6.3.4. The message goes on as the other actions decide, and a sender this server hosts is mailed a notice naming the rule; an outside sender is told nothing |
Details worth knowing:
- Move to IMAP folder understands subfolders:
Archive.2026.Invoicesfiles three levels deep, using.as the separator (if you have changed the IMAP hierarchy delimiter from the default dot, use your delimiter instead). The folder name may also contain the macros%YEAR%,%MONTH%and%DAY%, expanded at delivery time. - Forward updates the envelope, not the headers. The person receiving the
forwarded copy sees the original
ToandCCin their client. A forwarded message is a new message — and new messages are run through the rules themselves, which is how forwarding loops start (§27.5). (Through the COM API, a Forward action also exposes anAbortSpamFlaggedproperty — set it and messages already flagged as spam are not forwarded.) - Send using route and Bind to address are absent from the account rule dialog — both steer outbound delivery, which account-level rules never handle. Server rules have them, and since 6.3.4 so do a domain’s rules.
- Create copy stamps the copy with an
X-CopyRuleheader naming the rule that made it — which is also a ready-made loop guard: a Custom header criterion onX-CopyRulekeeps a copy rule from processing its own copies. - Run function calls a function that takes the message as its one parameter —
Sub MyFunction(oMessage)in VBScript — in the event-script file on Maintenance → Event scripts. Scripting must be switched on (Maintenance → Advanced, Scripting tab), and the syntax check is worth a click before you save. Since 6.3.4 the action names the function alone, as a plain identifier: ASCII letters, digits,_and$, not starting with a digit, soMyFunction, notMyFunction(x). On Windows any other name is refused asHM5018each time the rule matches, and nothing is run. Every release from 6.0.0 to 6.3.3 wrote the name into the script’s call unescaped, in VBScript and JScript alike (CVE-2026-100741), so whoever could edit a rule could run code in the service: on an older server, let nobody but the server administrator make or edit a rule with this action. On Linux, where event scripts are JavaScript and this action works since 6.3.4, the function isfunction MyFunction(oMessage) { … }inEventHandlers.js(chapter 28). As with all event scripts, this runs inside the mail flow: keep it short. - Reply deserves caution: never let it answer bulk mail or bounces. Recipe 6 shows the guards.
More: Mail flow rules, the outbound content policy and what a domain does to a message on the forum.
27.4 The cookbook
Eleven recipes, each stating its level. Addresses, folder names and thresholds are examples — substitute your own.
Recipe 1: file tagged spam into Junk
Chapter 10’s spam-mark threshold tags suspect mail (an X-hMailServer-Spam: YES header and,
if you switch it on, a [SPAM] subject prefix) but still delivers it. This global rule files the tagged mail out of the inbox:
| Part | Field | Search type | Value |
|---|---|---|---|
| Criterion | Subject | Contains | [SPAM] |
| Action | Move to IMAP folder | Junk |
Match whatever prefix your anti-spam settings actually add. The prefix is off unless you, or a
setting profile, switched it on (Spam & virus filtering → Anti-spam settings, General tab); while
it is off this criterion matches nothing. If you prefer to leave subjects out of it, match the tagging
header instead: a Custom header criterion on X-hMailServer-Spam, Equals
YES, which the server writes while AddHeaderSpam is on, as it is by default.
Recipe 2: trusted senders skip the filters
A white-list rule, placed above the rules it bypasses. Global:
| Part | Field | Search type | Value |
|---|---|---|---|
| Criterion | From | Contains | @yourbiggestclient.com |
| Action | Stop rule processing |
Everything below this rule is skipped for matching mail. If it sits below your filing and deletion rules instead of above them, it protects nothing — order is the whole mechanism here. Note this is a different job from the anti-spam white list (§10.4): that exempts senders from spam scoring; this rule exempts them from the rest of your rules.
Recipe 3: forward-and-keep an off-site copy
Copy everything addressed to sales@ to an external archive address, while delivering
the original as normal. Global rule, Match ALL criteria (AND):
| Part | Field | Search type | Value |
|---|---|---|---|
| Criterion | Recipient list | Contains | [email protected] |
| Criterion | Recipient list | Not contains | [email protected] |
| Action | Forward | [email protected] |
The second criterion is the loop guard. The forwarded copy is a new message and is
run through the rules itself. With these exact criteria the copy escapes on its own —
its envelope recipient is the archive address, not sales@ — but the moment the rule is
re-edited to match on To or Subject, both of which the forwarded copy carries
unchanged, it starts forwarding its own forwards. The guard costs nothing and keeps the
rule safe to edit. The server’s rule loop limit will stop a runaway eventually, but the
archive still receives duplicates in the meantime.
Recipient list criteria only work in global rules (§27.2). For a simple unconditional forward of one mailbox, the account’s Forwarding tab (§6.2) is the easier tool — rules earn their keep when conditions are involved.
Recipe 4: delete oversized messages
| Part | Field | Search type | Value |
|---|---|---|---|
| Criterion | Message size | Greater than | your limit, in bytes (§27.2) |
| Action | Delete |
Global. Note that a rule delete is silent — the sender learns nothing. A
protocol-level maximum message size (on the SMTP tab of Connections & protocols → Protocols) rejects the message
at the door with an error the sender sees, which is usually kinder. Use the rule when
you want size handling that depends on other conditions — over a limit and from
outside the organisation, say. Since 6.3.4 such a rule can tell the sender too: with
Reject with a text in place of Delete, the global rule refuses the message with
550 5.7.1 and your text before it is accepted.
Recipe 5: delete a problem sender, server-wide
| Part | Field | Search type | Value |
|---|---|---|---|
| Criterion | From | Contains | [email protected] |
| Action | Delete |
Global. Two cautions. First, if the sender is simply a spammer, chapter 10’s machinery (DNSBLs, scoring) is the right tool — a rule is for the awkward case the filters legitimately pass. Second, resist the tempting variant “delete anything with an empty subject”: an empty value also matches messages with no Subject header (§27.2), and some legitimate automated mail qualifies.
Recipe 6: an out-of-office reply that behaves
The account’s Auto-reply tab (§6.2) covers the ordinary out-of-office. Use a rule when you need to be choosy about what earns a reply. Account rule, Match ALL criteria (AND):
| Part | Field | Search type | Value |
|---|---|---|---|
| Criterion | To | Contains | [email protected] |
| Criterion | Custom header Precedence |
Not contains | bulk |
| Criterion | From | Not contains | mailer-daemon |
| Action | Reply | Subject Out of office, body as you like |
Why each guard is there:
- To Contains means only mail addressed to Alice directly gets a reply. Mailing-list
traffic carries the list’s address in
To, not Alice’s, so it never matches — exactly what you want. (This is a case where the header-not-envelope behaviour of To works for you.) - The Precedence guard skips well-behaved bulk senders that mark their mail.
- The mailer-daemon guard stops the rule answering bounce messages — replying to a bounce generates another bounce, and so on.
If two correspondents both auto-reply, the server’s rule loop limit caps the ping-pong; with these guards in place it rarely begins.
Recipe 7: date-stamped archive folders
On a dedicated archive account — the one fed by Recipe 3, say — file everything into a folder per month:
| Part | Field | Search type | Value |
|---|---|---|---|
| Criterion | Message size | Greater than | 0 |
| Action | Move to IMAP folder | Archive.%YEAR%.%MONTH% |
“Message size Greater than 0” is the traditional match-everything criterion. The macros
expand at delivery, so August 2026 mail lands in Archive/2026/08, and each new month’s
folder is created automatically on first use.
Recipe 8: flag mail from outside the organisation
A useful phishing defence: mark every external message so clients can display it differently. Global:
| Part | Field | Search type | Value |
|---|---|---|---|
| Criterion | From | Not contains | @yourcompany.com |
| Action | Set header value | X-External = yes |
Client-side rules — or a Sieve script (chapter 12) — can then colour, flag or file on
the X-External header. Set header value overwrites an existing header of the same
name, so an outsider cannot smuggle in a pre-set X-External: no.
Since 6.3.4 a domain can mark outside mail without a rule: the external-sender tag on the
Tagging and disclaimer tab of the domain’s properties (Accounts & domains → Domains)
stamps X-hMailServer-External: YES, or puts [EXTERNAL] at the front of the subject, on each
recipient’s copy. It judges “outside” by more than the From header — an authenticated session, a trusted
incoming relay, and whether both the envelope sender and the From address are at domains this server
hosts — and it is off on every domain until you switch it on.
Recipe 9: tag large messages in the subject
A rule can tag the subject without a script: Set header value on Subject with the
value [LARGE] %MACRO_ORIGINAL_HEADER%, where the macro stands for the header’s current value. This
recipe shows the general tool instead, for changes no built-in action can make: Run function
hands the message to a script. Global or account:
| Part | Field | Search type | Value |
|---|---|---|---|
| Criterion | Message size | Greater than | your threshold |
| Action | Run function | OnTagLarge |
Then on Maintenance → Event scripts, add the function and run the syntax check, and make sure scripting is switched on (Maintenance → Advanced, Scripting tab). In VBScript, on Windows:
Sub OnTagLarge(oMessage)
oMessage.Subject = "[LARGE] " & oMessage.Subject
oMessage.Save
End Sub
On Linux, where event scripts are JavaScript since 6.3.4, the same function goes in
EventHandlers.js, with the scripting language set to JavaScript (chapter 28):
function OnTagLarge(oMessage) { oMessage.Subject = '[LARGE] ' + oMessage.Subject; oMessage.Save(); }
The same pattern — criterion, Run function, small script — handles any per-message
transformation the built-in actions cannot express. It runs inside the mail flow, so
keep the script short and test it on a non-production server first.
Recipe 10: route one sender’s mail through a different server
Suppose [email protected] sends bulk mail and you want it delivered through a
separate route — a different smart host with its own IP reputation — so a blacklisting
incident never touches your ordinary mail. Define the route under Mail flow & delivery → Routes
(chapter 14), then, globally:
| Part | Field | Search type | Value |
|---|---|---|---|
| Criterion | From | Contains | [email protected] |
| Action | Send using route | the bulk-mail route |
Normally a route is chosen by recipient domain; Send using route overrides that
per rule. The same shape works keyed on a Custom header — have your mailing software
add X-Campaign: yes and match on that instead of the sender address.
Recipe 11: file machine-generated invoices by pattern
Account rule, using a regular expression:
| Part | Field | Search type | Value |
|---|---|---|---|
| Criterion | Subject | Regular expression | (?i).*invoice\s+#?\d{4,}.* |
| Action | Move to IMAP folder | Accounts.Invoices |
Reading the expression: (?i) makes it case-insensitive; .* at each end satisfies the
entire-value rule (§27.2); invoice\s+#?\d{4,} requires the word followed by an
optional # and at least four digits. “Your Invoice #20260807” matches; a casual
message that merely mentions an invoice does not. Check it against sample subjects with
Utilities.CriteriaMatch (§27.2) before saving.
27.5 When a rule does not fire
Rules fail quietly — there is no “rule declined to match” log line. Work from symptom to cause:
| Symptom | Likely cause |
|---|---|
| Never matches, though the mail looks right | Filtering the wrong list: To/CC are headers, but delivery follows the envelope. BCC’d and list mail carry the recipient only in the envelope |
| A Recipient list criterion in an account rule never matches | It cannot — the envelope list is cleared before account rules run (§27.2). Move it to a global rule |
| Regex matches in your head but not on the server | It must match the entire value. Add .* at both ends; confirm with Utilities.CriteriaMatch (§27.2) |
| A regular-expression rule never fires, or fires for a while and then stops | The pattern does not compile (HM6042 in the error log), or one match took 250 ms or more and the pattern is suspended for 300 seconds (HM6043). Fix or simplify it; a corrected pattern is used from the next message |
| The rule matches but the action seems undone | A later rule overrode it — the last move wins, and account rules run after global ones. The account’s Sieve script runs after both, and its fileinto outranks a rule’s folder (chapter 12) |
| The rule never even runs | An earlier rule executed Stop rule processing, or a global rule deleted the message before your account rule’s turn. Or the rule has no criteria: a rule without one runs no actions in either match mode, so a catch-all needs one that is always true, such as Message size Greater than 0 (Recipe 7) |
| Forwarded or auto-replied mail arrives late | Up to 6.3.3 a message a rule created waited for the delivery manager’s next look at the queue, up to a minute (below). Since 6.3.4 it leaves the queue at once, so a delay now is in its delivery: look for it on Monitoring & troubleshooting → Delivery queue |
| The archive receives duplicates | Forwarding loop: the forwarded copy re-enters rule processing. Add a Not contains loop guard (Recipe 3). Or retries: server and domain rules run again on every delivery attempt, so a message deferred for some recipient can forward, copy or reply again on each attempt. Add a Delivery attempts Equals 1 criterion |
| Matches most mail but not certain messages | Encoded or malformed headers — see below |
Encoded and malformed headers
Header values arrive MIME-encoded more often than you might think, particularly subjects in non-Latin scripts. hMailServer decodes headers before matching where it can, but content in some encodings is effectively unsearchable — a Contains criterion on a Japanese subject may never match even though the client displays it perfectly.
Malformed messages are the sneakier case. A correctly formed message ends every line with carriage return plus line feed; some sending software emits bare line feeds. When that happens in the header block, hMailServer cannot reliably read individual header values — and criteria against them fail. The server is behaving correctly: a line that is not properly ended is not a line.
What makes this confusing is that mail clients paper over the damage and render the
message perfectly. To check, open the raw message file from the data directory in
Notepad, which shows the file exactly as it is — word processors quietly repair the
very line endings you are trying to observe. Several headers jammed onto one visual
line means a malformed message. You can refuse such messages outright with the SMTP
RFC-compliance options on the SMTP tab of Connections & protocols → Protocols — disable the allowance for
incorrectly formatted line endings (AllowIncorrectLineEndings).
A rule’s forward no longer waits a minute
Up to 6.3.3 a message created by a rule — a forward, a copy, a reply — and an account’s
own forwarding waited in the queue until the delivery manager next read it, up to a minute later:
each is queued from inside the delivery that caused it, and nothing told the manager it was there.
Since 6.3.4 the manager is told, and each leaves the queue at once. What stops a badly-written rule
pair from bouncing messages between two accounts is the Rule loop limit setting (under
Mail flow & delivery → Delivery of e-mail, Rules tab, 5 by default; RuleLoopLimit in the COM API), which caps how
long a chain of messages made by rules can grow: a forward, a reply or a copy carries
X-hMailServer-LoopCount one higher than the message that caused it, and at the limit nothing more is
generated (HM5064, HM5065 or HM5201 in the error log).
Watch it happen
The fastest diagnosis is empirical: open Monitoring & troubleshooting → Live logs, send yourself a message
crafted to match, and watch the delivery. Combine that with a Utilities.CriteriaMatch
check of your patterns (§27.2) and there is very little a misbehaving rule can hide.
Scripting rules? Rules can also be created and managed through the COM API (chapter 23), and over the REST API (chapter 24):
/api/v1/rulesfor the server’s, and since 6.3.4/api/v1/domains/{name}/rulesfor a domain’s and/api/v1/accounts/{address}/rulesfor an account’s. If you do this, run 6.2.10 or later and check return values — earlier builds contained methods that reported success on calls they had actually refused.
28. Event scripts: the complete reference
Rules and Sieve (chapter 12) act on messages. Event scripts act on the server. At fifteen fixed points in hMailServer’s processing — a connection opening, a message arriving, a delivery failing, a backup finishing — the server hands control to a script you have written, and in several of those events your script decides what happens next: accept, reject, tag, redirect or drop.
Chapter 23 introduced event scripts in a paragraph and a short table. This chapter is the full reference: every event, its exact signature, which objects are live inside it, what you may change, and what the Result object means in each one.
The scripting model is carried over from classic hMailServer — the same file, the same
Result semantics — and 6.x extends it. The classic ten events are all still here under
their classic names, including OnError (the site documentation’s chapter 23 previously
listed it as “OnErrorLog”; that was an error — the event is and always was OnError),
so the large body of published 5.x-era scripts still applies unchanged. Five new events
join them: OnHELO, OnClientValidatePassword, OnClientLogon, OnRecipientUnknown
and OnTooManyInvalidCommands. What has moved is the switch: the classic
Administrator — retired in 6.2 — kept it at Settings → Advanced → Scripts; it is now
on the Scripting tab of Maintenance → Advanced in the Control Panel, and the
script itself is edited on Maintenance → Event scripts.
28.1 Getting a script running
Four pieces, all of them small:
| Piece | Where |
|---|---|
| The script file | C:\Program Files\hMailServer\Events\EventHandlers.vbs; on Linux, /var/lib/hmailserver/events/EventHandlers.js |
| The on/off switch | Control Panel → Maintenance → Advanced, Scripting tab; off as shipped |
| The language | VBScript (the default) or JScript, chosen in the Language (VBScript, JScript or JavaScript) field on the same tab. JavaScript on Linux since 6.3.4, where there is no Control Panel: the language and the switch are set with hmctl or the REST API, and the script is reloaded over REST (§28.2) |
| The reload | Save & reload on Maintenance → Event scripts, the page that edits the file; saving the Advanced page reloads the script as well |
Every handler lives in that one file. Define only the events you need — an event with
no matching handler (Sub in VBScript, function in JScript) is simply skipped, at no
cost. If you choose JScript, the file is EventHandlers.js in the same folder.
To enable scripting on Windows (a Linux server has no Control Panel; its steps are in §28.2):
- Open the Control Panel and go to Maintenance → Advanced, Scripting tab.
- Switch scripting on, pick the language, and click Save.
- Go to Maintenance → Event scripts, which edits the script file in the
Eventsfolder (EventHandlers.vbs, orEventHandlers.jsfor JScript), and add your handlers. The page reads and writes the file on the machine the Control Panel runs on, so use it on the server itself. - Click Save & reload. It writes a
.bakcopy first, then reloads the script and checks its syntax.
Edits do nothing until you reload. hMailServer reads the file when it loads the script — at start and at every reload — and keeps that text in memory; each event then runs it in a fresh engine, the whole file from the top and then the handler (so keep code outside a handler to declarations, §28.8). An edit on disk is not seen until the next load. After every edit, click Save & reload (or restart the service); a file that does not compile installs nothing, and the script loaded before stays in force (§28.6). Forgetting this is the single most common “my script doesn’t work” report.
The script runs as the service account — by default
NT SERVICE\hMailServeron an installation made with 6.3.4 or later,LocalSystemon one upgraded from an earlier version unless it was moved (§17.3), and thehmailserveruser on Linux. Anyone who can write to theEventsfolder can execute code as that account. On Windows, restrict NTFS write access to administrators, and treat the file like the configuration it is.
28.2 VBScript, JScript or JavaScript
One language per server, chosen on the Scripting tab of Maintenance → Advanced. The events, objects and Result semantics are identical in VBScript and JScript; only the syntax differs. JavaScript, the Linux language, is below.
Sub OnAcceptMessage(oClient, oMessage)
EventLog.Write("Accepted a message from " & oMessage.FromAddress)
End Sub
function OnAcceptMessage(oClient, oMessage)
{
EventLog.Write("Accepted a message from " + oMessage.FromAddress);
}
In practice, on Windows, choose VBScript unless you have a strong reason not to: nearly every published hMailServer example, forum answer and snippet you will ever find is VBScript, and translating on the fly is a needless source of bugs. The rest of this chapter uses VBScript throughout.
On Linux, since 6.3.4, the language is JavaScript — the QuickJS engine, embedded in the
server, for the same events under the same function names with the same objects, in
/var/lib/hmailserver/events/EventHandlers.js. The scripting language setting takes it as a third
word; Windows keeps VBScript and JScript and refuses JavaScript at load, and Linux refuses the other two. The
setting is seeded VBScript on every platform, so a Linux server sets it to JavaScript before
switching scripting on: hmctl settings set scripting --set-string language=JavaScript, then
hmctl settings set scripting --set enabled=true, then a reload with
POST /api/v1/settings/scripting/reload. A
script there has no file, process or network access, and ScriptTimeout and a 64 MB ceiling
bound it. Not every COM member is there: Message.Attachments and Message.Headers are not
(a header is written with SetHeaderValue(name, value)), names are case-sensitive,
CreateObject throws a TypeError, and null stands where Windows passes
Nothing. A JScript file carried from Windows is where a port starts, not a file that runs unchanged;
EventScripts.md
has the member-by-member contract and a ported handler.
Scripting in JScript? Upgrade to 6.3.4 or later. In every release from 6.0.0 to 6.3.3, a JScript server with an
OnClientValidatePassword handler could be made to run code inside hMailServer.exe by
any client that named an existing, active account at a sign-in, because the server escaped the apostrophe but not
the backslash in the values it passed; the same was open through OnExternalAccountDownload and
OnDeliveryFailed, and through a rule’s Run function action in either language. It is CVE-2026-100741; chapter 25
has the detail. Until you can upgrade, switch event scripting off. A server that cannot do without its scripts should take the
OnClientValidatePassword, OnExternalAccountDownload and OnDeliveryFailed handlers out of a JScript
EventHandlers.js and reload the scripts, and, in either language, let nobody but the server administrator make or edit a
rule with a Run function action. Since 6.3.4 a handler receives every value exactly as it was, backslashes included,
so a handler that undid the old escaping by hand must stop; and a rule’s Run function action must name a
plain identifier (ASCII letters, digits, _ and $, not starting with a digit), or nothing is
run: on Windows the name is refused as HM5018 each time the rule matches, and on Linux it finds no function.
28.3 Where the events sit in the mail flow
Seven of the fifteen events fire in a fixed order as a message travels through the server, interleaved with the built-in checks. Four more police the session — the two login events, unknown recipients and hostile clients. Knowing the order tells you what has already happened — and therefore what you can rely on — inside each handler:
TCP connection opened
│
├── OnClientConnect (oClient)
│
├── HELO / EHLO
├── OnHELO (oClient)
│
├── AUTH, if the client logs in
│ ├── OnClientValidatePassword (oAccount, sPassword) not for SCRAM
│ └── OnClientLogon (oClient)
│
├── MAIL FROM / RCPT TO
│ ├── connection-stage checks DNS blacklists, HELO host, MX, SPF, greylisting
│ └── OnRecipientUnknown (oClient, oMessage) on an unknown local address
│
├── DATA command issued
├── OnSMTPData (oClient, oMessage)
├── message content received
│
├── content-stage checks SURBL, DKIM, SpamAssassin
│
├── OnAcceptMessage (oClient, oMessage) ← last chance to reject
│
│ message accepted, saved, queued
│
├── OnDeliveryStart (oMessage)
├── anti-virus, global rules
├── OnDeliverMessage (oMessage)
├── delivery to mailboxes / onward relay, then account-level rules
│
└── OnDeliveryFailed (oMessage, sRecipient, sErrorMessage)
The two login events fire on POP3 and IMAP sessions too, not only SMTP, and OnTooManyInvalidCommands can fire at any point in an SMTP session — the moment the server decides the client is hostile and disconnects it. The remaining four fire outside the mail flow: OnExternalAccountDownload when mail is fetched from an external POP3 or IMAP account, and OnBackupCompleted, OnBackupFailed and OnError when the thing their name describes happens.
| Event | Fires when |
|---|---|
OnClientConnect |
A client opens a TCP connection to the server |
OnHELO |
An SMTP client greets the server with HELO or EHLO — new in 6.x |
OnClientValidatePassword |
An account password is about to be validated — new in 6.x |
OnClientLogon |
An SMTP, POP3 or IMAP authentication attempt completes — new in 6.x |
OnSMTPData |
An SMTP client issues the DATA command |
OnAcceptMessage |
A message is about to be accepted for delivery |
OnRecipientUnknown |
A RCPT TO names an unknown local recipient — new in 6.x |
OnTooManyInvalidCommands |
The server disconnects a session for too many invalid commands — new in 6.x |
OnDeliveryStart |
Delivery of an accepted message begins, before any rules |
OnDeliverMessage |
During delivery — after global rules, before account rules |
OnDeliveryFailed |
Delivery of an accepted message to a recipient has failed |
OnExternalAccountDownload |
A message has been fetched from an external POP3 or IMAP account |
OnBackupCompleted |
A backup has finished successfully |
OnBackupFailed |
A backup has failed |
OnError |
The server has logged an error |
28.4 The Result object
Result is a temporary object that the server creates for the events that consult it — eight of the fifteen. Your script writes to it; hMailServer reads it when the handler returns and acts accordingly. It has three properties:
| Property | Type | Meaning |
|---|---|---|
Result.Value |
number | Your decision. Pre-set to 0 — “carry on as normal” — in every event except OnClientValidatePassword, where the server pre-sets 2 (“built-in validation decides”) |
Result.Message |
string | The error text used when an event supports a script-defined rejection |
Result.Parameter |
number | An extra numeric argument; used only by OnExternalAccountDownload |
What each value means depends on the event:
| Event | 0 |
1 |
2 |
3 |
|---|---|---|---|---|
OnClientConnect |
accept the connection | close the connection | — | — |
OnHELO |
accept the greeting | reject with 554 5.7.1 Rejected |
reject with 554 5.7.1 and Result.Message |
temporary failure: 453 4.7.0 and Result.Message |
OnClientValidatePassword |
password is correct — log the user in | password is wrong — refuse the login | built-in validation decides (the pre-set default) | — |
OnSMTPData |
accept | reject with 554 5.7.1 Rejected |
reject with 554 5.7.1 and Result.Message |
temporary failure: 453 4.7.0 and Result.Message |
OnAcceptMessage |
accept | reject with 554 5.7.1 Rejected |
reject with 554 5.7.1 and Result.Message |
temporary failure: 453 4.7.0 and Result.Message |
OnDeliveryStart |
deliver | do not deliver | — | — |
OnDeliverMessage |
deliver | do not deliver | — | — |
OnExternalAccountDownload |
account’s own setting applies | delete from the remote server now | delete after Result.Parameter days |
never delete from the remote server |
The other seven events — OnClientLogon, OnRecipientUnknown,
OnTooManyInvalidCommands, OnDeliveryFailed, OnBackupCompleted, OnBackupFailed
and OnError — are notifications only: setting Result there does nothing. In
OnClientLogon, OnRecipientUnknown, OnTooManyInvalidCommands, OnDeliveryFailed and
OnError the server does not create the Result object at all, so referencing it there is a runtime
error, not a no-op.
Older hMailServer documentation quoted the fixed rejection reply as
542 Rejected— a code no SMTP standard defines. What the server actually sends is554 5.7.1 Rejected. The temporary-failure value3(a453 4.7.0reply) asks a well-behaved sender to retry later — useful for “not right now” policies where a permanent554would be a lie.“Do not deliver” is silent. A
Result.Valueof1inOnDeliveryStartorOnDeliverMessagedrops the message with no bounce and no trace beyond the logs. The sender believes it arrived. Use it deliberately, never as a lazy alternative to rejecting at SMTP time, where the sender is told.
28.5 The event reference
Every event below is available in 6.2 and later, and on Linux since 6.3.4. Alongside the named parameters, EventLog
(§28.6) exists in every handler, and Result (§28.4) in the eight events that honour
it. On Windows, scripts may also create COM objects with CreateObject (§28.7): any class while
ScriptAllowedObjects is at its shipped *, and only the listed ones once it is narrowed. On
Linux CreateObject exists only to throw a TypeError.
New in 6.x. The hMailServer.Client object handed to the connection and message
events has grown: alongside the classic IPAddress, Port, Username and HELO it
now reports SessionID (matches the session ID in the logs), Authenticated,
EncryptedConnection, and — on TLS sessions — the negotiated CipherVersion,
CipherName and CipherBits. All are read-only.
OnClientConnect
Fires the moment a client opens a TCP connection to any of the server’s protocol ports
— SMTP, IMAP and POP3 alike. Nothing has been said on the wire yet: no banner, no
login. oClient.Username is therefore always an empty string here.
Sub OnClientConnect(oClient)
| Parameter | Type | Populated |
|---|---|---|
oClient |
hMailServer.Client |
IPAddress, Port and SessionID only |
No message object exists at this point. Check oClient.Port when your policy applies
to one protocol only.
Result.Value |
Effect |
|---|---|
0 |
Accept the connection |
1 |
Close the connection immediately |
This event fires for every connection the server receives. It is the hottest path in the whole scripting surface — a handler that takes 100 ms puts 100 ms in front of every SMTP, IMAP and POP3 session. Keep it to a few comparisons. For static allow and deny policy, Sign-in & access → IP ranges is faster and clearer; script only what ranges cannot express.
OnHELO
New in 6.x. Fires when an SMTP client greets the server — once per HELO or EHLO
command. A client that greets again fires it again, and every STARTTLS client does
exactly that after the handshake, so expect more than one firing per session.
Sub OnHELO(oClient)
| Parameter | Type | Populated |
|---|---|---|
oClient |
hMailServer.Client |
IPAddress, Port, SessionID, HELO, and the encryption properties; Username is still empty |
oClient.HELO holds the name the client just presented — the natural place to refuse
greetings that claim to be your own server or bare IP-address literals your policy
forbids.
Result.Value |
Effect |
|---|---|
0 |
Accept the greeting |
1 |
Reject with 554 5.7.1 Rejected |
2 |
Reject with 554 5.7.1 and the text in Result.Message |
3 |
Reject temporarily with 453 4.7.0 and the text in Result.Message |
A rejection refuses the greeting itself, so the session goes no further than trying again or leaving. The built-in HELO host check (§26.5) already covers the common cases; script the exotic ones.
OnClientValidatePassword
New in 6.x. Fires whenever hMailServer is about to check a password a client sent —
SMTP AUTH, POP3 and IMAP logins with a password. It lets a script take over that check
entirely: an external directory, a per-application password scheme, an extra policy
gate. It is not consulted for SCRAM-SHA-256 (or -PLUS), which proves the
password without handing it to the server and is offered wherever AUTH is: a client that
signs in with SCRAM never reaches the handler, so a gate built on it does not stop that client.
The server pre-sets Result.Value to 2, so a handler that does nothing changes
nothing.
Sub OnClientValidatePassword(oAccount, sPassword)
| Parameter | Type | Meaning |
|---|---|---|
oAccount |
hMailServer.Account |
The account logging in — loaded fresh from the database, not from the cache |
sPassword |
string | The password the client supplied, in clear text |
Result.Value |
Effect |
|---|---|
0 |
The password is correct — the login succeeds, and built-in validation is skipped |
1 |
The password is wrong — the login fails |
2 |
(the pre-set default) hMailServer’s own validation decides |
This handler sees every user’s password in clear text, on every password sign-in. Never write
sPasswordto a log, and remember this is a hot path — it runs for every password check on every protocol. Note too thatResult.Value = 0bypasses all built-in checks, including the refusal of empty passwords — if you accept, be sure you have actually checked something.
OnClientLogon
New in 6.x. Fires when an authentication attempt completes on SMTP, POP3 or IMAP —
successful or not. oClient.Authenticated tells you which, and oClient.Username
holds the name that was attempted.
Sub OnClientLogon(oClient)
| Parameter | Type | Populated |
|---|---|---|
oClient |
hMailServer.Client |
fully — Username, Authenticated, the connection and encryption properties (HELO on SMTP sessions only) |
Notification only — no Result object. This is the place for login auditing and custom alerting on suspicious patterns; the brute-force case itself is already handled by auto-ban (chapter 17), which needs no script.
OnSMTPData
Fires once per message, when the SMTP client issues the DATA command — the message
content has not yet been transferred. The connection-stage anti-spam tests (DNS
blacklists, HELO host, MX, SPF) and greylisting, which decides at RCPT TO, have run; the content
transfer, the content-stage tests (SURBL, DKIM, SpamAssassin) and OnAcceptMessage all lie ahead.
Greylisting waits until after DATA for an empty envelope sender, or for a sender whose
greylisting bypass entry needs DKIM or DMARC; and on a connection from a listed incoming relay the
connection-stage tests wait for the body too. Rejecting
here spares the server the transfer and all of that later work.
Sub OnSMTPData(oClient, oMessage)
| Parameter | Type | Populated |
|---|---|---|
oClient |
hMailServer.Client |
fully |
oMessage |
hMailServer.Message |
partially — see below |
At this early point — no content has arrived, so there is no subject, body or header to
read — only four things on oMessage are reliable:
| Property | What it holds |
|---|---|
oMessage.Charset |
The message character set — utf-8, say |
oMessage.Filename |
The message’s .eml file under the Data directory |
oMessage.FromAddress |
The envelope sender |
oMessage.Recipients |
Each recipient’s Address |
Result.Value |
Effect |
|---|---|
0 |
Accept the message |
1 |
Reject with the fixed SMTP reply 554 5.7.1 Rejected |
2 |
Reject with 554 5.7.1 and the text you put in Result.Message |
3 |
Reject temporarily with 453 4.7.0 and the text in Result.Message |
Never call
oMessage.Saveinside OnSMTPData. The message is still being processed; saving it here corrupts it. Inspect and decide — nothing more. If you want to modify the message, wait forOnAcceptMessage.The fixed
554 5.7.1 Rejectedtells the sender nothing. PreferResult.Value = 2with a plain, truthfulResult.Message— or3when “try again later” is the honest answer.
OnAcceptMessage
The workhorse. Fires when SMTP delivery of the message to the server is complete and every built-in anti-spam check has run — the last moment at which the sender can still be told “no”. The message has not yet been written to the database, so it has no ID yet; everything else about it is available.
Sub OnAcceptMessage(oClient, oMessage)
| Parameter | Type | Populated |
|---|---|---|
oClient |
hMailServer.Client |
fully |
oMessage |
hMailServer.Message |
fully, except ID |
You may change anything on the message — headers via oMessage.HeaderValue, Subject,
Body, the Recipients and Attachments collections — and the changes stick once you
call oMessage.Save. Save once, at the end, not after every change.
Result.Value |
Effect |
|---|---|
0 |
Accept the message |
1 |
Reject with the fixed SMTP reply 554 5.7.1 Rejected |
2 |
Reject with 554 5.7.1 and the text you put in Result.Message |
3 |
Reject temporarily with 453 4.7.0 and the text in Result.Message |
OnRecipientUnknown
New in 6.x. Fires when an SMTP client’s RCPT TO names an address in a local
domain that does not exist — just before the server answers 550 Unknown user. The
rejection has already been decided; the event is your window onto it.
Sub OnRecipientUnknown(oClient, oMessage)
| Parameter | Type | Populated |
|---|---|---|
oClient |
hMailServer.Client |
fully |
oMessage |
hMailServer.Message |
the envelope so far — FromAddress and any previously accepted Recipients |
Notification only — no Result object; you cannot un-reject the recipient. Use it to spot directory-harvest probes (many unknown recipients from one IP in quick succession) and to catch persistently mistyped addresses that deserve an alias (chapter 13).
OnTooManyInvalidCommands
New in 6.x. Fires when the server gives up on an SMTP session that has exceeded the
invalid-command limit and disconnects it — just after it sends
421 4.7.0 Too many invalid commands. Bye!.
Sub OnTooManyInvalidCommands(oClient, oMessage)
| Parameter | Type | Populated |
|---|---|---|
oClient |
hMailServer.Client |
fully |
oMessage |
hMailServer.Message |
the message transaction in progress, if any — guard before touching it, since a session can be dropped before any MAIL FROM |
Notification only — the disconnect has already been decided. Log the client details; if the same address keeps appearing, an IP range or firewall rule finishes the job. The event fires only while Disconnect clients sending too many invalid commands is on (Connections & protocols → Protocols, SMTP tab), and it is off by default.
OnDeliveryStart
Fires when delivery of an accepted message begins — before the anti-virus check and before any global rules run. The client is long gone; only the message is available.
Sub OnDeliveryStart(oMessage)
| Parameter | Type | Populated |
|---|---|---|
oMessage |
hMailServer.Message |
fully |
Result.Value |
Effect |
|---|---|
0 |
Deliver the message |
1 |
Do not deliver — the message is dropped silently, with no bounce |
OnDeliverMessage
Fires during delivery, after global rules have run but before account-level rules. This
is the difference from OnDeliveryStart, which fires before both. Use OnDeliverMessage
when you want to see the message as the global rules have left it.
Sub OnDeliverMessage(oMessage)
| Parameter | Type | Populated |
|---|---|---|
oMessage |
hMailServer.Message |
fully |
Result.Value |
Effect |
|---|---|
0 |
Deliver the message |
1 |
Do not deliver — dropped silently, no bounce |
OnDeliveryFailed
Fires when delivery of an accepted message to a recipient fails. A message the server rejected — at SMTP time, by anti-spam, or by your own script — never reaches this event, because it was never accepted in the first place. For a message with several failed recipients the event fires once per recipient. A temporary deferral that is still being retried is not yet a failure; the event accompanies the failure that is final.
Sub OnDeliveryFailed(oMessage, sRecipient, sErrorMessage)
| Parameter | Type | Meaning |
|---|---|---|
oMessage |
hMailServer.Message |
The message that could not be delivered |
sRecipient |
string | The recipient it could not be delivered to |
sErrorMessage |
string | The error that caused the failure, typically the remote server’s reply |
Notification only — no Result object.
OnExternalAccountDownload
Fires when a message has been downloaded from an external account — the per-account POP3 or IMAP fetching configured on the account dialog’s External tab (chapter 6).
Sub OnExternalAccountDownload(oFetchAccount, oMessage, sRemoteUID)
| Parameter | Type | Meaning |
|---|---|---|
oFetchAccount |
hMailServer.FetchAccount |
The external account the message was fetched for |
oMessage |
hMailServer.Message |
The downloaded message — Nothing (null in JavaScript) if it had been downloaded before, or if the download failed |
sRemoteUID |
string | The message’s UID on the remote server |
Here the Result object controls one thing only: what happens to the copy on the remote server. The downloaded message is delivered locally whatever you set.
Result.Value |
Effect on the remote copy |
|---|---|
unset (0) |
The external account’s own deletion setting applies |
1 |
Delete it immediately |
2 |
Delete it after the number of days in Result.Parameter |
3 |
Never delete it |
Always test
If oMessage Is Nothingbefore touching the message — a previously seen message arrives with no message object at all, and an unguarded property access is a runtime error.
OnBackupCompleted
Fires when a backup (chapter 15) has completed successfully. No parameters, no result —
use it on Windows to notify someone, or to copy the finished backup off the machine. On Linux a handler has no file,
network or process access and creates no objects, so there it can only write to EventLog.
Sub OnBackupCompleted()
OnBackupFailed
Fires when a backup has failed, and tells you why. A backup that fails silently is
worse than no backup at all — at minimum, log it somewhere a human will look. Since 6.3.4 the server also raises its own
backup.failed alert, on as shipped, which is mailed once AlertRecipient and
AlertSenderAddress are set. A handler that logs the reason:
Sub OnBackupFailed(sReason)
EventLog.Write("BACKUP FAILED — " & sReason)
End Sub
| Parameter | Type | Meaning |
|---|---|---|
sReason |
string | Why the backup failed — "The specified backup directory is not accessible: …", "Could not backup data directory." and the like |
Notification only — setting Result here does nothing.
OnError
Fires when the server logs an error. Notification only — no Result object.
Sub OnError(iSeverity, iCode, sSource, sDescription)
| Parameter | Type | Meaning |
|---|---|---|
iSeverity |
number | 1 Critical, 2 High, 3 Medium, 4 Low |
iCode |
number | The hMailServer error code |
sSource |
string | The component that reported the error |
sDescription |
string | A description of the error |
This event has the same name it had in classic hMailServer —
OnError— so 5.x-era handlers carry over unchanged. The site documentation’s chapter 23 previously listed it as “OnErrorLog”; that was an error in the documentation, not a rename in the server.Keep OnError handlers minimal, and never do anything in one that can itself fail and be logged as an error — that way lies a loop. Filter on
iSeverityand pass the details somewhere cheap. For serious alerting, the Prometheus metrics endpoint (chapter 16) is the sturdier tool.
28.6 Logging and debugging
The EventLog object is available in every handler:
EventLog.Write("OnAcceptMessage: from=" & oMessage.FromAddress)
It appends to the events log, hmailserver_events.log, in the Logs directory
(/var/log/hmailserver on Linux; chapter 16). Liberal EventLog.Write calls while developing, stripped back once the
script works, is the debugging method that works.
When a script goes wrong:
- A compile error loads nothing. If the file will not compile, or its code outside a
function throws, none of it is installed, and the error log records the error with its line and
column. At a service start that means no events fire at all (
HM5016) and mail flows as if scripting were switched off; at a reload the previous script stays in force, whole, and goes on running (HM5710), so the old handlers still run and your edit does not. A language the build does not run (VBScript or JScript on Linux, JavaScript on Windows) is refused the same way, with a line naming the language to use. A Linux server still on the seededVBScriptruns no script until the language is set toJavaScript. Check the error log first whenever a script appears to be ignored, and use Check syntax on Maintenance → Event scripts (orPOST /api/v1/settings/scripting/check) to see the compile error and its line. - A runtime error aborts the handler at the point it strikes and is written to the
error log. Whatever
Result.Valueheld at that moment is what the server acts on — for most events the pre-set0, so the message carries on (chapter 23), but a value the handler set before the error still stands. Do not paper over this with a blanketOn Error Resume Next; if you must use it, checkErrafterwards and log what happened. - Watch it live. Keep Monitoring & troubleshooting → Live logs open and send yourself a test message, exactly as in chapter 6:
Send-MailMessage -SmtpServer localhost -Port 25 `
-From "[email protected]" -To "[email protected]" `
-Subject "script test" -Body "Trigger the events."
And develop on a test server, not the production one. Every mistake in
EventHandlers.vbs is a mistake in the path of live mail.
28.7 Reaching the rest of the COM API
On Windows, event scripts are not limited to the objects handed to them. The whole COM API of chapter 23 is available — create the Application object, authenticate, and read or change anything the Control Panel can:
Sub OnBackupFailed(sReason)
Dim oApp
Set oApp = CreateObject("hMailServer.Application")
If oApp.Authenticate("Administrator", "your-admin-password") Is Nothing Then
EventLog.Write("OnBackupFailed: COM authentication failed.")
Exit Sub
End If
' oApp.Settings, oApp.Domains and the rest are now available.
End Sub
Check the return values. On current builds a refused COM call genuinely fails —
AuthenticatereturnsNothingon a bad password — so a script that tests its results finds out immediately. The background is the 6.2.10 authorization fix, described in chapter 29. Since 6.3.4 a refusal from any COM class also arrives with the server’s own sentence, where 72 classes gave only the bare error0x800403E9, so log the error’s description. And once the administrator credential has a second factor (§17.4),AuthenticaterefusesAdministratoroutright:AuthenticateWithCodeis the only way past it, and a script has no code to give.
Putting the administrator password in EventHandlers.vbs hands it to anyone who can read
the file, so restrict reading the Events folder as well as writing to it (§28.1): administrators,
and the service account, which needs only Read there. Fetch only what you
need, and do not cache authenticated objects across events — each handler invocation
stands alone. Since 6.3.4 a property set over COM on a route, an incoming relay, a DNS blacklist, a
SURBL server, a remote-domain policy, a server message, a blocked attachment, an SSL certificate or a
group is held on that object’s own copy until Save(), so a script that changes one of these
must save it or nothing changes.
None of this section applies on Linux, where event scripts are JavaScript since 6.3.4 and there is no
COM: CreateObject and new ActiveXObject exist there only to throw a TypeError,
and a handler reaches nothing beyond the objects it is handed.
28.8 Performance and safety
Event scripts run synchronously, inside the mail flow. The rules that follow are all consequences of that one fact.
- Budget in milliseconds. Every line of
OnClientConnectruns for every connection; every line ofOnAcceptMessageruns for every inbound message — and every line ofOnClientValidatePasswordruns for every password sign-in on every protocol (not SCRAM-SHA-256, which has no password to hand it). - Keep the top level to declarations. Every event gets a fresh script engine that runs the whole file before it calls the handler, so code outside a function runs for every connection, every message and every error, and nothing a script keeps in a global survives to the next event. A top level that throws, or runs past the timeout, fails the load.
- The timeout is a backstop, not a budget. A handler still running after
ScriptTimeoutseconds (60 by default) is interrupted —HM5019on Windows,HM7341on Linux — and everything behind it has waited that long; on Windows an interrupt cannot release a handler blocked inside a COM call. The key is set on Maintenance → Server limits & expert settings (card Timeouts and queue bounds), over REST or COM, or withhmailserver --set-settingon Linux. - No network calls. An HTTP request, DNS lookup or database query to another machine inside an event puts that machine’s latency — and its outages — in front of your mail. If an external check matters, note the message and let something outside the mail flow do the checking.
- Handlers run concurrently. The server processes many sessions at once, so the same event can be executing several times simultaneously. Keep handlers re-entrant: no shared state, and treat any shared file as contended.
- Never anything interactive. The script runs inside the server’s service, with no
desktop. A
MsgBoxhas nowhere to appear and will hang the handler; on Linux a JavaScript handler has noprintorconsoleeither, so write toEventLog. - Save once, and never in
OnSMTPData. Make all your changes, then oneoMessage.Saveat the end of the handler. - Prefer the built-in tool when one exists. IP ranges, rules, Sieve, auto-ban and the anti-spam stack — since 6.3.4 also the domain white and black list, and country blocking given a country database you keep up to date — are all faster than script and need no script maintenance. Script is for the gaps between them.
28.9 Worked examples
Four complete, deployable handlers. Adjust the addresses and lists, paste into
EventHandlers.vbs, reload, and test. They are VBScript, for Windows. On Linux, where the
language is JavaScript since 6.3.4, the first three can be rewritten in JavaScript in EventHandlers.js; beyond the syntax, write
message.SetHeaderValue(name, value) where VBScript assigns HeaderValue, and
message.Save() with its brackets; the fourth has no Linux form, because a JavaScript handler
cannot create objects.
Tag suspicious mail instead of rejecting it
Subject-pattern matching produces false positives, so mark the message and let it through — a tagged message is recoverable, a rejected one is not. Rules or Sieve (chapter 12) can then file anything carrying the header.
Sub OnAcceptMessage(oClient, oMessage)
Dim sSubject
sSubject = LCase(oMessage.Subject)
If InStr(sSubject, "urgent invoice") > 0 Or _
InStr(sSubject, "payment overdue") > 0 Then
oMessage.HeaderValue("X-PR-Suspect") = "subject-pattern"
oMessage.Subject = "[SUSPECT] " & oMessage.Subject
oMessage.Save
End If
End Sub
Refuse unwanted networks at connect
Prefix matching against a deny list, applied to port 25 only so your own users on the
submission port are never touched. For ranges you can write down statically, prefer
Sign-in & access → IP ranges. For whole countries, prefer the country blocking built in since
6.3.4 (chapter 10): with a MaxMind country database named in GeoIpDatabaseFile, a
CountryPolicySMTP of block:CN,RU closes those countries’ connections before the
greeting, with no script. That policy covers every SMTP listener, submission included, so a prefix list
loaded into this array remains the way to police port 25 alone.
Sub OnClientConnect(oClient)
' Police port 25 only — our users authenticate on 587.
If oClient.Port <> 25 Then Exit Sub
Dim aBlocked, sPrefix
aBlocked = Array("203.0.113.", "198.51.100.")
For Each sPrefix In aBlocked
If Left(oClient.IPAddress, Len(sPrefix)) = sPrefix Then
Result.Value = 1 ' close the connection
Exit Sub
End If
Next
End Sub
Block a known spammer at SMTP time
Rejecting in OnSMTPData tells the sender honestly and spares the server the content
transfer and the content-stage anti-spam work. Only FromAddress and the recipient
addresses are reliable here — and no oMessage.Save. For a whole sending domain,
6.3.4’s Spam & virus filtering → Domain white and black list needs no script: a black
entry refuses it at MAIL FROM, before anything else runs, for an unauthenticated session from an
IP range that runs the spam tests.
Sub OnSMTPData(oClient, oMessage)
Dim sFrom
sFrom = LCase(oMessage.FromAddress)
If sFrom = "[email protected]" Or _
InStr(sFrom, "@spam-domain.example") > 0 Then
Result.Message = "Mail from this sender is not accepted here."
Result.Value = 2
End If
End Sub
Tell the postmaster when delivery fails
Creating a new hMailServer.Message and saving it queues it for delivery like any
other mail. The guards at the top matter: skip bounces (their envelope sender is
empty) and skip mail from the notification address itself, or one stuck recipient
becomes an endless loop of notifications about notifications.
Sub OnDeliveryFailed(oMessage, sRecipient, sErrorMessage)
If oMessage.FromAddress = "" Then Exit Sub
If LCase(oMessage.FromAddress) = "[email protected]" Then Exit Sub
Dim oNotice
Set oNotice = CreateObject("hMailServer.Message")
oNotice.From = "[email protected]"
oNotice.FromAddress = "[email protected]"
oNotice.AddRecipient "", "[email protected]"
oNotice.Subject = "Delivery failure: " & sRecipient
oNotice.Body = "A message from " & oMessage.FromAddress & _
" to " & sRecipient & " could not be delivered." & vbCrLf & vbCrLf & _
"Error: " & sErrorMessage & vbCrLf & _
"Subject: " & oMessage.Subject
oNotice.Save ' saving a new message queues it for delivery
End Sub
More: Event scripts: every event and its arguments, the limits, and the 6.3.4 security fix on the forum.
29. COM API reference I: server, domains and accounts
This is the first of three reference chapters covering every object in the COM API. Chapter 23 introduces the API and event scripts; this chapter documents the root Application object, the server-wide Settings object, and everything that makes up a domain — accounts, aliases, distribution lists, groups and external fetch accounts — plus the API’s constants and its changelog. The remaining objects (messages, folders, rules, anti-spam and the other Settings sub-objects) follow in the next two reference chapters.
Nearly everything the Control Panel can do, the API can do — the Control Panel is a COM API
client, as were the two administration tools this fork retired, the classic hMailServer
Administrator and the PHP WebAdmin (chapter 25). Some features have no COM surface at all and are REST only
(chapter 24), among them SCIM provisioning, SAML and OpenID Connect sign-in, passkeys, the send API, migrations and
eDiscovery, whose read-only Control Panel page reads over REST. PowerShell is the preferred
scripting language today and is used for the
examples; VBScript still works, both standalone under cscript.exe and in event
scripts. The whole API is also available to .NET code through
C:\Program Files\hMailServer\Bin\Interop.hMailServer.dll. All of this is Windows: the
Linux build has no COM, is administered over the REST API (chapter 24) and its command line, and
since 6.3.4 runs its event scripts in JavaScript rather than VBScript or JScript (chapter 28).
Connecting and authenticating
Every script starts the same way: create an hMailServer.Application object and call
Authenticate. Nothing else in the API is reachable until you have — every other
object is navigated to through the Application object, and that is deliberate: it is
how the API enforces authentication. The one exception is a server with no administrator password: while
[Security] AdministratorPassword is empty and no second factor is enrolled on the administrator
credential, every COM session starts already authenticated as a server administrator, with no credential at all
(the REST API refuses to start in that state, but COM does not). Set the password (chapter 17).
$app = New-Object -ComObject 'hMailServer.Application'
$session = $app.Authenticate('Administrator', 'your-admin-password')
if ($null -eq $session) { throw 'Authentication failed' }
"Connected to hMailServer $($app.Version)"
Authenticate returns an object representing the authenticated identity on success, and
nothing at all on failure — test the return value, do not assume.
Two kinds of credentials are accepted:
| User name | Password | Rights |
|---|---|---|
Administrator |
The main administration password | Full server administration |
An account address ([email protected]) |
That account’s password (not one of its app passwords, which Authenticate answers as a wrong password) |
Whatever the account’s AdminLevel grants — its own settings, its domain, or the whole server. Since 6.3.4,
an account given an administrator role (Accounts & domains → Administrator roles) reaches what that
role’s permissions reach, in the domains its assignment names (none named: every domain for serveradmin and auditor, the account’s own domain for the other roles); Application.CurrentRole and
CurrentPermissions say what the session holds |
Accessing something your credentials do not cover throws an exception; it does not fail
silently. Since 6.3.4 the exception carries the server’s own sentence (You do not have access to this
property / method…), where up to 6.3.3 seventy-two classes answered with a bare
Exception from HRESULT: 0x800403E9.
A second factor changes the call. Once a second factor is enrolled on the administrator
credential (chapter 17), Authenticate refuses the administrator whatever the password. Read
$app.AdministratorTOTPEnabled, which answers before you authenticate, and call
$app.AuthenticateWithCode('Administrator', $password, $code) instead. Since 6.3.4 a code is
accepted once only: a second sign-in within the same half minute waits for the authenticator’s next code.
VBScript is unchanged:
Dim app: Set app = CreateObject("hMailServer.Application")
If app.Authenticate("Administrator", "your-admin-password") Is Nothing Then
WScript.Echo "Authentication failed"
WScript.Quit 1
End If
COM calls run against the local installation. The Control Panel administers remote servers over DCOM, and a script can do the same — the Remote administration support component in the installer (chapter 4) registers the COM type library for exactly that, and DCOM must be permitted between the two machines. For remote automation, though — and from anything that is not Windows — the REST API (chapter 24) is the better tool.
Because this fork is 64-bit only, the old 5.x gotcha of needing a 32-bit script host to
create the COM object is gone. Any 64-bit PowerShell or cscript.exe works.
The 6.2.10 return-value fix. Before 6.2.10, fifteen COM methods reported success (
S_OK) on calls they had actually refused — among themSetAdministratorPassword, which could claim a password change that never happened, and five getters that handed an unauthorised caller “success” plus uninitialised memory. 6.2.10 makes refused calls return errors, as they always should have. If you script against the COM API, run 6.2.10 or later, and check every return value — a script that appeared to work on an earlier version may have been silently doing nothing.
How the API fits together
The object model is a tree of its own; the Control Panel’s eight task-based groups do not follow it.
Every object is reached from Application; you cannot create a Domains or Settings object directly.
hMailServer.Application
├─ Settings ← server-wide configuration (this chapter)
│ ├─ AntiSpam, AntiVirus, Logging, MessageIndexing, Routes, SecurityRanges, TCPIPPorts, …
│ │ ← later reference chapters
│ └─ Groups ── Group ── Members ── GroupMember
├─ Domains ── Domain
│ ├─ Accounts ── Account
│ │ ├─ IMAPFolders ── IMAPFolder ── Messages ── Message
│ │ ├─ FetchAccounts ── FetchAccount
│ │ └─ Rules ── Rule
│ ├─ Aliases ── Alias
│ ├─ DistributionLists ── DistributionList ── Recipients ── DistributionListRecipient
│ ├─ DomainAliases ── DomainAlias
│ └─ Rules ── Rule ← the domain's own rules (6.3.4)
├─ GlobalObjects ── DeliveryQueue, Languages, MessageTrace
├─ Links ← shortcut lookups by database ID
├─ Rules ← global rules
└─ Status, Utilities, Database, BackupManager, Diagnostics
Collections follow one pattern. A plural object (Domains, Accounts, Aliases…)
holds singular items. To create an item, call Add() on the collection — the new
object exists only in memory until you call Save() on it, which writes it to the
database and adds it to the collection. Collections expose Count, an indexed Item()
(zero-based), lookups by database ID (ItemByDBID) and usually by name or address, a
matching set of Delete methods, and Refresh() to re-read from the database.
PowerShell can enumerate any collection directly with foreach.
Since 6.3.4 a change to an item the running server shares waits for Save(). For a
route or one of its addresses, an incoming relay, a DNS blacklist, a SURBL server, a remote domain policy, a
server message, a blocked attachment, an SSL certificate or a group, setting a property writes your COM
object’s own copy; Save() writes it to the database and the server reads the list again. A
property set and never saved changes nothing (up to 6.3.3 it changed the running server until the next
restart), and another object for the same item, read before the save, keeps what it read: read the item
again from its collection after saving.
The API throws exceptions — surfaced in PowerShell as a terminating COMException,
so wrap risky calls in try/catch:
| It throws when… | Example |
|---|---|
| You create a duplicate | Two accounts with the same address |
| A collection lookup finds nothing | ItemByName('no-such-domain.com') |
| Your credentials are insufficient | A user-level session touching Settings |
| The database is unreachable | Connection to the database server lost |
More: The COM API on Windows: object model, Save() since 6.3.4, roles and the Import Tool on the forum.
Application
The root of the API. Its properties are the doorways to everything else; its methods control the server process itself.
| Property | Type | Access | Meaning |
|---|---|---|---|
AdministratorTOTPEnabled |
bool | RO | Whether a second factor is enrolled on the administrator credential; readable before authenticating (6.2.25) |
BackupManager |
BackupManager | RO | Runs backups and restores |
Database |
Database | RO | The database interface — connection state, transactions, setup |
Diagnostics |
Diagnostics | RO | New in 6.x. Connectivity self-tests — PerformTests() returns a DiagnosticResults collection (documented in a later reference chapter) |
Domains |
Domains | RO | Every domain on the server |
GlobalObjects |
GlobalObjects | RO | Delivery queue, message trace and language catalogue |
InitializationFile |
string | RO | Path of the hMailServer.INI in use |
Links |
Links | RO | Shortcut lookups by database ID |
Rules |
Rules | RO | The global (server-wide) rules |
ServerState |
eServerState | RO | Current state of the server engine |
Settings |
Settings | RO | All server-wide configuration |
Status |
Status | RO | Live counters and status text |
Utilities |
Utilities | RO | Helper functions (hashing, DNS checks, message import) |
Version |
string | RO | The hMailServer version |
VersionArchitecture |
string | RO | The build architecture — x64 in the x64 installer’s build; the ARM64 installer’s build, which 6.3.6 is the first release to carry, answers ARM64 |
| Method | Does |
|---|---|
Authenticate(username, password) |
Authenticates the session; returns the authenticated identity, or nothing on failure |
AuthenticateWithCode(username, password, code) |
Authenticates with a one-time code as well — the only call that satisfies a second factor, the administrator credential’s included |
Connect() |
Opens the database connection without authenticating — used by setup tooling |
Reinitialize() |
Reinitialises the server in place: every service stops and starts again, with its settings read afresh
from the database. It is what the Control Panel’s Apply now does; since 6.3.4 it applies the saved
[Settings] keys that wait for a reinitialise, and it ends maintenance mode (a drain) |
Start() |
Starts the server engine |
Stop() |
Stops the server engine |
SubmitEMail() |
Kicks off delivery of pending messages — useful after inserting a message directly into the database |
New in 6.3.4: administrator roles. Application gains Roles and
RoleAssignments, which list the roles and who holds them; AssignRole(address, role, domains),
which gives an account a role (an empty role name takes it away); CurrentRole and
CurrentPermissions, which say what this session holds; and ResetPassword and
UnlockAccount, which a help desk may call for the accounts it reaches. An account’s own role reads
back as Account.RoleName, RoleDomains and RolePermissions. COM’s settings
objects answer only a server administrator, so an auditor reads the configuration through the Control Deck or
REST.
Settings
Server-wide configuration — everything that is not tied to one domain. This is the
largest object in the API, so its scalar properties are grouped below by what they
control. The read-only properties that return whole sub-objects (AntiSpam,
Logging, …) are listed last. The tables cover the long-standing core: 6.3.4 appends many more
members, among them the breach check (BreachCheck…), bounce and complaint processing
(Bounce…, Complaint…), webhooks, country blocking, session policy, outbound shaping, spam
traps, the attachment sandbox (Sandbox…), message-store compression and setting profiles
(GetProfiles, ApplyProfile, RevertProfile). Every one is appended, so an early-bound
client built against 6.3.3 keeps working.
Settings has no
Save(). Each property write is persisted immediately, and most take effect at once, as when the same field is saved in the Control Panel (chapter 20). The thread counts (TCPIPThreads,MaxDeliveryThreads,MaxAsynchronousThreads) size pools the server builds when its services start, so a new value waits for a reinitialise (Application.Reinitialize) or a restart. Most keys that were the[Settings]section ofhMailServer.INIhave no property of their own and are reached throughGetIniSettingandSetIniSetting(below); a few do, such asRewriteEnvelopeFromWhenForwardingunder Performance and everything else. Since 6.3.4 the database holds them, and a save applies 211 of them at once, 28 at a reinitialise and 2 at a service restart. Since 6.3.4, too, a setting the server’s environment manages (HM_PROPERTY_<name>,HM_SETTING_<key>) refuses a change until a start without that variable.
Protocols and sessions
| Property | Type | Access | Meaning |
|---|---|---|---|
ServiceSMTP |
bool | RW | Enable the SMTP service |
ServiceIMAP |
bool | RW | Enable the IMAP service |
ServicePOP3 |
bool | RW | Enable the POP3 service |
HostName |
string | RW | The host name the server identifies itself with |
DefaultDomain |
string | RW | Domain assumed when a user logs on without @domain |
WelcomeSMTP |
string | RW | SMTP greeting banner (empty = default) |
WelcomeIMAP |
string | RW | IMAP greeting banner |
WelcomePOP3 |
string | RW | POP3 greeting banner |
MaxSMTPConnections |
long | RW | Simultaneous SMTP connections (0 = unlimited) |
MaxIMAPConnections |
long | RW | Simultaneous IMAP connections |
MaxPOP3Connections |
long | RW | Simultaneous POP3 connections |
MaxNumberOfInvalidCommands |
long | RW | Invalid client commands tolerated before disconnect |
DisconnectInvalidClients |
bool | RW | Drop clients that do not follow the RFCs |
AllowIncorrectLineEndings |
bool | RW | Accept mail with a bare CR or LF |
AllowSMTPAuthPlain |
bool | RW | Permit plain-text SMTP authentication mechanisms |
AddDeliveredToHeader |
bool | RW | Add a Delivered-To header to incoming messages |
DenyMailFromNull |
bool | RW | Reject mail with an empty envelope sender |
Leave
DenyMailFromNulloff. The null sender (MAIL FROM:<>) is how bounces and delivery reports arrive; rejecting it breaks the standard and loses you legitimate non-delivery notices.
Delivery and relaying
| Property | Type | Access | Meaning |
|---|---|---|---|
SMTPNoOfTries |
long | RW | Retries of a temporarily failed delivery before the message is bounced (0 = one attempt, then a bounce) |
SMTPMinutesBetweenTry |
long | RW | Minutes between delivery attempts |
SMTPConnectionSecurity |
eConnectionSecurity | RW | Encryption used when delivering after an MX lookup |
SMTPDeliveryBindToIP |
string | RW | Local IP address to bind for outbound delivery |
MaxDeliveryThreads |
long | RW | Simultaneous SMTP delivery threads |
MaxSMTPRecipientsInBatch |
long | RW | Recipients per outbound SMTP transaction |
MaxNumberOfMXHosts |
long | RW | How many of a domain’s MX hosts to try per delivery (0 = all) |
MaxMessageSize |
long | RW | Largest message accepted for sending, in KB (0 = no limit) |
MirrorEMailAddress |
string | RW | An address that receives a copy of all mail (empty = off) |
SMTPRelayer |
string | RW | Smart host to relay all outbound mail through (empty = deliver directly); several hosts separated by
| are tried in turn |
SMTPRelayerPort |
long | RW | Smart host port |
SMTPRelayerConnectionSecurity |
eConnectionSecurity | RW | Encryption for the smart host connection |
SMTPRelayerRequiresAuthentication |
bool | RW | Smart host requires a login |
SMTPRelayerUsername |
string | RW | Smart host user name |
SMTPRelayerUseSSL |
bool | RW | Legacy toggle — superseded by SMTPRelayerConnectionSecurity |
A smart host (chapter 14) configured entirely by script:
$s = $app.Settings
$s.SMTPRelayer = 'smtp.example.net'
$s.SMTPRelayerPort = 587
$s.SMTPRelayerConnectionSecurity = 3 # eCSSTARTTLSRequired
$s.SMTPRelayerRequiresAuthentication = $true
$s.SMTPRelayerUsername = 'apikey'
$s.SetSMTPRelayerPassword('the-relay-password')
Auto-ban
| Property | Type | Access | Meaning |
|---|---|---|---|
AutoBanOnLogonFailure |
bool | RW | Ban an IP after repeated failed logins |
AutoBanMinutes |
long | RW | How long a ban lasts |
MaxInvalidLogonAttempts |
long | RW | Failures before a ban |
MaxInvalidLogonAttemptsWithin |
long | RW | Minutes before a failure record expires |
IMAP and public folders
| Property | Type | Access | Meaning |
|---|---|---|---|
IMAPACLEnabled |
bool | RW | Enable the IMAP ACL extension |
IMAPIdleEnabled |
bool | RW | Enable the IMAP IDLE extension |
IMAPQuotaEnabled |
bool | RW | Enable the IMAP QUOTA extension |
IMAPSortEnabled |
bool | RW | Enable the IMAP SORT extension |
IMAPHierarchyDelimiter |
string | RW | The folder-hierarchy delimiter used in the IMAP protocol |
IMAPMasterUser |
string | RW | The IMAP master-user name (empty = disabled) |
IMAPSASLPlainEnabled |
bool | RW | Offer the SASL PLAIN authentication mechanism to IMAP clients |
IMAPSASLInitialResponseEnabled |
bool | RW | Accept a SASL initial response (SASL-IR) from IMAP clients |
IMAPPublicFolderName |
string | RW | The public-folder root name shown to IMAP clients |
PublicFolderDiskName |
string | RO | The on-disk folder name public folders are stored under |
PublicFolders |
IMAPFolders | RO | The public folder tree |
TLS
These are the same settings as Certificates & encryption → SSL/TLS in the Control Panel; the certificates are on Certificates & encryption → SSL certificates and Certificates & encryption → Certificates (ACME).
| Property | Type | Access | Meaning |
|---|---|---|---|
SslCipherList |
string | RW | Permitted ciphers for TLS 1.2 and below, in OpenSSL format; it does not touch TLS 1.3, whose suites are the
[Settings] key TlsCipherSuites13 (SetIniSetting) |
VerifyRemoteSslCertificate |
bool | RW | Verify the remote certificate when delivering outbound |
SSLCertificates |
SSLCertificates | RO | Certificates configured on the server |
TlsVersion10Enabled |
bool | RW | Enable TLS 1.0 — off by default, see below |
TlsVersion11Enabled |
bool | RW | Enable TLS 1.1 — off by default, see below |
TlsVersion12Enabled |
bool | RW | Enable TLS 1.2 — on by default |
TlsVersion13Enabled |
bool | RW | New in 6.x. Enable TLS 1.3 — on by default |
TlsOptionPreferServerCiphersEnabled |
bool | RW | Prefer the server’s cipher order over the client’s |
TlsOptionPrioritizeChaChaEnabled |
bool | RW | Prefer ChaCha20-Poly1305 when the client puts it first; it takes effect only with
TlsOptionPreferServerCiphersEnabled on |
The defaults — TLS 1.2 and 1.3 only — are correct, for the reasons chapter 9 gives. The TLS 1.0 and 1.1 toggles still function, for the rare ancient client that cannot do better; if a script must turn one on, plan to replace that client rather than live with it. The 5.x
SslVersion30Enabledproperty is gone from the API entirely — OpenSSL 4.0, which this fork builds against, does not implement SSL 3.0 at all, and a script that still touches that property now fails instead of silently doing nothing.
Performance and everything else
| Property | Type | Access | Meaning |
|---|---|---|---|
TCPIPThreads |
long | RW | Threads handling TCP/IP communication |
WorkerThreadPriority |
long | RW | Meant as the OS priority for worker threads; stored but never read by the server, so setting it changes nothing (the Control Panel shows it read-only) |
MaxAsynchronousThreads |
long | RW | Cap on asynchronous task threads |
RuleLoopLimit |
long | RW | The rule loop guard: how long a chain of messages made by rules can grow (a forward, a reply or a copy carries
X-hMailServer-LoopCount one higher than the message that caused it; at the limit nothing more is generated) |
IPv6PreferredEnabled |
bool | RW | Prefer IPv6 over IPv4 when both are available |
RewriteEnvelopeFromWhenForwarding |
bool | RW | Rewrite the envelope sender to the forwarding account’s address when forwarding |
UserInterfaceLanguage |
string | RW | Language for administrative text; only third-party COM tools read it (the Control Panel has its own language choice) |
CrashSimulationMode |
long | RW | Deliberately faults the server — used by the test suite; leave at 0 |
The 5.x
SendStatisticstelemetry switch has been removed from the API, along with the phone-home statistics feature behind it. Old scripts that set it will fail on this fork; delete the line.
Sub-object gateways
Each of these read-only properties returns a whole configuration object, documented in the following reference chapters.
| Property | Returns |
|---|---|
AntiSpam |
Spam thresholds, SPF, greylisting, SpamAssassin, DNSBL and SURBL collections |
AntiVirus |
Virus scanning and attachment blocking |
Backup |
Backup configuration (a BackupSettings object) |
Cache |
Object-cache tuning and hit rates |
Directories |
Program, data, log and temp directory paths |
Groups |
Server-wide account groups (documented in this chapter) |
IncomingRelays |
Upstream gateways (spam filters, load balancers) whose IP addresses do not count as the connecting client in anti-spam host checks |
Logging |
Log switches, paths and retention; since 6.3.4 also syslog forwarding (Syslog…) |
MessageIndexing |
New in 6.x. The message-indexing engine — enable it, read the total/indexed message counts, Clear() and rebuild with Index() (documented in a later reference chapter) |
RemoteDomainPolicies |
New in 6.3.4. Per remote domain: the TLS this server demands in each direction, the size and connections it will attempt, recipient verification and gateway encryption (Mail flow & delivery → Remote domains) |
Routes |
Per-domain delivery overrides |
Scripting |
Event-script configuration |
SecurityRanges |
IP ranges — who may connect, authenticate and relay |
ServerMessages |
The text of bounces and system messages |
TCPIPPorts |
Listening ports and their TLS bindings |
Settings methods
| Method | Does |
|---|---|
SetAdministratorPassword(newVal) |
Sets the main administration password (see below for a new 6.3.4 installation) |
SetSMTPRelayerPassword(newVal) |
Sets the smart host password |
ClearLogonFailureList() |
Clears the failed-login records behind auto-ban, for every address; server administrator only. It does not lift a ban already made: delete that ban’s range on Sign-in & access → IP ranges |
GetIniSetting(name), SetIniSetting(name, value), DeleteIniSetting(name) |
Read, write or remove one key of the settings store that was the [Settings] section of
hMailServer.INI (chapter 21); since 6.3.4 the database holds it, and a removed key returns to its
default. IniSettingNames lists every key the server knows |
IniSettingApplies(name) |
New in 6.3.4. When a save of that key applies: now, reinitialize or
restart. PendingIniSettings lists the saved keys the server does not run on yet, one per line |
EnrolAdministratorTOTP(), DisableAdministratorTOTP() |
Enrol a second factor on the administrator credential (returns the otpauth:// URI) or remove it
(chapter 17) |
SetAdministratorPasswordwas among the methods fixed in 6.2.10 — on earlier versions it could report success for a refused call, leaving the old password silently in place. Check the call succeeds, and verify with a freshAuthenticate.The password is kept in
[Security] AdministratorPasswordinhMailServer.INI, and a new 6.3.4 installation runs the service asNT SERVICE\hMailServer, which may not write that file. There the call is refused with a sentence, as areEnrolAdministratorTOTPandDisableAdministratorTOTP. Set the password from an elevated prompt withhMailServer.exe --set-admin-password, giving it inHM_ADMIN_PASSWORDor on standard input and never as an argument, then restart the service; on Linux the same issudo hmailserver --set-admin-password.
Domains and Domain
The Domains collection
All domains on the server, reached as Application.Domains.
| Property | Type | Access | Meaning |
|---|---|---|---|
Count |
long | RO | Number of domains |
Item(index) |
Domain | RO | Domain by position in the collection |
ItemByDBID(dbid) |
Domain | RO | Domain by database ID |
ItemByName(name) |
Domain | RO | Domain by name — throws if not found |
Names |
string | RO | A flat listing of every domain’s database ID and name — a cheap way to fill a picker |
| Method | Does |
|---|---|
Add() |
Returns a new, unsaved Domain |
DeleteByDBID(dbid) |
Deletes the domain with that database ID. Since 6.3.4 it is refused, with an error, while the domain or any account in it is under an eDiscovery legal hold |
Refresh() |
Re-reads the collection from the database |
The Domain object
One hosted domain, with its collections of accounts, aliases, lists and domain aliases. 6.3.4 appends further
members, among them Rules (the domain’s own rules), the quota-warning policy
(QuotaWarning…), DKIM key rotation (DKIMRotation…), what the domain does to a message
(ExternalTag…, Disclaimer…), impersonation protection, spam traps and setting levels
(SettingOverride, EffectiveSetting).
| Property | Type | Access | Meaning |
|---|---|---|---|
Accounts |
Accounts | RO | The domain’s accounts |
Active |
bool | RW | Whether the domain is enabled |
Aliases |
Aliases | RO | The domain’s aliases |
ADDomainName |
string | RW | Active Directory domain to synchronise with |
AddSignaturesToLocalMail |
bool | RW | Apply signatures to domain-internal mail |
AddSignaturesToReplies |
bool | RW | Apply signatures to replies |
AllocatedSize |
long | RO | Space already allocated to the domain’s accounts, in MB |
AntiSpamEnableGreylisting |
bool | RW | Greylisting on or off for this domain |
DKIMSignEnabled |
bool | RW | Sign outgoing mail from this domain with DKIM |
DKIMSelector |
string | RW | The DKIM selector — names the DNS record holding the public key |
DKIMPrivateKeyFile |
string | RW | Path to the private key used for signing. Since 6.3.4, while the domain’s DKIM key rotation is on or has
a key in flight, a Save() that changes the selector or the key file is refused: use the rotation’s own
members (DKIMRotation…) |
DKIMSignAliasesEnabled |
bool | RW | Also sign mail sent from the domain’s aliases |
DKIMSigningAlgorithm |
eDKIMAlgorithm | RW | Signature hash — SHA-1 (1) or SHA-256 (2); use SHA-256 |
DKIMHeaderCanonicalizationMethod |
eDKIMCanonicalizationMethod | RW | Header canonicalization — simple (1) or relaxed (2) |
DKIMBodyCanonicalizationMethod |
eDKIMCanonicalizationMethod | RW | Body canonicalization — simple (1) or relaxed (2) |
DistributionLists |
DistributionLists | RO | The domain’s distribution lists |
DomainAliases |
DomainAliases | RO | Alternative names for the domain |
ID |
long | RO | Database ID |
MaxAccountSize |
long | RW | Largest quota that may be set on an account in this domain, in MB |
MaxMessageSize |
long | RW | Largest message sent from this domain, in KB |
MaxNumberOfAccounts |
long | RW | Account limit for the domain |
MaxNumberOfAccountsEnabled |
bool | RW | Enforce the account limit |
MaxNumberOfAliases |
long | RW | Alias limit for the domain |
MaxNumberOfAliasesEnabled |
bool | RW | Enforce the alias limit |
MaxNumberOfDistributionLists |
long | RW | Distribution-list limit for the domain |
MaxNumberOfDistributionListsEnabled |
bool | RW | Enforce the list limit |
MaxSize |
long | RW | Size cap for the whole domain, in MB (0 = unlimited) |
Name |
string | RW | The domain name. Since 6.3.4 a single-label name that is one of the directories the server keeps its own
files in beside the domains’ mail (quarantine, files, lists, blobs and the
like) is refused for a new domain or a rename |
PlusAddressingCharacter |
string | RW | The separator for plus addressing (usually +) |
PlusAddressingEnabled |
bool | RW | alice+tag@ delivers to alice@ |
Postmaster |
string | RW | Catch-all — mail to a nonexistent address in the domain is redirected here |
SignatureEnabled |
bool | RW | Domain signature on or off |
SignatureHTML |
string | RW | HTML version of the signature |
SignatureMethod |
eDomainSignatureMethod | RW | How the domain signature combines with account signatures |
SignaturePlainText |
string | RW | Plain-text version of the signature |
Size |
long | RO | Current size of the domain, in MB |
| Method | Does |
|---|---|
Save() |
Writes the domain to the database |
Delete() |
Deletes the domain — and everything in it. Since 6.3.4 it is refused, with an error, while the domain or any account in it is under an eDiscovery legal hold |
SynchronizeDirectory() |
Synchronises the domain against Active Directory |
The domain’s limits — the
MaxNumberOf…counts,MaxAccountSizeandMaxSize— are enforced by the server itself when an account, alias or distribution list is created or edited: a save that would break one is refused, with the limit’s own message. They are not checked during mail flow. Each count applies only while its…Enabledflag is on; clearing the flag keeps the number but stops enforcing it.
Creating a domain with an alternative name:
$domain = $app.Domains.Add()
$domain.Name = 'example.com'
$domain.Active = $true
$domain.Postmaster = '[email protected]'
$domain.Save()
$da = $domain.DomainAliases.Add()
$da.AliasName = 'example.net'
$da.Save()
Accounts and Account
The Accounts collection
All accounts in one domain, reached as Domain.Accounts.
| Property | Type | Access | Meaning |
|---|---|---|---|
Count |
long | RO | Number of accounts |
Item(index) |
Account | RO | Account by position |
ItemByAddress(address) |
Account | RO | Account by email address |
ItemByDBID(dbid) |
Account | RO | Account by database ID |
| Method | Does |
|---|---|
Add() |
Returns a new, unsaved Account |
Delete(index) |
Deletes the account at that position |
DeleteByDBID(dbid) |
Deletes the account with that database ID |
Refresh() |
Re-reads the collection from the database |
The Account object
One mailbox: its address, password, quota, forwarding, auto-reply, signature and Active Directory link, plus gateways to its folders, messages, rules and fetch accounts.
| Property | Type | Access | Meaning |
|---|---|---|---|
Active |
bool | RW | Whether the account may log on and receive mail |
Address |
string | RW | The email address |
AdminLevel |
eAdminLevel | RW | Administration rights — see Constants below |
ADDomain |
string | RW | Active Directory domain name |
ADUsername |
string | RW | Active Directory user name |
IsAD |
bool | RW | The account authenticates against Active Directory |
DomainID |
long | RW | Database ID of the owning domain |
FetchAccounts |
FetchAccounts | RO | External POP3 and IMAP accounts downloaded into this mailbox |
ForwardEnabled |
bool | RW | Forwarding on or off |
ForwardAddress |
string | RW | Where to forward |
ForwardKeepOriginal |
bool | RW | Keep a copy after forwarding |
ForwardAbortSpamFlagged |
bool | RW | Do not forward messages flagged as spam |
ID |
long | RO | Database ID |
IMAPFolders |
IMAPFolders | RO | The account’s folder tree |
LastLogonTime |
date | RO | When the account was last used |
MaxSize |
long | RW | Mailbox quota in MB (0 = unlimited) |
Messages |
Messages | RO | The messages in the mailbox |
Password |
string | RW | Set to change the password — the server stores a hash, so the plaintext cannot be read back. A new password must pass the password policy and, since 6.3.4 and only when it is switched on (it is off by default), the breach check, which refuses one with a COM error carrying the reason |
PersonFirstName |
string | RW | First name of the holder |
PersonLastName |
string | RW | Last name of the holder |
QuotaUsed |
long | RO | Percentage of quota used, 0–100 |
Rules |
Rules | RO | The account’s own rules |
SieveScript |
string | RW | New in 6.x. The account’s active Sieve (RFC 5228) filter script |
SignatureEnabled |
bool | RW | Signature on or off |
SignatureHTML |
string | RW | HTML signature |
SignaturePlainText |
string | RW | Plain-text signature |
Size |
float | RO | Current mailbox size, in MB |
VacationMessageIsOn |
bool | RW | Auto-reply on or off |
VacationSubject |
string | RW | Auto-reply subject |
VacationMessage |
string | RW | Auto-reply body |
VacationMessageAbortSpamFlagged |
bool | RW | Do not auto-reply to messages flagged as spam |
VacationMessageExpires |
bool | RW | Auto-reply switches itself off on a date |
VacationMessageExpiresDate |
string | RW | That date |
| Method | Does |
|---|---|
Save() |
Writes the account to the database |
Delete() |
Deletes the account and its mail |
DeleteMessages() |
Deletes every message in the mailbox |
UnlockMailbox() |
Releases a stuck POP3 mailbox lock |
ValidatePassword(password) |
Returns true if the password is correct — the supported way to check a credential |
Delete()andDeleteMessages()are immediate and unrecoverable short of a backup restore. There is no confirmation dialog in a script. Since 6.3.4 both are refused, with an error that says why, while an eDiscovery legal hold covers the account or its domain.
Members the tables leave out. AppPasswords (the account’s app passwords), TOTPEnabled, EnrolTOTP and DisableTOTP (its second factor), SpamMarkThreshold and SpamDeleteThreshold (its own spam thresholds), AntiSpamEnabled, MessageRetentionDays (0 = the domain’s policy, -1 = keep forever), VacationMessageBeginDate and ExportMessages. 6.3.4 appends more, after every earlier member, so an early-bound client built against 6.3.3 keeps working (recompile it to reach them): mailbox move (MoveTo, InUseReason, LastMove); the account’s administrator role (RoleName, RoleDomains, RolePermissions); recovery (RecoveryAddress, which answers the address masked, RecoveryAddressVerified, RecoveryCodesUnused, ClearRecoveryAddress, ClearRecoveryCodes); the out-of-office policy (VacationMessageDays, VacationInternalSubject, VacationInternalMessage, VacationMessageInternalOnly); devices (GetDevices, RevokeDevice); Web Push (PushSubscriptionCount, RemovePushSubscriptions); the directory entry (DirectoryTitle, DirectoryDepartment, DirectoryPhone, DirectoryMobile, DirectoryOffice, DirectoryOptOut, DirectoryNote, DirectoryPhoto, DirectoryPhotoType); OpenPGPPublish and OpenPGPKeys; ExportArchive; ZeroAccess; RestrictedDelivery; setting levels (SettingOverride, EffectiveSetting, EffectiveSettingSource); ExternalId, the identity provider’s id for an account SCIM made; and Kind — 0 a person (the default), 1 a meeting room, 2 a piece of equipment, saved by Save().
Creating a mailbox with a quota and an auto-reply:
$account = $app.Domains.ItemByName('example.com').Accounts.Add()
$account.Address = '[email protected]'
$account.Password = 'a-strong-password'
$account.Active = $true
$account.MaxSize = 2000 # MB
$account.VacationSubject = 'Out of office'
$account.VacationMessage = 'Back on Monday.'
$account.VacationMessageIsOn = $true
$account.Save()
A housekeeping sweep — disable anything unused for a year:
$cutoff = (Get-Date).AddYears(-1)
foreach ($d in $app.Domains) {
foreach ($a in $d.Accounts) {
if ($a.LastLogonTime -and [datetime]$a.LastLogonTime -lt $cutoff) {
$a.Active = $false
$a.Save()
"{0} disabled (last logon {1})" -f $a.Address, $a.LastLogonTime
}
}
}
Aliases and Alias
An alias (chapter 13) is a second address delivering to an existing one. The
Aliases collection hangs off a domain; each Alias maps a Name (the alias
address) to a Value (where it delivers).
The Aliases collection
| Property | Type | Access | Meaning |
|---|---|---|---|
Count |
long | RO | Number of aliases |
Item(index) |
Alias | RO | Alias by position |
ItemByDBID(dbid) |
Alias | RO | Alias by database ID |
ItemByName(name) |
Alias | RO | Alias by its address |
| Method | Does |
|---|---|
Add() |
Returns a new, unsaved Alias |
Delete(index) |
Deletes the alias at that position |
DeleteByDBID(dbid) |
Deletes by database ID |
Refresh() |
Re-reads from the database |
The Alias object
| Property | Type | Access | Meaning |
|---|---|---|---|
Active |
bool | RW | Whether the alias resolves |
DomainID |
long | RW | Database ID of the owning domain |
ID |
long | RO | Database ID |
Name |
string | RW | The alias address, e.g. [email protected] |
Value |
string | RW | Where it delivers, e.g. [email protected] |
| Method | Does |
|---|---|
Save() |
Writes the alias to the database |
Delete() |
Deletes the alias |
$alias = $app.Domains.ItemByName('example.com').Aliases.Add()
$alias.Name = '[email protected]'
$alias.Value = '[email protected]'
$alias.Active = $true
$alias.Save()
DomainAliases and DomainAlias
A domain alias is an alternative name for a whole domain — mail to
[email protected] delivered to the accounts of example.com. Do not confuse it with
an ordinary alias, which maps a single address.
The DomainAliases collection
| Property | Type | Access | Meaning |
|---|---|---|---|
Count |
long | RO | Number of domain aliases |
Item(index) |
DomainAlias | RO | Domain alias by position |
ItemByDBID(dbid) |
DomainAlias | RO | Domain alias by database ID |
| Method | Does |
|---|---|
Add() |
Returns a new, unsaved DomainAlias |
Delete(index) |
Deletes by position |
DeleteByDBID(dbid) |
Deletes by database ID |
Refresh() |
Re-reads from the database |
The DomainAlias object
| Property | Type | Access | Meaning |
|---|---|---|---|
AliasName |
string | RW | The alternative domain name |
DomainID |
long | RW | Database ID of the real domain |
ID |
long | RO | Database ID |
| Method | Does |
|---|---|
Save() |
Writes to the database |
Delete() |
Deletes the domain alias |
Distribution lists
A distribution list (chapter 13) delivers one message to many recipients. Three
objects are involved: the domain’s DistributionLists collection, each
DistributionList, and its Recipients collection of DistributionListRecipient
objects.
The DistributionLists collection
| Property | Type | Access | Meaning |
|---|---|---|---|
Count |
long | RO | Number of lists |
Item(index) |
DistributionList | RO | List by position |
ItemByAddress(address) |
DistributionList | RO | List by its address |
ItemByDBID(dbid) |
DistributionList | RO | List by database ID |
| Method | Does |
|---|---|
Add() |
Returns a new, unsaved DistributionList |
DeleteByDBID(dbid) |
Deletes by database ID |
Refresh() |
Re-reads from the database |
The DistributionList object
| Property | Type | Access | Meaning |
|---|---|---|---|
Active |
bool | RW | Whether the list accepts and delivers mail |
Address |
string | RW | The list address, e.g. [email protected] |
BounceAddress |
string | RW | The envelope sender of every copy the list sends, so bounces reach it and not the poster; empty = the poster (6.2.24). A list with Managed on uses the list manager’s own bounce handling instead |
ID |
long | RO | Database ID |
Mode |
eDistributionListMode | RW | Who may post: 0 anyone, 1 list members only, 2 the one announcement address, 3 anyone in the list’s domain (a claimed address, so require SMTP authentication with it); 4 is refused |
ModeratorAddress |
string | RW | Where a posting the mode refuses is sent instead; the moderator approves it by sending it again from an authenticated session. Empty = no moderation (6.2.24) |
Recipients |
DistributionListRecipients | RO | The membership |
RequireSenderAddress |
string | RW | The one address allowed to post, in announcement mode |
RequireSMTPAuth |
bool | RW | Posting requires an authenticated SMTP session |
| Method | Does |
|---|---|
Save() |
Writes the list to the database |
Delete() |
Deletes the list and its membership |
6.3.4 appends Membership, a membership query answered against the server’s accounts when a message is sent — empty for a list of its recipients alone, or domain, domain:name, pattern:wildcard or everyone, always in union with the recipients and judged when the list is saved — and the list manager’s Managed, SubscribePolicy, Archive, ArchiveDays, Digest, DigestHour, ReplyToList, SubjectPrefix, Footer, Verp and Owner, with the method SendDigest; the list manager’s fields do nothing until Managed is on.
Set the posting mode deliberately. A list anyone may post to is a spam relay in waiting — the same warning as in chapter 13 applies doubly to lists created by script, where no dialog prompts you to think about it.
DistributionListRecipients and DistributionListRecipient
The membership of one list.
| Property | Type | Access | Meaning |
|---|---|---|---|
Count |
long | RO | Number of recipients |
Item(index) |
DistributionListRecipient | RO | Recipient by position |
ItemByDBID(dbid) |
DistributionListRecipient | RO | Recipient by database ID |
| Method | Does |
|---|---|
Add() |
Returns a new, unsaved recipient |
DeleteByDBID(dbid) |
Removes a recipient by database ID |
Each recipient is small, and its long-standing members are below. 6.3.4 adds the list manager’s state for a member — Delivery (every message, the daily digest, or nothing), Confirmed, HardBounces and SoftBounces — and the method ResetBounces.
| Property | Type | Access | Meaning |
|---|---|---|---|
ID |
long | RO | Database ID |
RecipientAddress |
string | RW | The member’s email address — local or external |
| Method | Does |
|---|---|
Save() |
Writes the recipient to the database |
Delete() |
Removes the recipient from the list |
$list = $app.Domains.ItemByName('example.com').DistributionLists.Add()
$list.Address = '[email protected]'
$list.Active = $true
$list.RequireSMTPAuth = $true
$list.Save()
foreach ($member in '[email protected]', '[email protected]') {
$r = $list.Recipients.Add()
$r.RecipientAddress = $member
$r.Save()
}
Groups and group members
Groups collect accounts so that public-folder permissions (chapter 13) can be
granted to many people at once. They are server-wide, not per-domain — the collection
hangs off Settings.Groups, and appears in the Control Panel under
Accounts & domains → Groups. Groups are one of the collections the running server reads while it works, so since 6.3.4 a property set on a Group is held on your object’s own copy until Save(): one set and never saved changes nothing, and an object read before the save keeps what it read, so read the group again from Settings.Groups to see the saved values (chapter 23).
The Groups collection
| Property | Type | Access | Meaning |
|---|---|---|---|
Count |
long | RO | Number of groups |
Item(index) |
Group | RO | Group by position |
ItemByDBID(dbid) |
Group | RO | Group by database ID |
ItemByName(name) |
Group | RO | Group by name |
| Method | Does |
|---|---|
Add() |
Returns a new, unsaved Group |
DeleteByDBID(dbid) |
Deletes by database ID |
Refresh() |
Re-reads from the database |
The Group object
| Property | Type | Access | Meaning |
|---|---|---|---|
ID |
long | RO | Database ID |
Members |
GroupMembers | RO | The group’s membership |
Name |
string | RW | The group name |
| Method | Does |
|---|---|
Save() |
Writes the group to the database |
Delete() |
Deletes the group |
GroupMembers and GroupMember
The membership collection carries the standard members — Count, Item(index),
ItemByDBID(dbid), Add(), DeleteByDBID(dbid), Refresh(). Each member links a
group to an account by database ID:
| Property | Type | Access | Meaning |
|---|---|---|---|
Account |
Account | RO | The member account itself |
AccountID |
long | RW | Database ID of the member account |
GroupID |
long | RW | Database ID of the group |
ID |
long | RO | Database ID of the membership row |
| Method | Does |
|---|---|
Save() |
Writes the membership to the database |
Delete() |
Removes the member from the group |
$group = $app.Settings.Groups.Add()
$group.Name = 'Support'
$group.Save()
$alice = $app.Domains.ItemByName('example.com').Accounts.ItemByAddress('[email protected]')
$member = $group.Members.Add()
$member.AccountID = $alice.ID
$member.Save()
Fetch accounts
A fetch account downloads mail from an external POP3 or IMAP mailbox into a local
account — the External tab of the Control Panel’s account dialog. The
FetchAccounts collection hangs off an Account.
ServerType 0 is POP3 and 1 is IMAP (new in 6.2.25): the remote INBOX is collected
once by UID, over LOGIN or XOAUTH2, with STARTTLS or implicit TLS. In that mode it is
the INBOX only — other folders are not mirrored, and a message that leaves the remote
INBOX by some other route is forgotten rather than deleted locally.
MirrorFolders (new in 6.2.27, schema 6031 — Mirror every folder in the Control
Panel) changes that. Every remote folder is collected into the local folder of the same
name: the message bytes as they are, the \Seen \Flagged \Answered \Draft \Deleted
flags, the internal date, the remote hierarchy delimiter mapped to the local one, and a
per-folder record so a second poll takes only what is new. Nothing is delivered — no
header, rule, anti-spam or anti-virus pass touches a mirrored copy — and setting days to
keep messages to -1 turns the mirror into a move (to 0 up to 6.3.5, when the IMAP fetcher read the two the other
way round; see DaysToKeepMessages below).
Since 6.3.4, FetchAccount.Save refuses a user name or password that contains a CR, LF or NUL, which the fetchers would otherwise have sent to the external server as a command of its own. Only a server administrator may add or change a fetch account for a host on FetchOAuth2Hosts, whose accounts can log in with the server’s own OAuth2 token, and a bearer token is sent over TLS only. And Save, Delete, DownloadNow and AccountID are refused to a credential that does not reach the local account and take in its authority, so a domain administrator can neither point a fetch account at another domain’s account nor add one to the server administrator’s own mailbox.
The FetchAccounts collection
Standard members: Count, Item(index), ItemByDBID(dbid), Add(),
Delete(index), DeleteByDBID(dbid), Refresh().
The FetchAccount object
| Property | Type | Access | Meaning |
|---|---|---|---|
AccountID |
long | RW | The local account the mail is delivered into |
ConnectionSecurity |
eConnectionSecurity | RW | Encryption for the connection to the external server |
DaysToKeepMessages |
long | RW | Retention on the external server — see below |
EnableRouteRecipients |
bool | RW | Also deliver to recipients matched by routes |
Enabled |
bool | RW | Whether the fetch runs |
ID |
long | RO | Database ID |
IsLocked |
bool | RO | Whether a fetch is currently running against this account |
MIMERecipientHeaders |
string | RW | The MIME headers recipients are read from, when ProcessMIMERecipients is on |
MinutesBetweenFetch |
long | RW | Polling interval |
MirrorFolders |
bool | RW | IMAP only: collect every remote folder into the local folder of the same name, filed and not delivered (6.2.27, described above) |
Name |
string | RW | A display name for the fetch account |
NextDownloadTime |
string | RO | When the next attempt is due |
Password |
string | RW | Password for the external server |
Port |
long | RW | External server port: 110 for POP3, or 995 with implicit TLS; 143 for IMAP, or 993 with implicit TLS |
ProcessMIMEDate |
bool | RW | Take the receive date from the Received header |
ProcessMIMERecipients |
bool | RW | Resolve recipients from the To/CC headers |
ServerAddress |
string | RW | The external POP3 or IMAP server |
ServerType |
long | RW | 0 POP3, 1 IMAP (6.2.25) |
UseAntiSpam |
bool | RW | Run downloaded mail through anti-spam |
UseAntiVirus |
bool | RW | Run downloaded mail through anti-virus |
Username |
string | RW | User name for the external server |
UseSSL |
bool | RW | Legacy toggle — superseded by ConnectionSecurity |
DaysToKeepMessages says what happens on the external server to a message once it has been collected:
| Value | Behaviour on the external server |
|---|---|
-1 |
Delete it from the external server as soon as it has been collected here |
0 |
Never delete: every message is left on the external server — the default for a new external account over COM, in the Control Panel and over the REST API |
| n > 0 | Keep messages n days, then delete |
That is what the setting has always meant to the POP3 fetcher, to COM and to the OnExternalAccountDownload
event. From 6.2.25 to 6.3.5 the IMAP fetcher read 0 as “delete” and -1 as “keep”, and the Control Panel,
the Deck, the webmail and the REST API described that reversed meaning for both kinds of account; from 6.3.6 both
fetchers read the table above, and schema step 6106 translates what is stored so that every account goes on doing what
it did or stops deleting, and none starts (§18.5). A program or script that wrote the value to the IMAP fetcher’s
earlier meaning is changed before it next writes it.
| Method | Does |
|---|---|
Save() |
Writes the fetch account to the database |
Delete() |
Deletes the fetch account |
DownloadNow() |
Runs a download immediately instead of waiting for the interval |
$fetch = $account.FetchAccounts.Add()
$fetch.Name = 'Old provider'
$fetch.ServerAddress = 'pop.oldprovider.example'
$fetch.Port = 995
$fetch.ConnectionSecurity = 1 # eCSTLS — implicit TLS
$fetch.Username = '[email protected]'
$fetch.Password = 'the-old-password'
$fetch.MinutesBetweenFetch = 15
$fetch.DaysToKeepMessages = -1 # delete there once collected here
$fetch.Enabled = $true
$fetch.Save()
$fetch.DownloadNow()
GlobalObjects
A small container, reached as Application.GlobalObjects, for three server-wide objects
that fit nowhere else. It has no methods.
| Property | Type | Access | Meaning |
|---|---|---|---|
DeliveryQueue |
DeliveryQueue | RO | The outbound queue — inspect, retry and remove waiting messages (documented in a later reference chapter) |
Languages |
Languages | RO | The catalogue of administrative-interface languages |
MessageTrace |
MessageTrace | RO | The message trace: what happened to a message, searched by sender, recipient or queue id. It records only while MessageTraceEnabled is on, which it is not by default |
Links
Application.Links is a shortcut: when you already hold a database ID — stored from an
earlier run, or read straight from the database — it fetches the object directly,
without walking the domain and account collections.
| Property | Type | Access | Meaning |
|---|---|---|---|
Account(dbid) |
Account | RO | Account by database ID |
Alias(dbid) |
Alias | RO | Alias by database ID |
DistributionList(dbid) |
DistributionList | RO | Distribution list by database ID |
Domain(dbid) |
Domain | RO | Domain by database ID |
$account = $app.Links.Account(4711)
An unknown ID throws, like any missing collection item.
Constants
The API’s enumerated types are plain numbers on the wire. PowerShell and VBScript do
not load them automatically — use the literal values below, or declare Const lines in
VBScript, or read them from the interop assembly (shown after the tables).
eAdminLevel — the rights carried by Account.AdminLevel, and by extension what an
account’s credentials may do when used with Authenticate. Three things changed in 6.3.4. An account given an administrator role — serveradmin, domainadmin, helpdesk, auditor or a custom role, in named domains, with Application.AssignRole — reaches what the role’s permissions reach, while an account with no role holds exactly what its level gives; Account.RoleName, RoleDomains and RolePermissions read the assignment back. An app password is no longer a COM logon: Authenticate answers one as a wrong password. And a write to an account or to what is in it (Account.Save and Delete, Accounts.Delete and DeleteByDBID, FetchAccount.Save among them) is refused to a credential that does not reach that account and take in its authority, so a domain administrator is refused them on the server administrator’s own mailbox in its domain. The levels:
| Constant | Value | Grants |
|---|---|---|
hAdminLevelNormal |
0 | The account’s own settings only |
hAdminLevelDomainAdmin |
1 | Administration of the account’s domain |
hAdminLevelServerAdmin |
2 | Full server administration |
eConnectionSecurity — used by Settings.SMTPConnectionSecurity,
Settings.SMTPRelayerConnectionSecurity and elsewhere:
| Constant | Value | Meaning |
|---|---|---|
eCSNone |
0 | No encryption |
eCSTLS |
1 | Implicit TLS from the first byte |
eCSSTARTTLSOptional |
2 | STARTTLS when the remote side offers it |
eCSSTARTTLSRequired |
3 | STARTTLS, or the connection fails |
The values are not in order of strength — implicit TLS is
1, between “none” and the STARTTLS modes. Scripts that compare these numerically get it wrong. One naming note: the 5.x documentation and old scripts call constant1eCSSSL; this fork’s type library names iteCSTLS. The value is the same either way.
eACLPermission — public-folder rights. These are bit flags: add (or OR) them together to grant several at once.
| Constant | Value | Grants |
|---|---|---|
ePermissionLookup |
1 | See that the folder exists |
ePermissionRead |
2 | Read messages |
ePermissionWriteSeen |
4 | Change the seen flag |
ePermissionWriteOthers |
8 | Change other flags |
ePermissionInsert |
16 | Copy messages in |
ePermissionPost |
32 | Post to the folder |
ePermissionCreate |
64 | Create sub-folders |
ePermissionDeleteMailbox |
128 | Delete the folder |
ePermissionWriteDeleted |
256 | Set the deleted flag |
ePermissionExpunge |
512 | Expunge deleted messages |
ePermissionAdminister |
1024 | Manage the folder’s permissions |
eACLPermissionType — who a public-folder permission applies to:
| Constant | Value | Meaning |
|---|---|---|
ePermissionTypeUser |
0 | One account |
ePermissionTypeGroup |
1 | A group |
ePermissionTypeAnyone |
2 | Everyone |
Further enumerations — eServerState, eDistributionListMode,
eDomainSignatureMethod, eSessionType, eMessageFlag, eRuleActionType,
eDKIMAlgorithm, eDKIMCanonicalizationMethod and others —
are used by individual properties and events. 6.3.4 adds hStateDraining (5) to eServerState, new rule criteria and actions (chapter 30), and two enumerations for remote domain policies: the outbound TLS level (eRTNone 0, eRTEncrypted 1, eRTVerified 2, eRTDane 3) and gateway encryption (eREEncryptNone 0, eREEncryptOpenPgp 1, eREEncryptSmime 2). All of them, names and values, can be read
straight out of the interop assembly:
Add-Type -Path 'C:\Program Files\hMailServer\Bin\Interop.hMailServer.dll'
[Enum]::GetValues([hMailServer.eConnectionSecurity]) |
ForEach-Object { '{0,-24} {1}' -f $_, [int]$_ }
In VBScript, declare what you use:
Const hAdminLevelServerAdmin = 2
Const eCSSTARTTLSRequired = 3
COM API changelog highlights
The API has been stable for a long time, which is why twenty-year-old scripts still run. The changes that matter when reading old scripts and forum posts:
| Version | Compatibility | What changed |
|---|---|---|
| 4.3 | Breaking | Authenticate() became mandatory before any other call (only the Message object is exempt). New objects for anti-spam, blocked attachments, SURBL, server messages and TCP/IP ports; new enumerations including eSessionType and eDistributionListMode. |
| 4.4 | Additive | White-list objects; Account.LastLogonTime; auto-reply expiry; per-domain account/alias/list limits; FetchAccount.DownloadNow(); rule reordering with MoveUp()/MoveDown(). |
| 5.0 | Breaking | IPHome/IPHomes replaced by the TCPIPPort objects. Port numbers, tarpitting, SPF, MX-check and DNSBL settings moved off Settings — to TCPIPPorts and the new AntiSpam object. Domains.ItemByName changed from a method to a property. Delete() added across most objects; Database gained transactions and setup methods. |
| 5.x → 6.2 (this fork) | Mostly additive | Scripts written against 5.x run unchanged unless they touch what was removed. Added: the Diagnostics/DiagnosticResults and MessageIndexing objects, Account.SieveScript, Settings.TlsVersion13Enabled, and Sieve syntax-check/evaluate helpers on Utilities. Removed: Settings.SslVersion30Enabled and Settings.SendStatistics. Renamed: the constant eCSSSL is now eCSTLS (same value, 1). Most other fork functionality was configured through hMailServer.INI (chapter 21) and the REST API (chapter 24) rather than new COM objects; since 6.3.4 its [Settings] keys are held in the database and a script changes one with Settings.SetIniSetting, not by editing the file (an edited line is reported as HM5804 and, where the service may write the file, put back). |
| 6.2.10 | Behavioural fix | Fifteen methods that reported S_OK on refused calls now return errors, and five getters no longer hand back uninitialised memory to unauthorised callers. No signatures changed — but scripts that never checked return values may now discover calls that were failing silently all along. |
| 6.3.4 | Additive, with behavioural changes | Six new interfaces (100 coclasses, up from 94) and new members on Application, Settings, Domain, Account, Utilities and others, all appended, so an early-bound client built against 6.3.3 keeps working; administrator roles arrive as Application.Roles, AssignRole, CurrentRole and CurrentPermissions. What an older script may notice: a property set on a route or a route’s address, an incoming relay, a DNS black list, a SURBL server, a remote domain policy, a server message, a blocked attachment, an SSL certificate or a group takes effect only at that object’s Save() — one set and never saved changes nothing, and an object read before the save keeps the values it read; Authenticate and AuthenticateWithCode no longer accept an app password; a one-time code is accepted once, and wrong codes count against a budget of five in fifteen minutes; 72 classes that answered a refusal with a bare HRESULT now say why; Utilities.MD5 and SHA256 hash text outside ASCII as its UTF-8; and Settings.SetIniSetting writes to the database, with IniSettingApplies and PendingIniSettings saying when a saved key takes effect. |
The full details of the 6.2.10 fix are in the version history (chapter 25), which also has an entry for 6.3.4; §18.5 lists what the upgrade to 6.3.4 changes without a switch, the Save() rule for shared configuration objects among them.
30. COM API reference II: messages, folders and rules
Chapter 23 introduced the COM API and the pattern every script follows: create
hMailServer.Application, call Authenticate, then walk down to the object you want.
The companion reference chapter covers the configuration side — Application, domains
and accounts. This chapter covers the other half: the mail itself. Messages and
their headers, attachments and recipients; the IMAP folder tree and its permissions;
and rules, the criteria-plus-actions system from chapter 12, driven entirely from code.
Two conventions hold throughout:
- Collections all look the same. Every collection has a read-only
Countand a zero-basedItem(index). Most addItemByDBID(id), many addItemByName(name), and writable ones addAdd()andDeleteByDBID(id). Once you have iterated one collection, you have iterated them all. - Objects are database rows. Anything with an
IDproperty lives in the database.Save()writes your changes;Delete()removes the row. Until you callSave(), you are editing a copy in memory.
Check your return values. The 6.2.10 authorization fix (chapter 29) applies to every object in this chapter: a refused call fails properly instead of pretending to succeed.
Authenticatefirst, and test what comes back. Since 6.3.4 a refusal also says why, in the server’s own sentence rather than a bare0x800403E9. And a write through aRule(with its actions and criteria), anIMAPFolder, anIMAPFolderPermissionor aMessageasks whether your credential reaches the account it belongs to (as the server administrator, an administrator of that account’s domain, or the account itself) and takes in that account’s authority, and is refused otherwise: a domain administrator can no longer change what is in the server administrator’s mailbox, or save a rule or an action under an account it does not reach. The objects an event script is handed are not affected.
None of these objects is created directly with New-Object — you reach them from an
authenticated Application object. The single exception is hMailServer.Message,
which can be created standalone to send mail (see the worked examples at the end). From 6.3.6, with an administrator
password set, such a message carries the credential of an Application the same program holds and has
signed in on, and Message.Save and Copy refuse one that carries none: sign in first and keep
the Application until the message is saved (in .NET, GC.KeepAlive), or take the message from
the new Application.CreateMessage(), which a .NET program reaches once rebuilt against 6.3.6’s type
library, or late-bound. The same holds for a hMailServer.FetchAccount created on its own. Mail that a
program signed in as an account queues must be from an address that account may send as; to send as anybody, sign in
as the server administrator. The server’s event scripts, which create these objects without signing in, work as
before, and a server with no administrator password and no second factor answers every caller as it did.
30.1 The object map
Where everything hangs:
Application
├─ Domains ──▶ Domain
│ ├─ Rules (the domain's own, 6.3.4) ──▶ Rule …
│ └─ Accounts ──▶ Account
│ ├─ IMAPFolders ──▶ IMAPFolder
│ │ ├─ SubFolders ──▶ IMAPFolder …
│ │ ├─ Messages ──▶ Message
│ │ │ ├─ Headers ─────▶ MessageHeader
│ │ │ ├─ Attachments ─▶ Attachment
│ │ │ └─ Recipients ──▶ Recipient
│ │ └─ Permissions ──▶ IMAPFolderPermission
│ └─ Rules ──▶ Rule
│ ├─ Criterias ──▶ RuleCriteria
│ └─ Actions ────▶ RuleAction
├─ Rules (server-wide) ──▶ Rule …
└─ Settings.PublicFolders ──▶ IMAPFolder …
Account rules live on Account.Rules; the server-wide rules from the Control Panel’s
Mail flow & delivery → Rules page (on screen, Global rules) live on
Application.Rules; and since 6.3.4 a domain’s own rules, edited on the same page with the
domain chosen at the top, live on Domain.Rules. The server’s rules run first, then the
domain’s, then, at local delivery, the account’s. The public folder tree from
chapter 13 is an ordinary IMAPFolders collection reached through
Settings.PublicFolders — everything below applies to it equally.
30.2 Messages and their parts
Message
One email message. You meet it in three places: in a folder’s Messages collection,
as the oMessage parameter of an event script (chapter 23), and freshly created via
New-Object -ComObject 'hMailServer.Message' when sending mail from a script. In a
JavaScript event script on Linux (since 6.3.4) the object has no Attachments or
Headers, and its two parameterised properties are written with methods,
SetHeaderValue(name, value) and SetFlag(flag, on); reading them is unchanged.
| Property | Type | Access | What it holds |
|---|---|---|---|
Attachments |
Attachments |
read-only | The message’s attachments |
Body |
string | read/write | The plain-text body |
CC |
string | read-only | The CC header |
Charset |
string | read/write | The message’s main character set |
Date |
string | read/write | The Date header |
DeliveryAttempt |
long | read-only | Which delivery attempt is in progress |
EncodeFields |
bool | read/write | Whether header text you set is MIME-encoded (matters for non-ASCII) |
Filename |
string | read-only | Path of the file holding the message in the data directory. Since 6.3.4 that file is not always the message
as it arrived: with MessageStoreCompression on it is the store’s compressed container, in a mailbox
encrypted at rest it is encrypted, and with an object store there is no file at that path once the message is
committed. Read the message through the API instead |
Flag(type) |
bool | read/write | One IMAP-style flag — pass a value from the flag table below |
From |
string | read/write | The From header, display name included |
FromAddress |
string | read/write | The envelope sender — the SMTP MAIL FROM, written as the first Return-Path |
Headers |
MessageHeaders |
read-only | Every MIME header, as a collection |
HeaderValue(name) |
string | read/write | The first header with that name; the name is case-insensitive |
HTMLBody |
string | read/write | The HTML body |
ID |
hyper (64-bit) | read-only | Database identifier |
InternalDate |
variant (date) | read-only | The message’s internal (IMAP) date |
Recipients |
Recipients |
read-only | The envelope recipients |
Size |
long | read-only | Message size, in kilobytes |
State |
long | read-only | Internal delivery state |
Subject |
string | read/write | The Subject header |
To |
string | read-only | The To header |
UID |
long | read-only | The message’s IMAP UID — unique within its mailbox |
| Method | What it does |
|---|---|
AddRecipient(name, address) |
Adds one recipient, given a display name and an address |
ClearRecipients() |
Removes every recipient, MIME headers included |
Copy(folderID) |
Copies the message into the IMAP folder with that database ID |
HasBodyType(type) |
Whether the message contains a body part of the given content type |
RefreshContent() |
Re-reads the message content |
Save() |
Writes changes — and hands a new message to the delivery queue |
Save()on a new message sends it. There is no separate “send” call and no draft state — the moment a newly created message is saved, it is queued for delivery. Set every property first, save once.
Flag() takes one of these values. The flags are stored together as a bit-mask (a
message that is Recent and virus-scanned stores 32 + 64 = 96), but through the API you
read and set them one at a time as booleans:
| Value | Flag |
|---|---|
| 1 | Seen (read) |
| 2 | Deleted |
| 4 | Flagged |
| 8 | Answered |
| 16 | Draft |
| 32 | Recent |
| 64 | Virus-scanned |
| 128 | Spam |
Messages
The messages inside one IMAP folder, reached through IMAPFolder.Messages.
| Property | Type | Access | What it holds |
|---|---|---|---|
Count |
long | read-only | Number of messages in the folder |
Item(index) |
Message |
read-only | The message at that position (zero-based) |
ItemByDBID(id) |
Message |
read-only | The message with that database ID |
| Method | What it does |
|---|---|
Add() |
Creates a new message in the collection |
Clear() |
Removes every message in the folder |
DeleteByDBID(id) |
Deletes one message by database ID |
Deleting shifts the indexes of everything after it. If you delete while iterating with
Item(i), walk the collection backwards — fromCount - 1down to0— or you will skip every second message. AndClear()empties a mailbox folder with no undo; be certain of which folder object you are holding. Recover deleted items (6.3.4) keeps what a person deletes and what a retention policy removes, not what a script deletes, unless the account or its domain is on an eDiscovery legal hold: a hold keeps a copy of an administrator’s delete among the account’s deleted items.
MessageHeader
A single MIME header — one Name: Value line.
| Property | Type | Access | What it holds |
|---|---|---|---|
Name |
string | read/write | The header’s name |
Value |
string | read/write | The header’s value |
| Method | What it does |
|---|---|
Delete() |
Removes this header from the message |
MessageHeaders
Every MIME header of one message, via Message.Headers. It has no methods of its own.
| Property | Type | Access | What it holds |
|---|---|---|---|
Count |
long | read-only | Number of headers |
Item(index) |
MessageHeader |
read-only | The header at that position |
ItemByName(name) |
MessageHeader |
read-only | The first header with that name |
For a quick read or write of a single header, Message.HeaderValue('X-Whatever') is
the shorter path. Use the collection when a header occurs more than once (Received
being the classic case) or when you need to delete one. Either way, header changes
take effect when you save the owning message.
Attachment
One attachment of one message.
| Property | Type | Access | What it holds |
|---|---|---|---|
Filename |
string | read-only | The attachment’s file name |
Size |
long | read-only | The attachment’s size |
| Method | What it does |
|---|---|
Delete() |
Strips this attachment out of the message |
SaveAs(path) |
Writes the attachment to disk at the given path |
Attachments
All attachments of one message, via Message.Attachments.
| Property | Type | Access | What it holds |
|---|---|---|---|
Count |
long | read-only | Number of attachments |
Item(index) |
Attachment |
read-only | The attachment at that position |
| Method | What it does |
|---|---|
Add(path) |
Attaches the file at the given path to the message |
Clear() |
Removes every attachment |
Give SaveAs and Add full paths, and save the message after stripping or adding
attachments — the change is not on disk until you do. From 6.3.6 both answer the server administrator alone, since
one reads a file from the server’s disk and the other writes one: a program signed in as a domain administrator or
as an account is refused them.
Recipient
One recipient, as recorded in the SMTP envelope — not the To header. Envelope
recipients include people the headers never mention, BCC recipients above all.
| Property | Type | Access | What it holds |
|---|---|---|---|
Address |
string | read-only | The recipient’s address |
IsLocalUser |
bool | read-only | Whether the address belongs to an account on this server |
OriginalAddress |
string | read-only | The address as first given, before any rewriting (alias resolution, for instance) |
Recipients
The envelope recipient list of one message, via Message.Recipients. It is read-only
and has no methods — to change the recipient list, use Message.AddRecipient and
Message.ClearRecipients.
| Property | Type | Access | What it holds |
|---|---|---|---|
Count |
long | read-only | Number of recipients |
Item(index) |
Recipient |
read-only | The recipient at that position |
30.3 IMAP folders and permissions
IMAPFolder
One IMAP folder — a user’s Inbox, a sub-folder three levels deep, or a public folder.
| Property | Type | Access | What it holds |
|---|---|---|---|
CreationTime |
string | read-only | When the folder was created |
CurrentUID |
long | read-only | The latest message UID issued in this folder |
ID |
long | read-only | Database identifier — what Message.Copy() wants |
Messages |
Messages |
read-only | The messages in this folder |
Name |
string | read/write | The folder’s name (one level, not a path) |
ParentID |
long | read-only | Database ID of the parent folder |
Permissions |
IMAPFolderPermissions |
read-only | The folder’s ACL: on a public folder, and on an account’s own folder that its owner shares with other
accounts (IMAP SETACL) |
SubFolders |
IMAPFolders |
read-only | The folders inside this one |
Subscribed |
bool | read/write | The folder’s IMAP subscription state |
| Method | What it does |
|---|---|
Delete() |
Deletes the folder from the database |
Save() |
Writes changes to the database |
Folder names are single components. To reach Projects/2026/Invoices, chain through
SubFolders:
$folder = $account.IMAPFolders.ItemByName('Projects').SubFolders.ItemByName('2026').SubFolders.ItemByName('Invoices')
IMAPFolders
The root-level folders of an account (Account.IMAPFolders) or the public folder
tree (Settings.PublicFolders). Sub-folder collections returned by
IMAPFolder.SubFolders behave identically.
| Property | Type | Access | What it holds |
|---|---|---|---|
Count |
long | read-only | Number of folders at this level |
Item(index) |
IMAPFolder |
read-only | The folder at that position |
ItemByDBID(id) |
IMAPFolder |
read-only | The folder with that database ID |
ItemByName(name) |
IMAPFolder |
read-only | The folder with that name |
| Method | What it does |
|---|---|
Add(name) |
Creates a folder with that name — saved immediately |
DeleteByDBID(id) |
Deletes the folder with that database ID |
Add(name)is the odd one out: mostAdd()calls give you an unsaved object that needsSave(), but a folder added here is written to the database at once.
IMAPFolderPermission
One entry in a folder’s ACL: this account (or group, or anyone) may do these things in
this folder. Chapter 13 covers what public folders are; this object is how you
script who sees them. An account’s own folders carry entries too when their owner shares them, which an
IMAP client does with SETACL: that is how a shared or delegated mailbox is made, and this
object reads and writes those entries as well.
| Property | Type | Access | What it holds |
|---|---|---|---|
Account |
Account |
read-only | The account this entry refers to |
Group |
Group |
read-only | The group this entry refers to |
ID |
long | read-only | Database identifier |
Permission(right) |
bool | read/write | One right — pass a value from the table below |
PermissionAccountID |
long | read/write | ID of the account being granted rights |
PermissionGroupID |
long | read/write | ID of the group being granted rights |
PermissionType |
eACLPermissionType |
read/write | What kind of grantee the entry targets |
ShareFolderID |
long | read-only | ID of the folder being shared |
Value |
long | read/write | The whole set of rights as one bit-mask |
| Method | What it does |
|---|---|
Delete() |
Removes the entry from the database |
Save() |
Writes changes to the database |
The rights, and their bit values:
| Value | Right | Allows |
|---|---|---|
| 1 | Lookup | Seeing that the folder exists |
| 2 | Read | Opening the folder and reading its messages |
| 4 | Keep seen flag | Persisting read/unread state |
| 8 | Keep other flags | Persisting flagged/answered/draft state |
| 16 | Insert | Adding messages to the folder |
| 32 | Post | Sending mail to the folder’s submission address (not enforced by IMAP itself). On another account’s
INBOX it also lets the grantee send as that account’s address: in the webmail, and over SMTP while
SmtpAuthenticatedSenderCheck is on |
| 64 | Create mailbox | Creating sub-folders |
| 128 | Delete mailbox | Deleting the folder |
| 256 | Keep deleted flag | Marking messages as deleted |
| 512 | Expunge | Permanently removing deleted messages |
| 1024 | Administer | Changing the folder’s permissions |
Set Value to the sum of the rights you want: insert plus the ability to mark
deletions is 16 + 256 = 272; everything except deleting the folder itself is
1 + 2 + 4 + 8 + 16 + 32 + 64 + 256 + 512 + 1024 = 1919. Permission(right) reads or
flips a single bit instead.
# Let alice see, read and add messages to a public folder
$folder = $app.Settings.PublicFolders.ItemByName('Noticeboard')
$perm = $folder.Permissions.Add()
$perm.PermissionAccountID = $app.Domains.ItemByName('example.com').Accounts.ItemByAddress('[email protected]').ID
$perm.Value = 1 + 2 + 16 # lookup + read + insert
$perm.Save()
Entries are inherited, and never merged. A folder with no entries of its own takes them from the nearest folder above it that has any, and a folder with entries is decided by those alone. So the first entry you add to a sub-folder replaces the whole inherited set for it, and everyone the parent’s entries let in loses the sub-folder until they have entries there too. Grant Lookup (1) with anything else: without it the folder does not exist for that grantee.
IMAPFolderPermissions
The full ACL of one folder, public or shared, via IMAPFolder.Permissions.
| Property | Type | Access | What it holds |
|---|---|---|---|
Count |
long | read-only | Number of entries |
Item(index) |
IMAPFolderPermission |
read-only | The entry at that position |
ItemByDBID(id) |
IMAPFolderPermission |
read-only | The entry with that database ID |
ItemByName(name) |
IMAPFolderPermission |
read-only | The entry with that name |
| Method | What it does |
|---|---|
Add() |
Adds a new, unsaved entry |
Delete(index) |
Deletes the entry at that position |
DeleteByDBID(id) |
Deletes the entry with that database ID |
Refresh() |
Reloads the collection from the database |
30.4 Rules
A rule, as chapter 12 puts it, is criteria plus actions. Through the API that is
three objects: the Rule itself, its RuleCriterias, and its RuleActions.
Enum constants are just numbers over COM. Samples in the original hMailServer documentation used names like
eRAForwardEmail, which work in VBA because VBA imports the type library. PowerShell and VBScript do not — use the numeric values from the tables below, and comment the name next to the number so the script stays readable.
Rule
One rule — server-wide (from Application.Rules), belonging to one domain (from
Domain.Rules, since 6.3.4) or belonging to one account (from
Account.Rules). Rules run in priority order, top first; MoveUp and MoveDown
reorder them. A matching rule does not stop the set: only a Stop rule processing action (type 6) does.
| Property | Type | Access | What it holds |
|---|---|---|---|
AccountID |
long | read/write | The owning account, for account-level rules |
Actions |
RuleActions |
read-only | What the rule does when it matches |
Active |
bool | read/write | Whether the rule runs |
Criterias |
RuleCriterias |
read-only | What the rule tests |
ID |
long | read-only | Database identifier |
Name |
string | read/write | The rule’s name |
UseAND |
bool | read/write | true: every criterion must match; false: any one is enough |
| Method | What it does |
|---|---|
Delete() |
Deletes the rule from the database |
MoveDown() |
Moves the rule down the priority order |
MoveUp() |
Moves the rule up the priority order |
Save() |
Writes changes to the database |
Rules
A rule collection — Application.Rules for the server-wide set, Domain.Rules
for one domain’s set (since 6.3.4), Account.Rules for one mailbox’s set.
| Property | Type | Access | What it holds |
|---|---|---|---|
Count |
long | read-only | Number of rules |
Item(index) |
Rule |
read-only | The rule at that position (priority order) |
ItemByDBID(id) |
Rule |
read-only | The rule with that database ID |
| Method | What it does |
|---|---|
Add() |
Adds a new, unsaved rule |
DeleteByDBID(id) |
Deletes the rule with that database ID |
Refresh() |
Reloads the collection from the database |
RuleAction
One thing a rule does when it matches. Type picks the action; one or more of the
other properties carry its payload.
| Property | Type | Access | What it holds |
|---|---|---|---|
AbortSpamFlagged |
bool | read/write | Skip this action when the message is spam-flagged — stops replies and forwards going out for spam |
Body |
string | read/write | Reply text, for reply actions; the disclaimer’s text, for disclaimer actions (6.3.4) |
Filename |
string | read/write | Unused |
FromAddress |
string | read/write | Reply sender address, for reply actions |
FromName |
string | read/write | Reply sender name, for reply actions |
HeaderName |
string | read/write | Header to set, for set-header actions |
ID |
long | read-only | Database identifier |
IMAPFolder |
string | read/write | Target folder name, for move actions |
RouteID |
long | read/write | Route to deliver through, for route actions |
RuleID |
long | read-only | The rule this action belongs to |
ScriptFunction |
string | read/write | Function name to run, for script actions |
Subject |
string | read/write | Reply subject, for reply actions |
To |
string | read/write | Forwarding address, for forward actions |
Type |
eRuleActionType |
read/write | Which action — see the table below |
Value |
string | read/write | Payload for the remaining action types |
| Method | What it does |
|---|---|
Delete() |
Deletes the action from the database |
MoveDown() |
Moves the action down the execution order |
MoveUp() |
Moves the action up the execution order |
Save() |
Writes changes to the database |
Action types:
Type |
Constant | Does | Payload |
|---|---|---|---|
| 1 | eRADeleteEmail |
Deletes the message | — |
| 2 | eRAForwardEmail |
Forwards the message | To |
| 3 | eRAReply |
Sends a reply | FromName, FromAddress, Subject, Body |
| 4 | eRAMoveToImapFolder |
Moves the message into a folder | IMAPFolder |
| 5 | eRARunScriptFunction |
Runs a function from your event scripts, passing the message object | ScriptFunction |
| 6 | eRAStopRuleProcessing |
Stops any further rules from running | — |
| 7 | eRASetHeaderValue |
Sets a header, replacing any existing one of the same name | HeaderName, Value |
| 8 | eRASendUsingRoute |
Delivers through a specific route (chapter 14) | RouteID |
| 9 | eRACreateCopy |
Creates an exact copy of the message, recipients included | — |
| 10 | eRABindToAddress |
Binds outgoing SMTP for the message to a local IP address | Value |
| 11 | eRAAddDisclaimer |
Appends the text to the plain and HTML parts, with X-hMailServer-Disclaimer naming the rule (6.3.4) |
Body |
| 12 | eRAReject |
Refuses the message with 550 5.7.1 and the text while the sender is still connected, before it is accepted; a message the rule meets only after acceptance is dropped, as a delete is. With no text, the answer names the rule (6.3.4) |
Value |
| 13 | eRARequireTLS |
Every external delivery of the message needs STARTTLS; a destination that offers none is deferred, and the message is not sent in the clear (6.3.4) | — |
| 14 | eRAQuarantine |
Holds the message in the quarantine under Rule: <name>, for an administrator to release or delete (6.3.4) |
— |
| 15 | eRAWarnSender |
Lets the message go on and mails a sender this server hosts a notice naming the rule; an outside sender gets nothing (6.3.4) | Value |
| 0 | eRAUnknown |
Nothing defined — never set this | — |
A Run function action names a plain function. Type 5 calls the named function of the
event script with the message, and needs scripting switched on. Since 6.3.4 ScriptFunction
must be ASCII letters, digits, _ and $, not starting with a digit. Any other name
runs nothing: on Windows each match is reported as HM5018, and on Linux, where event scripts are
JavaScript, the name finds no function. Up to 6.3.3 the name was written into the script’s call as it was,
in VBScript and JScript alike, so whoever could edit a rule, a domain’s own administrator included, could run
code in the server (CVE-2026-100741). On a server not yet on 6.3.4, let nobody but the server administrator
make or edit a rule with this action.
More: Mail flow rules, the outbound content policy and what a domain does to a message on the forum.
RuleActions
All actions of one rule, via Rule.Actions. Actions run in order; MoveUp and
MoveDown on the individual action reorder them.
| Property | Type | Access | What it holds |
|---|---|---|---|
Count |
long | read-only | Number of actions |
Item(index) |
RuleAction |
read-only | The action at that position |
ItemByDBID(id) |
RuleAction |
read-only | The action with that database ID |
| Method | What it does |
|---|---|
Add() |
Adds a new, unsaved action |
Delete(index) |
Deletes the action at that position |
DeleteByDBID(id) |
Deletes the action with that database ID |
Refresh() |
Reloads the collection from the database |
One trap: in the type library, the parameter of Delete here (and on
RuleCriterias) is named DBID — but the implementation treats it as a
position. Pass an index to Delete; when what you have is a database ID, use
DeleteByDBID.
RuleCriteria
One test in a rule. Either it tests a predefined field (UsePredefined = true,
field chosen by PredefinedField) or a named header (UsePredefined = false,
header named in HeaderField). MatchType and MatchValue say what counts as a
match.
| Property | Type | Access | What it holds |
|---|---|---|---|
HeaderField |
string | read/write | Header to test, when not using a predefined field |
ID |
long | read-only | Database identifier |
MatchType |
eRuleMatchType |
read/write | How to compare — see below |
MatchValue |
string | read/write | What to compare against (a regular expression for type 5) |
PredefinedField |
eRulePredefinedField |
read/write | Which built-in field to test — see below |
RuleID |
long | read-only | The rule this criterion belongs to |
UsePredefined |
bool | read/write | true: test PredefinedField; false: test HeaderField |
| Method | What it does |
|---|---|
Delete() |
Deletes the criterion from the database |
Save() |
Writes changes to the database |
Match types:
MatchType |
Constant | Matches when |
|---|---|---|
| 1 | eMTEquals |
The whole field equals the value, ignoring case |
| 2 | eMTContains |
The field contains the value |
| 3 | eMTLessThan |
The field is less than the value, both read as decimal numbers (a comma as a decimal point); a field that is not a number reads as 0 |
| 4 | eMTGreaterThan |
The field is greater than the value, both read the same way |
| 5 | eMTRegExMatch |
The whole field matches the regular expression (Perl syntax, case-sensitive). It is not a search: write .*invoice.* to find a word anywhere. A pattern that takes 250 ms or more is suspended for 300 seconds and matches nothing meanwhile |
| 6 | eMTNotContains |
The field does not contain the value; also true when the field is empty, as for a header the message does not have |
| 7 | eMTNotEquals |
The field differs from the value; also true when the field is empty |
| 8 | eMTWildcard |
The whole field matches the wildcard expression (* any run of characters, ? exactly one), ignoring case: write *invoice* to find a word anywhere. A broken pattern fails silently |
| 0 | eMTUnknown |
Never set this |
Predefined fields:
PredefinedField |
Constant | Tests |
|---|---|---|
| 1 | eFTFrom |
The From header |
| 2 | eFTTo |
The To header |
| 3 | eFTCC |
The CC header |
| 4 | eFTSubject |
The Subject header |
| 5 | eFTBody |
The plain-text body and the HTML body, joined into one string; not the attachments. A message encrypted end to end (S/MIME or PGP/MIME), judged from its body and not its label, has no body the server can read, and the condition does not match it whichever way it is phrased, does not contain included; a signed message, whose text is in the clear, is read as before (6.3.4) |
| 6 | eFTMessageSize |
The message size, in bytes, not the kilobytes of Message.Size: 25 MB is 26214400 |
| 7 | eFTRecipientList |
Every envelope recipient — BCC recipients included |
| 8 | eFTDeliveryAttempts |
The number of delivery attempts, the current one included |
| 9 | eFTAttachmentName |
Every attachment’s file name, with ; between them (6.3.4) |
| 10 | eFTSensitiveContent |
The detectors that found something in the readable text: card_number, iban, account_number (6.3.4) |
| 11 | eFTAttachmentType |
Every attachment’s type, read from its first bytes rather than its name (6.3.4) |
| 12 | eFTExternalRecipient |
Every envelope recipient this server does not host; empty when every recipient is local (6.3.4) |
| 13 | eFTContent |
The readable text: the plain and HTML parts and every attachment whose bytes are text (6.3.4) |
| 0 | eFTUnknown |
Never set this |
RuleCriterias
All criteria of one rule, via Rule.Criterias. Rule.UseAND decides whether they
combine with AND or OR.
| Property | Type | Access | What it holds |
|---|---|---|---|
Count |
long | read-only | Number of criteria |
Item(index) |
RuleCriteria |
read-only | The criterion at that position |
ItemByDBID(id) |
RuleCriteria |
read-only | The criterion with that database ID |
| Method | What it does |
|---|---|
Add() |
Adds a new, unsaved criterion |
Delete(index) |
Deletes the criterion at that position |
DeleteByDBID(id) |
Deletes the criterion with that database ID |
Refresh() |
Reloads the collection from the database |
30.5 Worked examples
All of the following are PowerShell, run on the Windows server itself — or on another machine
where the installer’s Remote administration support component has registered the
COM API (chapter 4), with DCOM permitted between the two machines. The preamble’s
New-Object line is the local form; from another machine, replace it with one that names
the server (here mail01):
$app = [Activator]::CreateInstance([Type]::GetTypeFromProgID('hMailServer.Application', 'mail01'))
The Control Panel administers remote servers over this same interface. hMailServer has been 64-bit only since 6.0.0,
so use the ordinary 64-bit PowerShell;
the old 5.x-era advice about running the 32-bit SysWOW64 PowerShell no longer
applies. A Linux server has no COM: there the same jobs go through the REST API (chapter 24),
the Control Deck and hmctl.
Every example except Sending a message starts from this preamble, shown once:
$app = New-Object -ComObject 'hMailServer.Application'
if (-not $app.Authenticate('Administrator', 'your-admin-password')) {
throw 'Authentication failed'
}
These scripts change a live server the moment they run — there is no dry-run mode in the COM API. Read a script until you can say what it does, then run it against a test installation before production.
Creating an account
$domain = $app.Domains.ItemByName('example.com')
$account = $domain.Accounts.Add()
$account.Address = '[email protected]'
$account.Password = 'a-strong-password'
$account.Active = $true
$account.MaxSize = 1000 # MB, 0 = unlimited
$account.Save()
Changing a password
$account = $app.Domains.ItemByName('example.com').Accounts.ItemByAddress('[email protected]')
$account.Password = 'the-new-password'
$account.Save()
The server hashes the password on save (chapter 17 covers the algorithms), so
Password is effectively write-only — do not expect to read a password back.
Since 6.3.4 a password set this way can also be checked against published data breaches,
when that check is turned on (it is off by default): a breached password is refused with a
COM error carrying the reason.
Sending a message
The one object you create directly. No Authenticate is needed: the message is
injected straight into the local server’s delivery queue.
$msg = New-Object -ComObject 'hMailServer.Message'
$msg.From = 'Alice Example <[email protected]>'
$msg.FromAddress = '[email protected]'
$msg.AddRecipient('Bob', '[email protected]')
$msg.Subject = 'Scripted message'
$msg.Body = 'Sent through the COM API.'
$msg.Save() # queued for delivery immediately
Set both From (the header, with display name) and FromAddress (the envelope
sender) — mail with an empty envelope sender is treated as a bounce by receiving
servers.
Copying messages into an IMAP folder
Copies everything in Alice’s Inbox into a Backup folder, creating it if needed.
Copy() takes the destination folder’s database ID, not its name.
$account = $app.Domains.ItemByName('example.com').Accounts.ItemByAddress('[email protected]')
$folders = $account.IMAPFolders
$inbox = $folders.ItemByName('Inbox')
try { $backup = $folders.ItemByName('Backup') }
catch { $backup = $folders.Add('Backup') } # Add() saves immediately
$messages = $inbox.Messages
for ($i = 0; $i -lt $messages.Count; $i++) {
$messages.Item($i).Copy($backup.ID) | Out-Null
}
Listing a user’s folders
Folder trees are recursive, so listing one is too:
function Show-FolderTree($folders, $indent = 0) {
for ($i = 0; $i -lt $folders.Count; $i++) {
$folder = $folders.Item($i)
(' ' * $indent) + $folder.Name
Show-FolderTree $folder.SubFolders ($indent + 3)
}
}
$account = $app.Domains.ItemByName('example.com').Accounts.ItemByAddress('[email protected]')
Show-FolderTree $account.IMAPFolders
Enabling a domain
$domain = $app.Domains.ItemByName('example.com')
$domain.Active = $true
$domain.Save()
Setting $false and saving disables it again — the same switch as the box on the
General tab of the domain’s Properties, under
Accounts & domains → Domains in the Control Panel.
Deleting an account
Deletion goes through the collection and needs the account’s database ID, so fetch the account first:
$accounts = $app.Domains.ItemByName('example.com').Accounts
$account = $accounts.ItemByAddress('[email protected]')
$accounts.DeleteByDBID($account.ID)
Deleting an account deletes its mailbox contents with it, and Recover deleted items, new in 6.3.4, keeps nothing of an account being deleted. If there is any chance the mail matters, take a backup first (chapter 15) or copy the folders elsewhere using the example above. Since 6.3.4 an account under an eDiscovery legal hold, or in a domain under one, cannot be deleted: the call is refused with a sentence saying why.
Building a rule from code
Everything from §30.4 in one piece: an account rule that forwards every message — any message larger than 0 bytes — to an archive address.
$account = $app.Domains.ItemByName('example.com').Accounts.ItemByAddress('[email protected]')
$rule = $account.Rules.Add()
$rule.Name = 'Archive everything'
$rule.Active = $true
$rule.UseAND = $true
$rule.Save() # first: criteria and actions need its ID
$crit = $rule.Criterias.Add()
$crit.UsePredefined = $true
$crit.PredefinedField = 6 # eFTMessageSize
$crit.MatchType = 4 # eMTGreaterThan
$crit.MatchValue = '0'
$crit.Save()
$action = $rule.Actions.Add()
$action.Type = 2 # eRAForwardEmail
$action.To = '[email protected]'
$action.Save()
Swap $account.Rules for $app.Rules and the same code creates a server-wide rule
instead — the objects are identical either way. Since 6.3.4 a domain has rules of its own as well:
swap in $app.Domains.ItemByName('example.com').Rules and the rule runs, after the server’s
rules, on that domain’s mail: on the whole message when the domain sent it (its own account submitted it, or
DMARC passed for it) or every recipient is its own, and otherwise on its own recipients only (chapter 12).
More: The COM API on Windows: object model, Save() since 6.3.4, roles and the Import Tool on the forum.
31. COM API reference III: security, filtering and operations
This chapter covers the objects you script when you operate the server rather than administer its mailboxes: anti-spam and anti-virus, IP ranges and ports, certificates, relays and routes, backup, the delivery queue, logging, status counters and the utility functions. Between them they bring most of what chapters 9–11 and 14–17 cover within reach of automation.
Every example assumes the connection boilerplate from chapter 23:
$app = New-Object -ComObject 'hMailServer.Application'
$app.Authenticate('Administrator', 'your-admin-password') | Out-Null
Check your return values. Several of the methods covered by the 6.2.10 authorization fix (chapter 29) live on the objects in this chapter — the
Cachegetters among them. A refused call fails properly: in PowerShell it throws; in VBScript it raises an error you can inspect withErr. Wrap operational scripts intry/catch(orOn Errorhandling) rather than ignoring failures. Since 6.3.4 a refusal also says why, in the server’s own sentence, where up to 6.3.3 seventy-two classes answered one with a bare HRESULT (chapter 23).
Where each object lives
| Object | Reached from |
|---|---|
AntiSpam |
Application.Settings.AntiSpam |
DNSBlackLists |
Application.Settings.AntiSpam.DNSBlackLists |
SURBLServers |
Application.Settings.AntiSpam.SURBLServers |
WhiteListAddresses |
Application.Settings.AntiSpam.WhiteListAddresses |
GreyListingWhiteAddresses |
Application.Settings.AntiSpam.GreyListingWhiteAddresses |
GreyListingBypassEntries |
Application.Settings.AntiSpam.GreyListingBypassEntries (new in 6.3.4) |
SenderListEntries |
Application.Settings.AntiSpam.SenderList; a domain’s own is Domain.SenderList (new in 6.3.4) |
BlockedSenders |
Application.Settings.AntiSpam.BlockedSenders |
Quarantine |
Application.Settings.AntiSpam.Quarantine |
AntiVirus |
Application.Settings.AntiVirus |
BlockedAttachments |
Application.Settings.AntiVirus.BlockedAttachments |
SecurityRanges |
Application.Settings.SecurityRanges |
TCPIPPorts |
Application.Settings.TCPIPPorts |
SSLCertificates |
Application.Settings.SSLCertificates |
IncomingRelays |
Application.Settings.IncomingRelays |
Routes |
Application.Settings.Routes |
RouteAddresses |
Route.Addresses |
RemoteDomainPolicies |
Application.Settings.RemoteDomainPolicies (new in 6.3.4; chapter 14) |
BackupSettings |
Application.Settings.Backup |
BackupManager |
Application.BackupManager |
Backup |
Returned by BackupManager.LoadBackup() |
Database |
Application.Database |
DeliveryQueue |
Application.GlobalObjects.DeliveryQueue |
Directories |
Application.Settings.Directories |
EventLog |
Global object inside event scripts |
Logging |
Application.Settings.Logging |
Status |
Application.Status |
Utilities |
Application.Utilities |
Diagnostics |
Application.Diagnostics (new in 6.x) |
Scripting |
Application.Settings.Scripting |
ServerMessages |
Application.Settings.ServerMessages |
Client |
Passed as a parameter to event handlers |
Cache |
Application.Settings.Cache |
MessageIndexing |
Application.Settings.MessageIndexing (new in 6.x) |
Languages |
Application.GlobalObjects.Languages |
Two kinds of object
The objects here fall into two families, and knowing which is which saves confusion:
- Settings objects —
AntiSpam,AntiVirus,Logging,Cache,BackupSettings,Directories,Scripting. Assigning a property writes it immediately; there is noSave()to call. - Row objects — anything with an
IDproperty: aDNSBlackList, aSecurityRange, aRoute, and so on. Each represents one database row. Changes take effect only when you callSave(), andDelete()removes the row. Since 6.3.4 that holds for the items the running server reads as well (routes and their addresses, incoming relays, DNS blacklists, SURBL servers, remote domain policies, server messages, blocked attachments, SSL certificates and groups): up to 6.3.3 a property set on one of them changed the running server at once, saved or not. An object read before aSave()keeps what it read, so read the item again from its collection to see the saved values (chapter 23).
Row objects come in collections, and the collections share a standard shape:
| Member | Kind | Does |
|---|---|---|
Count |
property (read-only) | Number of members |
Item(index) |
property (read-only) | The member at a zero-based position |
ItemByDBID(dbid) |
property (read-only) | The member with a given database identifier |
Add() |
method | Creates a new, unsaved member — set its properties, then call its Save() |
DeleteByDBID(dbid) |
method | Deletes the member with a given database identifier |
Refresh() |
method | Re-reads the collection from the database |
The tables below list every member per object; where a collection matches the standard shape exactly, that is stated rather than repeated.
Types are those of the COM type library: long, bool, string, Date/Time, and a
handful of enumerations whose numeric values are given where they matter.
AntiSpam
The server-wide anti-spam configuration — the object behind Spam & virus filtering → Anti-spam settings (chapter 10). Every enabled test adds to a message’s score; the two thresholds decide whether the total gets the message tagged or refused.
Methods
| Method | Does |
|---|---|
ClearGreyListingTriplets() |
Empties the greylisting triplet table, so every sender is treated as new again |
DKIMVerify(file) |
Verifies the DKIM signature of a message file on disk and returns the result as an eDKIMResult |
TestSpamAssassinConnection(host, port) |
Tests the connection to a SpamAssassin spamd, returning success plus a result text — what the Control Panel’s test button calls |
Properties
| Property | Type | Description |
|---|---|---|
AddHeaderReason |
bool | Add an X-hMailServer-Reason-1, -2 and so on header for each failing test, and
X-hMailServer-Reason-Score with their sum (on as shipped; the per-account spam overrides act only
while it is on) |
AddHeaderSpam |
bool | Add X-hMailServer-Spam: YES to messages at or above the mark threshold (on as shipped) |
BypassGreylistingOnMailFromMX |
bool | Skip greylisting when the sending IP is one of the sender domain’s MX hosts |
BypassGreylistingOnSPFSuccess |
bool | Skip greylisting when the sending IP passes the sender domain’s SPF record (on as shipped; it needs
UseSPF on, and it trusts every SPF pass, where GreyListingBypassEntries, below, names partners) |
CheckHostInHelo |
bool | Test whether the HELO/EHLO host name matches the connecting host |
CheckHostInHeloScore |
long | Score added when the HELO check fails |
CheckPTR |
bool | Test that the connecting IP has a reverse-DNS (PTR) record whose forward lookup comes back to that IP |
CheckPTRScore |
long | Score added when the PTR check fails |
DKIMVerificationEnabled |
bool | Verify DKIM signatures on incoming messages |
DKIMVerificationFailureScore |
long | Score added when DKIM verification fails |
DMARCEnabled |
bool | New in 6.x. Evaluate the sender domain’s DMARC policy (chapter 10) |
DMARCFailureScore |
long | New in 6.x. Score added when DMARC evaluation fails |
DNSBlackLists |
DNSBlackLists | The DNSBL collection (read-only) |
GreyListingEnabled |
bool | Turn greylisting on |
GreyListingInitialDelay |
long | Minutes a new sender triplet is temporarily rejected |
GreyListingInitialDelete |
long | Hours after which a triplet that never retried is dropped (24 as shipped; the Control Panel shows this value in days) |
GreyListingFinalDelete |
long | Hours after which an established triplet is dropped, counted from its last accepted message (864, that is 36 days, as shipped; the Control Panel shows it in days) |
GreyListingWhiteAddresses |
GreyListingWhiteAddresses | Addresses exempt from greylisting (read-only) |
MaximumMessageSize |
long | Largest message the tests made after the body (SURBL, DKIM, ARC, DMARC, SpamAssassin and the filter hook) will scan, in KB (1024 as shipped); a larger message skips them, and the tests made before the body still run |
PrependSubject |
bool | Prefix the subject of tagged messages |
PrependSubjectText |
string | The prefix to use, e.g. [SPAM] |
SpamAssassinEnabled |
bool | Hand messages to a SpamAssassin spamd for content analysis |
SpamAssassinHost |
string | Host spamd listens on |
SpamAssassinPort |
long | Port spamd listens on (conventionally 783) |
SpamAssassinMergeScore |
bool | Use SpamAssassin’s own score instead of the fixed one |
SpamAssassinScore |
long | Fixed score added when SpamAssassin flags a message |
SpamDeleteThreshold |
long | Total score at or above which the message is refused during the SMTP conversation, or held in the quarantine when quarantine is on and the verdict came after the body (20 as shipped; 0 never refuses) |
SpamMarkThreshold |
long | Total score at or above which the message is tagged |
SURBLServers |
SURBLServers | The SURBL collection (read-only) |
TarpitCount |
long | The recipient tarpit (since 6.2.25): how many recipients an unauthenticated SMTP session may name before
each further RCPT TO waits; 0, as shipped, is off. It is the [Settings] key
SmtpTarpitCount, and a save applies it |
TarpitDelay |
long | Seconds each RCPT TO past TarpitCount waits for its reply, at most 30; 0, as shipped,
is off (SmtpTarpitDelaySeconds). Authenticated sessions and IP ranges with spam protection off are exempt |
UseMXChecks |
bool | Test that the sender’s domain can actually receive mail |
UseMXChecksScore |
long | Score added when the MX check fails |
UseSPF |
bool | Evaluate the sender’s SPF record (to RFC 7208 since 6.3.4) |
UseSPFScore |
long | Score added on an SPF hard Fail only; since 6.3.4 SoftFail, TempError and PermError can carry scores of their own (below) |
WhiteListAddresses |
WhiteListAddresses | Senders exempt from spam checking entirely (read-only) |
Also on AntiSpam, and not in the table. BlockedSenders, the
Blocked senders list (an address or domain, a score, 100 on a new entry, and a description);
Quarantine, the messages held on Spam & virus filtering → Quarantine
(Refresh(), Count, ReleaseByDBID, DeleteByDBID,
DeleteExpired()); and ArcFilteringEnabled with ArcTrustedSealers, inbound
ARC, off as shipped and doing nothing while the sealer list is empty. Added in 6.3.4, with every
switch among them off as shipped: SenderList, the server’s allow and block list, with
SendQuarantineDigests() and SendQuarantineDigestsAtHour(hour); for greylisting,
GreyListingBypassEntries (below), GreyListingSlideOnEarlyRetry,
GreyListingAbuseThreshold and GreyListingAbuseLockMinutes; and the SMTP policy a score
cannot express: UseSPFSoftFailScore, UseSPFTempErrorScore,
UseSPFPermErrorScore, SPFTempErrorDefer, RejectMultiRcptNullSender,
BanMultiRcptNullSender and BanMultiRcptNullSenderHours, with the domain white and black
list as DomainPolicyList (a JSON document), AddDomainPolicyListEntry(pattern, matchOn, action,
note) and RemoveDomainPolicyListEntry(id). Chapter 10 says what each does.
The tag-first advice from chapter 10 applies to scripts too: before raising scores or lowering thresholds programmatically, run with deletion disabled (
SpamDeleteThresholdof0) and read what gets tagged.
DNSBlackList and DNSBlackLists
One DNSBlackList is one IP blocklist queried for every connecting server — the rows on
Spam & virus filtering → DNS blacklists. DNSBlackLists is their collection.
DNSBlackList properties (plus Save() and Delete())
| Property | Type | Description |
|---|---|---|
Active |
bool | The list is queried |
DNSHost |
string | The DNSBL zone to query, e.g. zen.spamhaus.org |
ExpectedResult |
string | The answer(s) that mean “listed”: | separates alternatives, a range on the last octet such as
127.0.0.2-8 covers .2 to .8, and * is a wildcard |
ID |
long | Database identifier (read-only) |
RejectMessage |
string | SMTP rejection text shown to a listed sender |
Score |
long | Score added when the connecting IP is listed |
DNSBlackLists follows the standard collection shape, plus:
| Member | Does |
|---|---|
ItemByDNSHost(host) |
Returns the list with a given DNS zone (read-only) |
A new installation already has an inactive zen.spamhaus.org row (expected result
127.0.0.2-8|127.0.0.10-11, score 3). To use it, tick Active on it, or from a script
fetch it with ItemByDNSHost('zen.spamhaus.org'), set Active and call Save(),
rather than adding a second row. On a server that has no such row, add the workhorse list from chapter 10:
$dnsbl = $app.Settings.AntiSpam.DNSBlackLists.Add()
$dnsbl.Active = $true
$dnsbl.DNSHost = 'zen.spamhaus.org'
$dnsbl.ExpectedResult = '127.0.0.2|127.0.0.3|127.0.0.4|127.0.0.9|127.0.0.10|127.0.0.11'
$dnsbl.Score = 5
$dnsbl.RejectMessage = 'Rejected: sending IP is listed by Spamhaus ZEN.'
$dnsbl.Save()
SURBLServer and SURBLServers
A SURBLServer is a URL blocklist checked against the links inside message bodies
rather than the connecting IP (Spam & virus filtering → SURBL servers). The members
mirror DNSBlackList minus the expected-result field.
SURBLServer properties (plus Save() and Delete())
| Property | Type | Description |
|---|---|---|
Active |
bool | The list is queried |
DNSHost |
string | The SURBL zone to query, e.g. multi.surbl.org |
ID |
long | Database identifier (read-only) |
RejectMessage |
string | SMTP rejection text used when the message is refused |
Score |
long | Score added when a linked domain is listed |
SURBLServers follows the standard collection shape, plus ItemByDNSHost(host)
(read-only).
WhiteListAddress and WhiteListAddresses
The global spam white list (Spam & virus filtering → White list). A message that
matches an entry skips spam checking altogether. An entry matches on two axes at once:
the sender address must match EmailAddress, and the sending IP must fall inside the
LowerIPAddress–UpperIPAddress span. To whitelist a sender regardless of where they
send from, use the span 0.0.0.0 to 255.255.255.255.
WhiteListAddress properties (plus Save() and Delete())
| Property | Type | Description |
|---|---|---|
Description |
string | Free-text note about the entry |
EmailAddress |
string | The sender address to exempt; it may be a wildcard, and empty or * means any sender in the IP span |
ID |
long | Database identifier (read-only) |
LowerIPAddress |
string | Bottom of the sending-IP span |
UpperIPAddress |
string | Top of the sending-IP span |
WhiteListAddresses follows the standard collection shape, plus:
| Member | Does |
|---|---|
Clear() |
Deletes every entry in the white list |
GreyListingWhiteAddress and GreyListingWhiteAddresses
The narrower of the two white lists (§10.4): an IP address here is exempt from greylisting only and still runs the full battery of spam tests. Use it for senders whose first message must not be delayed.
GreyListingWhiteAddress properties (plus Save() and Delete())
| Property | Type | Description |
|---|---|---|
Description |
string | Free-text note about the entry |
ID |
long | Database identifier (read-only) |
IPAddress |
string | The sending IP to exempt from greylisting |
GreyListingWhiteAddresses follows the standard collection shape, plus
ItemByName(ipAddress) (read-only), which looks an entry up by its IP address.
New in 6.3.4: GreyListingBypassEntries.
Application.Settings.AntiSpam.GreyListingBypassEntries is the list on Spam & virus
filtering → Greylisting bypass list: a domain, or a suffix with a leading dot, let past greylisting only
when the message passes the SPF, DKIM or DMARC check the entry requires, for that name. A
GreyListingBypassEntry has ID, Pattern, MatchOn (1 to 3, which
says whether the pattern is compared with the envelope sender’s domain, the HELO host or either),
RequireSPF, RequireDKIM and RequireDMARC (at least one must be set),
Note, Save() and Delete(). The collection follows the standard shape, plus
ItemByName. Like the greylisting white list it skips greylisting only; every spam test still runs.
AntiVirus
The virus-scanning configuration behind Spam & virus filtering → Anti-virus settings (chapter 11). Three scanner hookups are available, and all three can be on at once; ClamAV over TCP is the one to use where you can — ClamWin and the custom scanner start a process for every file scanned (the whole message, then each attachment), which is slow. The custom scanner is also how a command-line engine such as Microsoft Defender is used, and the Control Panel has presets for several.
Methods
| Method | Does |
|---|---|
TestClamAVScanner(host, port) |
Tests the connection to a clamd daemon, returning success plus a result text |
TestClamWinScanner(executable, dbFolder) |
Tests the ClamWin hookup the same way |
TestCustomerScanner(executable, virusReturnCode) |
Tests the custom-scanner hookup (the misspelt name is the type library’s, kept as-is) |
Properties
| Property | Type | Description |
|---|---|---|
Action |
eAntivirusAction | What to do with an infected message: 0, as shipped, deletes it for every recipient; 1
(Delete infected attachments only) rebuilds the message from its first text part, so an HTML version,
inline images and every clean attachment are discarded along with the infected one |
BlockedAttachments |
BlockedAttachments | The blocked-extension collection (read-only) |
ClamAVEnabled |
bool | Scan with a ClamAV clamd daemon over TCP |
ClamAVHost |
string | Host name or IP where clamd listens |
ClamAVPort |
long | clamd port (conventionally 3310) |
ClamWinEnabled |
bool | Scan by running the ClamWin scanner executable per message |
ClamWinExecutable |
string | Path to the ClamWin scanner |
ClamWinDBFolder |
string | Path to the ClamWin signature database |
CustomScannerEnabled |
bool | Scan with an arbitrary command-line scanner |
CustomScannerExecutable |
string | The command line to run against each file; %FILE% marks where the file’s path goes (without it,
the path is appended). Since 6.3.4 a bare %FILE% is quoted for you: up to 6.3.3 a path with a space
in it, as under C:\Program Files, was cut at the space, nothing was scanned and the message was
delivered as clean |
CustomScannerReturnValue |
long | Exit code the custom scanner uses to mean “infected” |
EnableAttachmentBlocking |
bool | Turn extension blocking on |
MaximumMessageSize |
long | Largest message passed to the scanners, in KB; 0, as shipped, is no limit. A larger message is delivered unscanned with no log line, so a generous-looking limit is a hole |
NotifyReceiver |
bool | Tell the intended recipient that a message was removed |
NotifySender |
bool | Tell the apparent sender that a message was removed |
Leave
NotifySenderoff. Virus senders are forged, so the notification lands on an innocent third party — that is backscatter, and chapter 11 explains why it gets you blacklisted.
BlockedAttachment and BlockedAttachments
The attachment block list (Spam & virus filtering → Blocked attachments). Each entry
is one file-name wildcard, matched without regard to case against an attachment’s declared name,
independent of virus scanning. The list acts only while AntiVirus.EnableAttachmentBlocking is on,
and it is off as shipped. A matching attachment is not refused: .txt is appended to its name and
its content is replaced by the ATTACHMENT_REMOVED server message, so the message is still delivered.
BlockedAttachment properties (plus Save() and Delete())
| Property | Type | Description |
|---|---|---|
Description |
string | Free-text note, e.g. “Executable” |
ID |
long | Database identifier (read-only) |
Wildcard |
string | The pattern to block, e.g. *.exe |
BlockedAttachments follows the standard collection shape exactly.
SecurityRange and SecurityRanges
A SecurityRange is one IP range — the rows on Sign-in & access → IP ranges,
and the single most security-sensitive collection in the API (§17.1). When a
client connects, the server finds every range containing the client’s IP and applies the
one with the highest Priority. The range then answers three questions: which protocols
may this client use, which mail flows will we accept from it, and which of those flows
demand authentication.
The option flags. The delivery flags form a grid. “Local” and “remote” refer to the
sender and recipient addresses, not to IP addresses: an address is local when its
domain is hosted on this server (or belongs to a route marked as local — see
TreatSenderAsLocalDomain and TreatRecipientAsLocalDomain under Route), and
remote otherwise. A colleague on the office LAN sending to Gmail is a
local-to-remote delivery; a stranger on the internet sending to one of your
mailboxes is remote-to-local.
| Flow | Accept it? | Demand authentication? | On an internet-facing range |
|---|---|---|---|
| Local → local | AllowDeliveryFromLocalToLocal |
RequireSMTPAuthLocalToLocal |
Allow, require auth — otherwise anyone can forge internal mail |
| Local → remote | AllowDeliveryFromLocalToRemote |
RequireSMTPAuthLocalToExternal |
Allow, require auth — this is your users relaying outbound |
| Remote → local | AllowDeliveryFromRemoteToLocal |
RequireSMTPAuthExternalToLocal |
Allow, no auth — this is inbound mail from the world |
| Remote → remote | AllowDeliveryFromRemoteToRemote |
RequireSMTPAuthExternalToExternal |
Deny — allowing it unauthenticated is an open relay |
(The two halves of the grid use different vocabulary — Remote in the allow flags,
External in the auth flags. They mean the same thing.)
Methods: Save() and Delete().
Properties
| Property | Type | Description |
|---|---|---|
AllowDeliveryFromLocalToLocal |
bool | Accept mail from a local sender to a local recipient |
AllowDeliveryFromLocalToRemote |
bool | Accept mail from a local sender to a remote recipient |
AllowDeliveryFromRemoteToLocal |
bool | Accept mail from a remote sender to a local recipient |
AllowDeliveryFromRemoteToRemote |
bool | Accept mail from a remote sender to a remote recipient |
AllowSMTPConnections |
bool | Clients in this range may connect to SMTP |
AllowIMAPConnections |
bool | Clients in this range may connect to IMAP |
AllowPOP3Connections |
bool | Clients in this range may connect to POP3 |
EnableAntiVirus |
bool | Run virus scanning on mail from this range |
EnableSpamProtection |
bool | Run spam tests on mail from this range |
Expires |
bool | The range is temporary and will be removed — how auto-ban entries work |
ExpiresTime |
Date/Time | When a temporary range lapses |
ID |
long | Database identifier (read-only) |
IsForwardingRelay |
bool | Obsolete — use the IncomingRelays collection instead |
LowerIP |
string | Bottom of the range |
Name |
string | Display name, e.g. “Internet” or “Office LAN” |
Priority |
long | Higher wins when ranges overlap |
RequireAuthForDeliveryToLocal |
bool | Obsolete — use the RequireSMTPAuth* flags instead |
RequireAuthForDeliveryToRemote |
bool | Obsolete — use the RequireSMTPAuth* flags instead |
RequireSMTPAuthLocalToLocal |
bool | Demand authentication for local-to-local deliveries |
RequireSMTPAuthLocalToExternal |
bool | Demand authentication for local-to-remote deliveries |
RequireSMTPAuthExternalToLocal |
bool | Demand authentication for remote-to-local deliveries |
RequireSMTPAuthExternalToExternal |
bool | Demand authentication for remote-to-remote deliveries |
RequireSSLTLSForAuth |
bool | Refuse authentication on unencrypted connections from this range: AUTH is not even offered
before TLS, on SMTP, POP3, IMAP and ManageSieve. A new 6.3.4 installation sets it on the Internet range; an
upgraded server keeps what it had |
UpperIP |
string | Top of the range |
Trusting an upstream forwarder — so spam tests judge the originating IP from the
Receivedheaders rather than your own front-end filter — is not an IP-range option. That is what Mail flow & delivery → Incoming relays and theIncomingRelayscollection are for (§14.3, below).
SecurityRanges follows the standard collection shape, plus:
| Member | Does |
|---|---|
Delete(index) |
Deletes the range at a zero-based position |
ItemByName(name) |
Returns the range with a given name (read-only) |
SetDefault() |
Discards the current ranges and writes the two classic ones, Internet and My computer, as
the create scripts make them. Neither requires TLS for authentication, so on a new 6.3.4 installation, whose
Internet range does, set RequireSSLTLSForAuth on it again afterwards |
Adding a range for the office network:
$range = $app.Settings.SecurityRanges.Add()
$range.Name = 'Office LAN'
$range.LowerIP = '192.168.1.0'
$range.UpperIP = '192.168.1.255'
$range.Priority = 25
$range.AllowSMTPConnections = $true
$range.AllowIMAPConnections = $true
$range.AllowPOP3Connections = $true
$range.AllowDeliveryFromLocalToLocal = $true
$range.AllowDeliveryFromLocalToRemote = $true
$range.AllowDeliveryFromRemoteToLocal = $true
$range.AllowDeliveryFromRemoteToRemote = $false
$range.RequireSMTPAuthLocalToLocal = $true
$range.RequireSMTPAuthLocalToExternal = $true
$range.RequireSSLTLSForAuth = $true
$range.Save()
Auto-ban (§17.1) blocks an abusive IP by inserting a range with Expires set, at priority 100 and
named Auto-ban: <user>. Since 6.3.4 a spam-trap hit and a banned multi-recipient null sender
are recorded the same way, so the loop below lifts those too; with AutoBanFirewall on, the firewall
is brought into line when a range is deleted. To lift every current ban:
$ranges = $app.Settings.SecurityRanges
for ($i = $ranges.Count - 1; $i -ge 0; $i--) {
$r = $ranges.Item($i)
if ($r.Expires) { $ranges.DeleteByDBID($r.ID) }
}
A careless range is how servers become open relays. After any scripted change, confirm that no internet-facing range allows remote-to-remote delivery without authentication.
TCPIPPort and TCPIPPorts
A TCPIPPort is one listener — an address, a port, a protocol and its TLS behaviour
(Connections & protocols → TCP/IP ports, chapter 22). Listeners are bound when the
servers start, so a change takes effect at the next reinitialise (Application.Reinitialize, or
POST /api/v1/server/reinitialize) or service restart.
TCPIPPort properties (plus Save() and Delete())
| Property | Type | Description |
|---|---|---|
Address |
string | The local address to bind — 0.0.0.0 for all interfaces |
ClientCertificateCAFile |
string | The CA bundle (a file of the certificate authorities) that a client certificate on this port must chain to — the only trust anchor for them, not the Windows certificate store |
ClientCertificatePolicy |
long | Mutual TLS on this port: 0 off (the default), 1 request (verified if offered, never fails the handshake), 2 require (the handshake fails without a certificate that chains to ClientCertificateCAFile, and with no loadable bundle the listener does not start). Refused on a port with no TLS |
ConnectionSecurity |
eConnectionSecurity | How TLS is applied on this port (see below) |
ID |
long | Database identifier (read-only) |
PortNumber |
long | The TCP port |
Protocol |
eSessionType | Which protocol answers on this port (see below) |
SSLCertificateID |
long | ID of the certificate in SSLCertificates used on this port |
UseSSL |
bool | Older on/off TLS switch, superseded by ConnectionSecurity; kept for script compatibility |
eConnectionSecurity
| Value | Meaning |
|---|---|
0 |
None — plaintext |
1 |
SSL/TLS — encrypted from the first byte (implicit TLS, as on 993/995/465) |
2 |
STARTTLS optional — starts plaintext, upgrade offered |
3 |
STARTTLS required — commands refused until the session upgrades |
eSessionType
| Value | Protocol |
|---|---|
1 |
SMTP |
3 |
POP3 |
5 |
IMAP |
The gaps are the server’s own client sessions, 2 for its SMTP client and 4 for its
POP3 client, which no listener takes; the same values are used by
Status.SessionCount.
The “SSL” in these names is historical. Whatever a property is called, the versions actually negotiated are those configured on the Protocol versions tab of Certificates & encryption → SSL/TLS — TLS 1.2/1.3 by default, and chapter 9’s advice stands: leave the older protocols off.
TCPIPPorts follows the standard collection shape, plus:
| Member | Does |
|---|---|
SetDefault() |
Replaces the collection with the classic default listeners — SMTP on 25 and 587, POP3 on 110, IMAP on 143 — with no TLS and no certificate (the Restore defaults button of Connections & protocols → TCP/IP ports does the same). It does not recreate what a new 6.3.4 installation is given, STARTTLS optional with the self-signed certificate, and where the Internet IP range requires TLS for authentication nobody outside this computer can then sign in, so set each port’s connection security and certificate again afterwards |
SSLCertificate and SSLCertificates
The certificate store (Certificates & encryption → SSL certificates,
§9.2): each entry names a PEM certificate file and its private key, ready to be
assigned to ports via TCPIPPort.SSLCertificateID. The built-in ACME client (§9.1)
needs no entry from you: it creates and updates its own entry here and assigns it to the TLS
ports that have no certificate and, since 6.3.4, to those still holding the self-signed
certificate a new installation made (listed as Self-signed (made at installation)).
SSLCertificate properties (plus Save() and Delete())
| Property | Type | Description |
|---|---|---|
CertificateFile |
string | Path to the PEM certificate, which must hold the leaf certificate followed by its issuer chain |
ID |
long | Database identifier (read-only) |
Name |
string | Display name |
PrivateKeyFile |
string | Path to the PEM private key |
PrivateKeyPassword |
string | The passphrase of an encrypted private key, per certificate — write-only; it cannot be read back, and without it the TLS ports using an encrypted key do not start |
SSLCertificates follows the standard collection shape, plus:
| Member | Does |
|---|---|
Clear() |
Deletes every certificate entry — TLS ports referencing them stop working, so treat with care |
IncomingRelay and IncomingRelays
The trusted-forwarder list (Mail flow & delivery → Incoming relays, §14.3).
When mail arrives from an IP inside one of these ranges — a front-end spam filter,
say — hMailServer takes the originating IP from the Received headers instead, so
spam tests judge the real sender rather than your own infrastructure.
IncomingRelay properties (plus Save() and Delete())
| Property | Type | Description |
|---|---|---|
ID |
long | Database identifier (read-only) |
LowerIP |
string | Bottom of the trusted range |
Name |
string | Display name |
UpperIP |
string | Top of the trusted range |
IncomingRelays follows the standard collection shape, plus:
| Member | Does |
|---|---|
Delete(index) |
Deletes the relay at a zero-based position |
ItemByName(name) |
Returns the relay with a given name (read-only) |
Route and Routes
A Route overrides delivery for one domain (Mail flow & delivery → Routes, §14.2):
mail for DomainName goes to TargetSMTPHost instead of wherever DNS points. The
route can carry its own credentials, its own retry schedule, and its own idea of which
recipient addresses it covers.
Methods
| Method | Does |
|---|---|
Delete() |
Deletes the route |
Save() |
Saves changes |
SetRelayerAuthPassword(password) |
Sets the password used to authenticate to the target server — write-only; it cannot be read back |
Properties
| Property | Type | Description |
|---|---|---|
Addresses |
RouteAddresses | The recipient addresses this route covers, when AllAddresses is off (read-only) |
AllAddresses |
bool | Route every address in the domain, rather than only the listed ones |
ConnectionSecurity |
eConnectionSecurity | How TLS is applied on the connection to the target server — same values as under TCPIPPort |
Description |
string | Free-text note |
DomainName |
string | The domain whose mail is rerouted; wildcards such as *.partner.example are matched, and the first matching route in name order wins |
ID |
long | Database identifier (read-only) |
MinutesBetweenTry |
long | Retry interval for this route |
NumberOfTries |
long | Delivery attempts before giving up |
RelayerAuthUsername |
string | User name for authenticating to the target server |
RelayerRequiresAuth |
bool | The target server demands authentication |
TargetSMTPHost |
string | The server to hand the mail to |
TargetSMTPPort |
long | The port on the target server |
TreatRecipientAsLocalDomain |
bool | Count recipient addresses in this domain as local when IP-range rules are evaluated (see SecurityRange) |
TreatSecurityAsLocalDomain |
bool | Obsolete alias of TreatRecipientAsLocalDomain; kept for script compatibility |
TreatSenderAsLocalDomain |
bool | Count sender addresses in this domain as local when IP-range rules are evaluated |
UseSSL |
bool | Older on/off TLS switch, superseded by ConnectionSecurity; kept for script compatibility |
Routes follows the standard collection shape, plus ItemByName(domainName)
(read-only), which looks a route up by the domain it serves. The policies of
Mail flow & delivery → Remote domains (one per remote domain or pattern), new in 6.3.4,
are a separate collection, Settings.RemoteDomainPolicies.
Changed in 6.3.4. The running server reads routes, route addresses, incoming relays and SSL certificates while it works, so a property set on one of them is now written on your object’s own copy and is in effect only after
Save(); up to 6.3.3 it changed the running server at once, and an unsaved change lasted until the next restart. An object read before a save keeps the values it read: read the route again (ItemByName,ItemByDBID) to see the saved ones, with itsAddresses. Addresses added or deleted throughRoute.Addressesare in effect at once, as before, and a save whose re-read fails is reported asHM7390and is in effect at the next reinitialise, restart or save of that collection.
RouteAddress and RouteAddresses
When a route does not cover the whole domain, RouteAddresses lists exactly which
recipients it applies to. Each RouteAddress is one recipient address.
RouteAddress properties (plus Save() and Delete())
| Property | Type | Description |
|---|---|---|
Address |
string | The recipient address |
ID |
long | Database identifier (read-only) |
RouteID |
long | The route the address belongs to |
RouteAddresses members
| Member | Kind | Does |
|---|---|---|
Count |
property (read-only) | Number of addresses |
Item(index) |
property (read-only) | The address at a zero-based position |
ItemByDBID(dbid) |
property (read-only) | The address with a given database identifier |
Add() |
method | Creates a new, unsaved address |
DeleteByAddress(address) |
method | Deletes an entry by its address text |
DeleteByDBID(dbid) |
method | Deletes an entry by database identifier |
BackupSettings
What the built-in backup includes and where it goes — the object behind
Maintenance → Backup & restore (chapter 15). Reached as Application.Settings.Backup;
being a settings object, assignments persist immediately.
Properties
| Property | Type | Description |
|---|---|---|
BackupDomains |
bool | Include domains, accounts, aliases and lists |
BackupMessages |
bool | Include the message files themselves |
BackupSettings |
bool | Include server-wide settings |
CompressDestinationFiles |
bool | Put the message files into each run’s archive. Off, they are copied to one DataBackup folder beside the archives that every run overwrites, so there is only ever one generation of mail; since 6.3.4 an encrypted backup of messages is refused without it |
Destination |
string | Directory the backup is written to. Since 6.3.4, while the service runs as NT SERVICE\hMailServer (the account a new installation is given), a folder outside the program folder’s own Data, Logs, Temp and Database must first be approved for the service, as chapter 15 says, or the backup cannot be written there |
EncryptionPassphrase |
string | New in 6.3.4. Write-only. Once set, every archive is encrypted as it is written (7-Zip AES-256, headers included); 12 to 256 printable ASCII characters, and an empty value goes back to unencrypted archives. It is never written into an archive or read back, and an archive whose passphrase is lost cannot be restored by anybody |
EncryptionPassphraseSet |
bool | New in 6.3.4. Whether a passphrase is set (read-only) |
LogFile |
string | Path of the backup log (read-only) |
BackupMessagesis the flag people forget. A backup without it contains your configuration but not your mail — the exact trap chapter 15 warns about. It also needsBackupDomains: messages without domains stage nothing and the run fails.
BackupManager
The object that runs backups and restores. StartBackup() takes what BackupSettings
describes; LoadBackup() opens an existing backup file and hands you a Backup object
to restore from.
Methods
| Method | Does |
|---|---|
DescribeMailbox(file, address, passphrase) |
New in 6.3.4. One account’s folders and messages as an archive in the backup destination holds them |
LoadBackup(file) |
Opens a backup archive (HMBackup date time.7z) and returns it as a Backup object. An archive that is truncated, not a backup or written by a newer version raises an error instead |
LoadEncryptedBackup(file, passphrase) |
New in 6.3.4. The same, for an archive written with an encryption passphrase; a missing or wrong passphrase is refused before anything on the server is touched |
MailboxRestoreStatus(job) |
New in 6.3.4. How a mailbox or message restore went: folders created, messages added, skipped and failed |
RestoreMailbox(file, address, mode, passphrase) |
New in 6.3.4. Restores one account’s mailbox, and nothing else, from an archive in the backup destination taken with domains and messages: merge adds what the live mailbox lacks, replace empties its folders first, so mail that arrived since the backup is gone (kept only for RecoverDeletedDays, which is off by default) |
RestoreMessage(file, address, id, folder, passphrase) |
New in 6.3.4. Restores one message of that mailbox, into the folder it was in or the one named |
StartBackup() |
Starts a backup using the current BackupSettings |
StartBackup() returns once the backup is under way; the outcome is written to the
log file named by BackupSettings.LogFile. Since 6.2.25 Maintenance → Backup & restore
can also run backups on a schedule of its own, with retention; a nightly scheduled task of your own
reduces to:
$app = New-Object -ComObject 'hMailServer.Application'
$app.Authenticate('Administrator', 'your-admin-password') | Out-Null
$cfg = $app.Settings.Backup
$cfg.Destination = 'D:\Backups\hMailServer'
$cfg.BackupDomains = $true
$cfg.BackupMessages = $true
$cfg.BackupSettings = $true
$cfg.CompressDestinationFiles = $true # off, every run overwrites the one copy of the mail
$app.BackupManager.StartBackup()
Backup
One loaded backup file. The Contains* flags report what is in the file; the
Restore* flags choose what to bring back; StartRestore() does it.
Methods
| Method | Does |
|---|---|
StartRestore() |
Starts restoring the selected parts of this backup |
Properties
| Property | Type | Description |
|---|---|---|
ContainsDomains |
bool | The file includes domain data (read-only) |
ContainsMessages |
bool | The file includes messages (read-only) |
ContainsSettings |
bool | The file includes server settings (read-only) |
RestoreDomains |
bool | Restore domain data |
RestoreMessages |
bool | Restore messages |
RestoreSettings |
bool | Restore server settings |
$latest = Get-ChildItem 'D:\Backups\hMailServer' -Filter 'HMBackup *.7z' |
Sort-Object LastWriteTime | Select-Object -Last 1
$backup = $app.BackupManager.LoadBackup($latest.FullName)
$backup.RestoreDomains = $true
$backup.RestoreMessages = $true
$backup.RestoreSettings = $true
$backup.StartRestore()
A whole restore deletes every domain, account and public folder, and the whole message store, before it puts the archive’s back, with no transaction, and nothing can undo it, so rehearse it on a scratch server first. Chapter 15’s DPAPI caveat is about copying the database, not this restore: route, fetch-account and certificate-key passwords, and since 6.3.4 the second factors and the
[Settings]secrets, travel in the archive in a portable form and are protected again on the new machine. The per-domain relay settings (host, port, user name, password and whether authentication is required) are in no archive and must be re-entered on the domain’s dialog, and no archive written by 6.3.4 or later carriesPasswordPepper: if one was set, copy it into the new machine’shMailServer.INIfirst, or no account whose password is an Argon2id or scrypt hash can sign in.
More: Backup and restore: what is in the archive, what is not, and restoring one mailbox on the forum.
Database
Information about the configured database, and a small toolkit for running SQL against
it. It is also the object the installer and DBUpdater drive when creating and
upgrading databases (chapter 18). Since 6.3.4, while the service runs as
NT SERVICE\hMailServer (the account a new installation is given), CreateInternalDatabase,
CreateExternalDatabase and SetDefaultDatabase are refused before anything is made,
because they write [Database] into hMailServer.INI, which that account may not write. The
installer does its database step while the service is still LocalSystem; to change the database later, move
the service to LocalSystem for the change and back, or edit [Database] and restart the service.
Methods
| Method | Does |
|---|---|
BeginTransaction() |
Opens a transaction — affects ExecuteSQL() calls only |
CommitTransaction() |
Commits it |
RollbackTransaction() |
Rolls it back |
CreateExternalDatabase(type, server, port, name, user, password) |
Creates a new external database of the given type |
CreateInternalDatabase() |
Creates the built-in database |
EnsurePrerequisites(dbVersion) |
Ensures the prerequisites for a given schema version are fulfilled — a DBUpdater helper |
ExecuteSQL(statement) |
Runs one SQL statement |
ExecuteSQLScript(filename) |
Runs a file of SQL statements |
ExecuteSQLWithReturn(statement) |
Runs one SQL statement and returns the unique ID it generated |
SetDefaultDatabase(type, server, port, name, user, password) |
Points the server at a database |
UtilGetFileNameByMessageID(messageID) |
Returns the on-disk path of a message’s .eml file, given its database ID. Since 6.3.4, with MessageStoreCompression on, that file can be a compressed container rather than the message as it arrived: read the message through the API instead |
Properties (all read-only)
| Property | Type | Description |
|---|---|---|
CurrentVersion |
long | Schema version of the connected database |
DatabaseExists |
bool | Whether the configured database exists |
DatabaseName |
string | Its name |
DatabaseType |
eDBtype | Which engine — the built-in database, MySQL/MariaDB, MS SQL or PostgreSQL |
IsConnected |
bool | Whether the server currently has a working connection |
RequiredVersion |
long | Schema version this build expects (6108 in 6.3.6 to 6.3.8; 6105 in 6.3.5, 6104 in 6.3.4, 6040 in 6.3.3, 6038 in 6.3.2, 6031 in 6.2.27–6.3.1, 6030 in 6.2.25–6.2.26, 6025 in 6.2.24, 6011 in 6.2.19–6.2.21, 6005 before that — chapter 18) |
RequiresUpgrade |
bool | The schema is older than this build expects |
ServerName |
string | The database server’s host name |
ExecuteSQLbypasses everything. Writing to the database directly skips validation, business logic and the object cache — the server can serve stale data until the cache expires. If you must write raw SQL, call$app.Settings.Cache.Clear()afterwards, and prefer the proper COM objects whenever one exists.
DeliveryQueue
The outbound queue you see on Monitoring & troubleshooting → Delivery queue (§16.1). The message
identifiers its methods take come from Status.UndeliveredMessages.
Methods
| Method | Does |
|---|---|
Clear() |
Empties the queue — every queued message is discarded, not delivered |
Remove(messageID) |
Removes one message from the queue |
ResetDeliveryTime(messageID) |
Makes one message due for delivery immediately |
StartDelivery() |
Wakes the deliverer to look for due messages now |
The classic operation — “resend everything that is stuck, now”:
$queue = $app.GlobalObjects.DeliveryQueue
foreach ($line in $app.Status.UndeliveredMessages -split "`n") {
if ($line.Trim()) {
$fields = $line -split "`t"
$queue.ResetDeliveryTime([long]$fields[0])
}
}
$queue.StartDelivery()
Clear()deletes queued mail outright. If the queue is full of legitimate messages stuck behind a DNS or smart-host problem, fix the cause and resend — do not clear.
Directories
The directories the server is using, as configured in hMailServer.INI. The defaults
are listed in §19.7. A changed directory takes effect when the service next
starts. Since 6.3.4, while the service runs as NT SERVICE\hMailServer (the account a new
installation is given), setting one over COM is refused with a sentence, because that account may not
write hMailServer.INI: an administrator changes the file and approves the new folder for the
service (hMailServer.exe --confirm /GrantServiceAccess from an elevated prompt, or Grant
access on the Windows service account card of Maintenance → Server limits & expert
settings).
Properties
| Property | Type | Description |
|---|---|---|
DatabaseDirectory |
string | Where the built-in database lives |
DataDirectory |
string | Where messages are stored |
DBScriptDirectory |
string | Where the schema SQL scripts live (read-only) |
EventDirectory |
string | Where the event-script file lives |
LogDirectory |
string | Where logs are written |
ProgramDirectory |
string | The installation directory |
TempDirectory |
string | Scratch space for messages in transit |
EventLog
A single-purpose object available to event scripts (chapter 23) as the global
EventLog: it writes a line to the server’s event log, which lands in the file named
by Logging.CurrentEventLog and in the live log view. Since 6.3.4 the JavaScript engine of the
Linux builds has it too, written in full as EventLog.Write(text).
Methods
| Method | Does |
|---|---|
Write(message) |
Appends a message to the event log |
Sub OnClientConnect(oClient)
EventLog.Write("Connection from " & oClient.IPAddress & " on port " & oClient.Port)
End Sub
Logging
The logging configuration behind Monitoring & troubleshooting → Logging (§16.2), plus the live log the Control Panel’s Monitoring & troubleshooting → Live logs page drains.
Methods
| Method | Does |
|---|---|
EnableLiveLogging(enabled) |
Starts or stops capturing the live log buffer |
Properties
| Property | Type | Description |
|---|---|---|
AWStatsEnabled |
bool | Also write an AWStats-compatible log |
CurrentAwstatsLog |
string | Path of the current AWStats log (read-only) |
CurrentDefaultLog |
string | Path of the current main log (read-only) |
CurrentErrorLog |
string | Path of the current error log (read-only) |
CurrentEventLog |
string | Path of the current event log (read-only) |
Device |
eLogDevice | Where log output is sent |
Directory |
string | The log directory (read-only) |
Enabled |
bool | Master switch for logging |
KeepFilesOpen |
bool | Hold log files open between writes rather than reopening each time |
LiveLog |
string | Drains and returns the captured live-log output (read-only) |
LiveLoggingEnabled |
bool | Whether the live-log buffer is currently capturing (read-only) |
LogApplication |
bool | Log server events |
LogDebug |
bool | Log debug detail |
LogFormat |
eLogOutputFormat | On-disk log format |
LogSMTP |
bool | Log SMTP conversations |
LogIMAP |
bool | Log IMAP conversations |
LogPOP3 |
bool | Log POP3 conversations |
LogTCPIP |
bool | Log connection-level activity |
MaskPasswordsInLog |
bool | Obsolete — passwords are now always masked in the logs; kept for script compatibility |
SyslogEnabled, SyslogHost, SyslogPort, SyslogTransport, SyslogFacility, SyslogMinimumSeverity, SyslogLogSMTP, SyslogLogPOP3, SyslogLogIMAP, SyslogLogApplication, SyslogLogTCPIP, SyslogLogDebug |
various | New in 6.3.4. A copy of the log entries, as RFC 5424, to a syslog collector: off by default, port 514, transport 0 UDP (the default), 1 TCP or 2 TCP with TLS, facility 2 (mail), minimum severity 6 (informational; less severe entries are not sent), and one switch per category. Errors are always sent. An empty SyslogHost is not off: on Linux under systemd it selects the journal, and on Windows nothing is sent. Saved at once; the running sink takes them at the next reinitialise or service start |
The chapter 16 warning applies from scripts as much as from the GUI:
LogDebugis for diagnosis, not for leaving on. JSON log output is the[Settings]keyJsonLogging(chapter 21), which since 6.3.4 is kept in the database and applies when saved: set it with$app.Settings.SetIniSetting('JsonLogging', '1'), not by editinghMailServer.INI, where a changed line is reported asHM5804and, where the service may write the file, put back.
Status
Uptime and counters — the numbers behind Monitoring & troubleshooting → Server status and the Dashboard (chapter 16). The counters accumulate from service start and reset when it restarts; for long-term monitoring, scrape the Prometheus endpoint instead (§16.3).
Properties (all read-only)
| Property | Type | Description |
|---|---|---|
ProcessedMessages |
long | Messages delivered since the service started |
RemovedSpamMessages |
long | Messages the spam tests deleted |
RemovedViruses |
long | Infected messages or attachments removed |
SessionCount(sessionType) |
long | Current sessions for a protocol — eSessionType: 1 SMTP, 3 POP3, 5 IMAP |
StartTime |
string | When the service started |
ThreadID |
long | The current thread number — of diagnostic interest only |
UndeliveredMessages |
string | The delivery queue as text — one message per line, tab-separated fields, the first field being the identifier DeliveryQueue methods take |
$status = $app.Status
'Started: {0}' -f $status.StartTime
'Processed: {0}' -f $status.ProcessedMessages
'Spam removed: {0}' -f $status.RemovedSpamMessages
'Viruses: {0}' -f $status.RemovedViruses
'SMTP sessions: {0}' -f $status.SessionCount(1)
'IMAP sessions: {0}' -f $status.SessionCount(5)
Utilities
A grab-bag of helper functions. Several are what Control Panel pages call
under the bonnet: ResolveMXRecords powers Monitoring & troubleshooting → MX query — since 6.2.21
it asks the server’s own resolver, honouring a configured custom DNS server, rather
than the operating system’s — and EmailAllAccounts powers
Mail flow & delivery → Server sendout.
Methods
| Method | Does |
|---|---|
BlowfishEncrypt(input) / BlowfishDecrypt(input) |
The legacy reversible encoding for stored secrets — kept for script compatibility |
CheckSieveSyntax(script) |
New in 6.x. Checks a Sieve (RFC 5228) script — returns an empty string when valid, otherwise the error |
CriteriaMatch(matchValue, matchType, testValue) |
Tests whether a rule criteria matches a value, using the same matching as message rules |
EmailAllAccounts(recipientWildcard, fromAddress, fromName, subject, body) |
Sends a message to every account matching the wildcard |
EvaluateSieveScript(script, rawMessage) |
New in 6.x. Runs a Sieve script against a raw message and returns the resulting action summary — for testing scripts before deploying them |
GenerateGUID() |
Returns a new GUID |
GetMailServer(emailAddress) |
Resolves which server receives mail for an address (an MX lookup) |
ImportMessageFromFile(filename, accountID) |
Imports a raw .eml file — with an account ID it lands in that account’s inbox; with 0 it is delivered to the message’s recipients. The service reads the file, so since 6.3.4, while it runs as NT SERVICE\hMailServer, the file must be somewhere that account may read |
ImportMessageFromFileToIMAPFolder(filename, accountID, imapFolder) |
As above, but into a named IMAP folder of the account |
IsLocalHost(hostname) |
Whether a host name points at this machine |
IsStrongPassword(username, password) |
Whether a password passes the server’s strength rules |
IsValidDomainName(domainName) |
Whether a string is a well-formed domain name |
IsValidEmailAddress(emailAddress) |
Whether a string is a well-formed address |
IsValidIPAddress(ipAddress) |
Whether a string parses as an IP address |
MakeDependent(otherService) |
Adds a Windows service dependency, so hMailServer starts only after the named service — useful when your database engine runs on the same machine. Since 6.3.4 it is refused, with a sentence saying why, while the service runs as NT SERVICE\hMailServer, the account a new installation is given; an administrator sets the dependency instead, from an elevated prompt: sc.exe config hMailServer depend= RPCSS/service |
MD5(input) / SHA256(input) |
Hex digest of a string. Since 6.3.4 it is the digest of the string’s UTF-8, so for text outside ASCII it can differ from what 6.3.3 answered |
PerformMaintenance(operation) |
Runs a database maintenance operation — eMaintenanceOperation: 1 updates IMAP folder UIDs to their current values |
RetrieveMessageID(filename) |
Returns the database ID of a message file — the inverse of Database.UtilGetFileNameByMessageID |
ResolveMXRecords(domainName) |
New in 6.2.21. Resolves a domain’s MX records through the server’s own resolver — one hostname<TAB>ip line per delivery target, in the order delivery would try them. Empty when the domain has no mail servers; an error when the lookup itself failed — the distinction that matters when your own DNS server is unreachable |
RunTestSuite(testPassword) |
Runs the internal test suite — destructive, see below |
Never call
RunTestSuiteon a production server. It deletes every object on the server as part of its run. It exists for development against a scratch installation.
New in 6.3.4. Utilities gains the scripted side of that release’s features, among
them the audit trail (GetAuditTrail, VerifyAuditTrail, RevertAuditedChange),
the suppression list and bounces (GetSuppressionList, AddSuppression,
RemoveSuppression, GetBounceCounts, ClassifyBounce), journal rules,
spam-trap hits, the reputation check, DKIM rotation, webhook subscriptions, OpenPGP keys, the setup
checklist (GetSetupChecklist), the sample domain, mailbox moves (MoveMailbox,
GetMailboxMoves) and BackupArchiveIsEncrypted. Every one is appended, so a script
or an early-bound client written for 6.3.3 keeps working.
From 6.3.6 the helpers that do work answer a caller that has signed in.
BlowfishEncrypt, BlowfishDecrypt, ResolveMXRecords, GetMailServer,
IsLocalHost, CheckSieveSyntax, EvaluateSieveScript and CriteriaMatch
refuse a COM client that has not called Authenticate, with You do not have access to this property /
method, whether it reached them through Application.Utilities or created
hMailServer.Utilities itself. They answer an account of any level or the administrator that has signed in,
and an event script running in the service, which needs no change. MD5, SHA256,
GenerateGUID, IsStrongPassword, IsValidEmailAddress,
IsValidIPAddress and IsValidDomainName stay open to any caller, and a server with no
administrator password and no second factor answers every caller as it did. A program or script outside the service
creates hMailServer.Application, calls Authenticate and uses that object’s
Utilities: a Utilities created on its own has no way to sign in.
The MD5 and Blowfish helpers are historical. No new account password is stored with
them — it is hashed with PBKDF2-HMAC-SHA256 by default, or with SHA-256, Argon2id or scrypt as
PreferredHashAlgorithm chooses (§17.2), and an older, weaker hash is upgraded at the account’s next successful logon — but old scripts
that call these helpers keep working, signed in from 6.3.6 (above).
ImportMessageFromFile is the workhorse for migrations: exported .eml files from
another server can be dropped straight into mailboxes, or handed to the deliverer. For
mbox exports, and since 6.2.27 a Maildir, there is no need to script it yourself — the Import Tool (§33.8, new
in 6.2.12) wraps ImportMessageFromFileToIMAPFolder in a supported workflow.
Diagnostics, DiagnosticResults and DiagnosticResult
New in 6.x. The scripted face of the server’s self-tests: point Diagnostics at a
domain you host and a remote domain to test against, call PerformTests(), and read
the outcome per test from the returned collection. Reached as
Application.Diagnostics. Since 6.3.4 the same tests also run over REST, as
POST /api/v1/diagnostics/run with the two domain names, which answers each result’s name,
description, success and details; on Linux, which has no COM, that is the way to script them.
Diagnostics members
| Member | Kind | Does |
|---|---|---|
LocalDomainName |
property (string) | A domain hosted on this server to run the tests on |
TestDomainName |
property (string) | A remote domain to test outbound connectivity against |
PerformTests() |
method | Runs the diagnostics and returns a DiagnosticResults collection |
DnssecChainStatus(name, type) |
method | The server’s DNSSEC verdict for one name: 0 secure, 1 insecure, 2 bogus (since 6.2.27) |
DiagnosticResult properties (all read-only)
| Property | Type | Description |
|---|---|---|
Description |
string | What the test checks |
ExecutionDetails |
string | The test’s output — what it did and what it saw |
Name |
string | The test’s name |
Result |
bool | Whether the test passed |
DiagnosticResults is read-only: Count and Item(index) only.
$diag = $app.Diagnostics
$diag.LocalDomainName = 'example.com'
$diag.TestDomainName = 'gmail.com'
$results = $diag.PerformTests()
for ($i = 0; $i -lt $results.Count; $i++) {
$r = $results.Item($i)
'{0}: {1}' -f $r.Name, $(if ($r.Result) { 'OK' } else { 'FAILED' })
}
Scripting
The event-script configuration behind two Control Panel pages (chapter 23): the
Scripting tab of Maintenance → Advanced, which holds the switch and the language,
and Maintenance → Event scripts, which edits the file. The script file itself lives in Directories.EventDirectory; if you
edit it on disk rather than through the Control Panel, call Reload() or the running
server will keep the old version.
Methods
| Method | Does |
|---|---|
CheckSyntax() |
Compiles the current script and returns the result — an empty result means it compiled cleanly |
Reload() |
Re-reads the script file from the event directory |
Properties
| Property | Type | Description |
|---|---|---|
CurrentScriptFile |
string | Path of the script file in use (read-only) |
Directory |
string | The event-script directory (read-only) |
Enabled |
bool | Event scripts run |
Language |
string | VBScript or JScript, the two a Windows server runs. Since 6.3.4 it also takes
JavaScript, the language of the Linux builds, and accepts all three on either platform (any other
word is refused and nothing is stored); a language the build does not run is refused when the script is
loaded, as HM5016 at a start or HM5710 at a reload, which keeps the earlier script.
Storing the language loads nothing: the script in force keeps its language until the next load, so call
Reload() after changing it (on Linux, which has no COM,
POST /api/v1/settings/scripting/reload) |
ServerMessage and ServerMessages
The customisable texts the server sends — bounce messages and other system mail — as
edited on Mail flow & delivery → Server messages. The set of messages is fixed:
you edit their text, but you cannot add or remove entries, and the collection’s shape
reflects that. Since 6.3.4 a text set over COM is written on the object’s own copy and is in
effect only at Save(): one never saved changes nothing, where up to 6.3.3 it changed the
running server at once. An object read from the collection before the save keeps what it read, so read
the message again (ItemByName, ItemByDBID) to see the saved text.
ServerMessage properties (plus Save())
| Property | Type | Description |
|---|---|---|
ID |
long | Database identifier (read-only) |
Name |
string | Which message this is |
Text |
string | The text sent |
ServerMessages members
| Member | Kind | Does |
|---|---|---|
Count |
property (read-only) | Number of messages |
Item(index) |
property (read-only) | The message at a zero-based position |
ItemByDBID(dbid) |
property (read-only) | The message with a given database identifier |
ItemByName(name) |
property (read-only) | The message with a given name |
Refresh() |
method | Re-reads the collection |
Client
A temporary object handed to event handlers (chapter 23) describing the connected client. It only exists inside an event; there is no way to reach it from an external script. Since 6.3.4 a JavaScript handler on Linux is handed the same object, with the same ten properties, all read-only.
Properties (all read-only)
| Property | Type | Description |
|---|---|---|
Authenticated |
bool | Whether the client has logged on |
CipherBits |
long | Key bits of the negotiated TLS cipher |
CipherName |
string | Name of the negotiated TLS cipher |
CipherVersion |
string | TLS version of the connection |
EncryptedConnection |
bool | Whether the connection is TLS-encrypted |
HELO |
string | The host name the client gave in HELO/EHLO |
IPAddress |
string | The connecting client’s IP address |
Port |
long | The local port the client connected to |
SessionID |
long | The identifier assigned to this TCP/IP session — the session number you see in the logs |
Username |
string | The user name the client logged on, or tried to log on, with. In OnClientLogon, which
fires after every logon attempt, successful or not, it names the account that was tried;
Authenticated says whether the logon succeeded |
Cache
hMailServer caches domains, accounts, aliases and distribution lists in memory to spare the database. This object — behind the Cache tab of Maintenance → Performance — tunes the time-to-live and size cap per object type and reports how often the cache is hit. A size cap set here applies at once but is not kept: every restart puts the four caps back to 10240 KB.
Methods
| Method | Does |
|---|---|
Clear() |
Empties the cache and resets the hit-rate statistics — call it after writing to the database directly. Since 6.3.4 it also drops the cached setting levels of domains and accounts, so a level row written straight into the database is in effect at once rather than within sixty seconds |
Properties
| Property | Type | Description |
|---|---|---|
AccountCacheMaxSizeKb |
long | Cap on the account cache, in KB |
AccountCacheSizeKb |
long | Current size of the account cache, in KB (read-only) |
AccountCacheTTL |
long | Seconds an account stays cached |
AccountHitRate |
long | Account cache hit rate (read-only) |
AliasCacheMaxSizeKb |
long | Cap on the alias cache, in KB |
AliasCacheSizeKb |
long | Current size of the alias cache, in KB (read-only) |
AliasCacheTTL |
long | Seconds an alias stays cached |
AliasHitRate |
long | Alias cache hit rate (read-only) |
DistributionListCacheMaxSizeKb |
long | Cap on the distribution-list cache, in KB |
DistributionListCacheSizeKb |
long | Current size of the distribution-list cache, in KB (read-only) |
DistributionListCacheTTL |
long | Seconds a distribution list stays cached |
DistributionListHitRate |
long | Distribution list cache hit rate (read-only) |
DomainCacheMaxSizeKb |
long | Cap on the domain cache, in KB |
DomainCacheSizeKb |
long | Current size of the domain cache, in KB (read-only) |
DomainCacheTTL |
long | Seconds a domain stays cached |
DomainHitRate |
long | Domain cache hit rate (read-only) |
Enabled |
bool | Caching is on |
The
Cachegetters are among the methods hardened in 6.2.10: called without authentication they now fail instead of returning success and uninitialized memory.
MessageIndexing
New in 6.x. The message index: when enabled, the server keeps an index of the messages’
headers in the database (hm_message_metadata), built in passes. On its own it does not index
message bodies: the full-text term index that narrows which messages IMAP SEARCH BODY and
TEXT have to read is IndexerFullText, on the Indexing tab of
Maintenance → Performance, off by default and indexing nothing unless this switch is on
as well. Reached as Application.Settings.MessageIndexing; a settings
object, so assignments persist immediately.
Methods
| Method | Does |
|---|---|
Clear() |
Clears the message-indexing cache — the index is rebuilt on the next run |
Index() |
Triggers an indexing run now, if one is not already in progress |
Properties
| Property | Type | Description |
|---|---|---|
Enabled |
bool | Message indexing is on |
TotalIndexedCount |
long | Messages indexed so far, across the installation (read-only) |
TotalMessageCount |
long | Total messages in the installation (read-only) |
Comparing the two counters tells you how far along a rebuild is.
Language and Languages
The interface translations shipped with the server. Languages is a read-only
collection; each Language can translate an English source string into that language.
Their main consumer was the classic hMailServer Administrator, which the fork retired
in 6.2.0 (source removed in 6.2.10) in favour of the Control Panel; the objects remain
in the COM API for script compatibility.
Language members
| Member | Kind | Does |
|---|---|---|
IsDownloaded |
property (read-only) | Whether the language has been downloaded |
Name |
property (read-only) | The language’s name |
String(englishString) |
property (read-only) | The translation of an English source string |
Download() |
method | Not implemented — it fetched translations from a download service on hmailserver.com, which this fork does not use |
Languages members (all read-only)
| Member | Does |
|---|---|
Count |
Number of languages |
Item(index) |
The language at a zero-based position |
ItemByName(name) |
The language with a given name |
32. Error messages explained
This chapter is a lookup reference. When a log line, a bounce message or a mail client hands you an exact error text, find it here and you will get the cause and the fix. Chapter 19 is the first-line checklist — “mail doesn’t arrive, where do I start?”. Come here once you have a specific message in front of you.
Errors surface in three places:
| Where | What you see |
|---|---|
Monitoring & troubleshooting → Live logs (in a Control Panel running on the server itself) and the Logs directory (/var/log/hmailserver on Linux) |
Everything, as it happens. The error log carries the detail |
| Monitoring & troubleshooting → Delivery queue | The last error a queued message received from the remote server |
| Bounce messages | What the sender is told when delivery fails permanently |
All console paths in this chapter refer to the hMailServer Control Panel, which is a Windows application. On Linux, the Control Deck, the browser administration page the REST listener serves, has queue and logs views that show the same. The classic hMailServer Administrator was retired in 6.2.0 and the PHP WebAdmin was removed in 6.2.10 — neither is an error surface in 6.3.8.
The texts hMailServer issues can be customised under Mail flow & delivery → Server messages, so an edited installation may word them slightly differently. The reply codes do not change.
32.1 Reading an SMTP reply
Every SMTP reply starts with a three-digit code, and the first digit tells you almost everything:
| First digit | Meaning | What happens next |
|---|---|---|
| 2 | Success | Nothing — carry on |
| 3 | Intermediate — send more | Normal mid-transaction state |
| 4 | Temporary failure | The sending server keeps the message and retries later. No bounce |
| 5 | Permanent failure | The sending server gives up and bounces the message to the sender |
The distinction matters when you diagnose: a 4xx in your delivery queue means wait,
or fix and wait — the message is not lost. A 5xx means the message has already
bounced and resending requires the sender to act.
New in 6.2. On modern (EHLO) sessions hMailServer also appends an enhanced
status code — a dotted triple such as 5.7.1 — after the three-digit code, because it
advertises ENHANCEDSTATUSCODES (chapter 21). Legacy HELO sessions get the classic
replies. For everything in this chapter, the three-digit code and the text are what you
match on.
32.2 Database errors
hMailServer keeps accounts, settings and message metadata in its database (§3.5). When the database misbehaves, the symptoms range from a harmless log line to refused mail. Database errors always land in the error log; the entry immediately before a refusal usually names the real cause.
Connection failures
| Message | Cause | Fix |
|---|---|---|
MySQL server has gone away |
The connection to the database server dropped mid-query — a network blip, or the database server shutting down (typically just before a reboot of its host) | Usually none needed. hMailServer reconnects and re-runs the query automatically; this line is only logged when the retry also fails. Investigate the network path or the database host if it recurs |
Cannot connect to the database at startup (HM4354 in the error log: hMailServer failed to connect to the database server) |
The database service is not running, the connection details in the [Database] section of hMailServer.INI are wrong, or a firewall sits between hMailServer and a remote database host. Since 6.3.4 it can also be [Database] TlsMode: a value other than prefer, require, verify-full or disable is refused (HM7530), verify-full naming a TlsCaFile that is not there is refused (HM7531), and require or verify-full will not connect to a database server that offers no TLS |
Start the database service; verify host, port and credentials; check the firewall; correct TlsMode and restart. Since 6.3.4, hMailServer.exe --check-database (sudo -u hmailserver hmailserver --check-database on Linux) makes one connection with the file’s settings, says whether it is encrypted or why it failed, and changes nothing, so it can run beside the service |
| Connection lost during operation, repeatedly | An unreliable network path to a remote database server | Move the database onto the same machine, or fix the link. A mail server that cannot reach its database cannot accept mail |
Running the database on a separate machine is fine, but the link between the two is now part of your mail server. Treat it with the same seriousness as port 25.
Schema mismatches
The database schema is versioned — 6108 in 6.3.6 to 6.3.8, 6105 in 6.3.5, 6104 in 6.3.4, 6040 in 6.3.3, 6038 in 6.3.2,
6031 from 6.2.27 to 6.3.1, 6030 in 6.2.25 and
6.2.26, 6025 in 6.2.24, 6011 in 6.2.19 to 6.2.21 and 6005 for the 6.2
releases before that — and the installer runs DBUpdater to walk older schemas
forward automatically (chapter 18). On Linux the same work is
hmailserver --upgrade-database, which the package’s post-install step runs
(chapter 35).
If a SQL Server Compact upgrade to schema 6030 reported failure on 6.2.25, it had in fact succeeded: a memory-safety fault inside the OLE DB provider made the verification step give a false verdict and took the service down with it, logged as
HM10045 Unknown error; two seconds later the server’s crash oracle, which treats a memory-safety fault as fatal, ended the service, and service recovery restarted it. Fixed in 6.2.26. Your database is at 6030 with its foreign keys in place, and the 6.2.26 or later installer finds nothing left to upgrade.
| Symptom | Cause | Fix |
|---|---|---|
The database is too old for this version of hMailServer. Please run hMailServer Database updater (DBUpdater.exe) to upgrade it. at startup |
The program files were updated but the schema upgrade did not run or did not finish | Run the installer again and let it upgrade the database, or run DBUpdater.exe directly, or, since 6.3.4, hMailServer.exe --upgrade-database from Bin with the service stopped, which needs neither .NET nor the administrator password; on Linux, sudo -u hmailserver hmailserver --upgrade-database. Since 6.3.4 a service whose binaries were replaced by something other than the installer can do it itself as it starts, with UpgradeOnStart=1 in the [Database] section of hMailServer.INI: off by default, it takes no backup first, and it belongs on one node at a time. On a fresh install this error was a real defect through 6.2.4 — the install scripts stamped schema 6004 — fixed in 6.2.5; install the current release (§19.6) |
Errors after a rollback to an older version: The database is too new for this version of hMailServer. Please upgrade hMailServer. |
An older server refuses a newer schema — the schema only moves forward | Restore the database from the backup you took before upgrading, then reinstall the older version (§18.6). There is no downgrade path without one. If the version you are leaving had sealed its stored secrets (from schema 6103, in 6.3.4 and later), restore the pre-upgrade hMailServer.INI with the database: an older server does not open the sealed values that version wrote into the file |
The upgrade stops at the step from schema 6040 to 6041 with MySQL error 1071, and the database stays at 6040 |
New in 6.3.4, MySQL and MariaDB only. Steps 6041, 6101 and 6102 need index keys that only MySQL 5.7.9 or MariaDB 10.2.2 and later can hold. Each step opens with a probe, so nothing has been changed | Upgrade the database server, then run the schema upgrade again |
Schema step 6102 stops with MySQL error 1062, naming an index and a value |
New in 6.3.4, MySQL and MariaDB only. Step 6102 converts every table to utf8mb4, and two rows the old collation kept apart (straße and strase on MariaDB 11.5 and later, for example) are one value under the new one |
Rename or remove one of the two rows and run the upgrade again: every statement of the step can run twice. The step also needs the ALTER privilege on the database itself, not only on its tables, and it rebuilds every table, so on a large database it takes as long as copying it |
More: Upgrading from 5.x, 6.2 or 6.3: the 24 changes without a switch, and rolling back on the forum.
Timeouts and save failures
| Message | Cause | Fix |
|---|---|---|
451 Your message was received but it could not be saved. Please retry later. (sent to the SMTP client) |
The message arrived over the wire but the database insert failed | Read the error log entries immediately before this line — they contain the underlying database error. The sending server will retry, so nothing is lost yet |
| Queries timing out; the server feels sluggish | The database host is overloaded, or the built-in database is being pushed past its comfortable size | Check CPU, memory and disk on the database host. For a large installation on the built-in database, migrate to MySQL/MariaDB or PostgreSQL (§3.5) |
451 Unable to verify the recipient at the moment. Please retry later. (sent to the SMTP client at RCPT TO) |
The database did not answer the recipient lookup. hMailServer tells a database that cannot answer apart from an address that does not exist, so the mail is deferred rather than refused as 550 Unknown user |
Check the database, and /readyz (§16.3). The sending server retries, so nothing is lost yet |
32.3 DNS errors
On Windows, hMailServer resolves names through the Windows resolver
(DnsQuery), using the DNS servers configured in the machine’s network settings, unless
Connections & protocols → DNS resolver names a server of its own (DNSServer: one IPv4
address, no port; every lookup then bypasses the Windows DNS cache, so leave it empty unless you need a
specific resolver). Since 6.3.4 SPF lookups follow the same setting. When a lookup fails, the Windows error code is written to the error
log.
On Linux it uses its own DNS client, which reads the nameserver list out of
/etc/resolv.conf and falls back to TCP on truncation by itself. It reports the same
verdicts under the same numbers — 9003 for NXDOMAIN, 9501 for no records of that type — so
the table below reads correctly on either platform. One difference worth knowing: it
passes over IPv6 nameservers in /etc/resolv.conf while the file lists an IPv4 one, and up to 6.3.5
always, so a resolver reachable only over IPv6 was not used; from 6.3.6 the first two IPv6 nameservers are asked
when no IPv4 one is listed.
| Code | Windows name | Meaning | Effect on delivery |
|---|---|---|---|
| 1460 | ERROR_TIMEOUT |
The query timed out — the DNS server is rebooting, overloaded or unreachable | Temporary. The message stays in the queue and is retried on the schedule on the Delivery tab of Mail flow & delivery → Delivery of e-mail |
| 9002 | DNS_ERROR_RCODE_SERVER_FAILURE |
The DNS server answered SERVFAIL — it failed internally, or (on a validating resolver) the domain’s DNSSEC signatures are broken | Temporary. Retried as above. If one specific domain always fails, suspect its DNSSEC |
| 9003 | DNS_ERROR_RCODE_NAME_ERROR |
NXDOMAIN — the domain does not exist | Not delivered, but hMailServer answers an MX lookup failure with a class 4 code, so the message is retried and bounces when the retries run out; check the recipient address for typos |
| 9501 | DNS_INFO_NO_RECORDS |
The name exists but has no records of the requested type — for an MX query, the domain publishes no MX records | If nothing else resolves for the domain, as for 9003: retried, then bounced when the retries run out |
When the MX lookup for a recipient domain fails, the message is retried, and the bounce the sender receives once the retries run out says which failure it was: No mail servers appear to exists for the recipient’s address. when DNS answered that the domain publishes nowhere to deliver mail, and Unable to find the recipient’s email server. The DNS query has failed. when the lookup itself failed. Two very different situations produce them:
- The recipient domain really is broken or gone. Verify from a machine outside your network, or with Monitoring & troubleshooting → MX query.
- Your own resolver is broken. The give-away is every outbound message waiting in the queue with the same DNS failure. If Connections & protocols → DNS resolver names a server, it is that server, not the machine’s, that must answer, and the commands below test only the machine’s own resolvers. Windows keeps separate DNS settings per adapter, so a working browser does not prove the service’s lookups work. Test what the machine actually resolves:
Resolve-DnsName -Type MX gmail.com
On Linux, against the nameservers the server itself will read:
cat /etc/resolv.conf
dig +short MX gmail.com
If that does not return Google’s mail servers, fix the machine’s DNS configuration before touching anything in hMailServer.
New in 6.0 — DNSSEC changes the failure modes. With
DnssecValidationEnabled=1(the default), SPF, DKIM, DMARC and DANE lookups against a domain with a bogus DNSSEC chain are treated as failed — deliberately. And with DANE enforcement on, a recipient domain whose TLSA records cannot be validated will have your mail deferred rather than delivered unencrypted (chapter 9). If one domain’s mail suddenly sits in the queue, check whether their DNSSEC broke. From 6.3.6 an MX or TLSA lookup that cannot tell — no answer,SERVFAIL, a signed zone’s answer without its signatures or its proof that no records exist — is indeterminate rather than “unsigned”: the host is not used, and a delivery with no host left is deferred with4.7.0and a reason naming the lookup. So if every domain’s mail sits in the queue after the upgrade, check your own resolver: one that strips DNSSEC signatures, or a firewall that drops DNS answers longer than 512 octets, leaves the server unable to prove most domains unsigned, and the application log says so once in ten minutes (“DNSSEC: the root zone’s keys did not validate”). Make the resolver answer DNSSEC queries, or setDaneEnforcementEnabledto 0 (§18.5).
32.4 SMTP errors hMailServer issues
Everything in this section is sent by your server to a connecting client or server. Grouped by what kind of problem each code family signals.
Temporary refusals (4xx)
| Reply | When it is sent | What to do |
|---|---|---|
421 Connection timeout. |
The session sat idle past the SMTP server’s idle timeout (the Timeouts tab of Connections & protocols → Protocols), so hMailServer closed it | Nothing — a slow or stalled client. Harmless in isolation |
421 Excessive amounts of data sent to server. |
A client sent a very large run of data with no line terminator | A broken or malicious client. Investigate the source IP if it repeats |
421 Too many messages from your IP address. Please slow down and try again later. |
New in 6.2. MaxSubmissionsPerIPPerMinute is set and the source IP exceeded it (§17.5) |
Working as intended — this is what contains a compromised account. Find out which account is submitting |
450 Please try again later. |
Greylisting, for a message with an empty sender. The refusal is deliberately delayed until after DATA so that another server’s recipient-verification callback (which probes RCPT TO with an empty sender) still gets a straight answer |
Nothing. The sender retries and passes |
451 Please try again later. |
Greylisting: the sender/recipient/IP triplet has not been seen before | Nothing — a real server retries, is let through once the initial delay (30 minutes by default) has passed, and is remembered. Exempt time-critical senders via Spam & virus filtering → Greylisting white list; since 6.3.4, Spam & virus filtering → Greylisting bypass list can instead let a named domain through when its SPF, DKIM or DMARC passes |
451 (enhanced code 4.4.3) |
New in 6.3.4, and off until SPFTempErrorDefer is on: the sender’s SPF check could not finish (DNS timed out or answered SERVFAIL, or the lookup limit was reached), so the transaction is deferred rather than scored |
Nothing, usually: the sender retries once DNS answers. If it recurs for one domain, check that domain’s SPF record and your own resolver |
Protocol errors — the client is misbehaving
These indicate a defective client (or a probe), not a server problem:
| Reply | When it is sent |
|---|---|
500 Line too long. |
A command exceeded the permitted length — a client defect or a hacking attempt |
501 EHLO Invalid domain address. / 501 HELO Invalid domain address. |
The host name given in EHLO/HELO is syntactically invalid |
502 TURN disallowed. / 502 VRFY disallowed. |
TURN and VRFY are not supported — both are historic verbs with abuse potential |
503 Bad sequence of commands |
The client issued commands before identifying itself with EHLO/HELO, or used a verb hMailServer does not implement or that is invalid in the current session state |
503 Must have sender first. |
The client sent RCPT TO before MAIL FROM |
503 Must have sender and recipient first. |
The client sent DATA (or BDAT) before completing the envelope |
503 Issue a reset if you want to start over |
The client started a new message mid-transaction without RSET |
504 Authentication type not specified. / 504 Authentication mechanism not supported. |
The client attempted AUTH with no mechanism, or one hMailServer does not offer |
421 Too many invalid commands. Bye! |
Sent just before hMailServer disconnects a client that has had too many commands refused — a probe, or a badly broken client. Only when Disconnect clients sending too many invalid commands is on (off by default; the SMTP tab of Connections & protocols → Protocols) |
Authentication and relay refusals
530 SMTP authentication is required. — the IP range the client connects from
requires authentication, and the client is not authenticating. The right fix is almost
always on the client: configure it to authenticate and to submit on port 587, not
port 25. The defaults are deliberate: connections from localhost are exempt, except when they relay from an
external sender to an external recipient; external
servers delivering to local accounts need not authenticate (otherwise nobody
could send you mail); external clients sending to external recipients always do.
From the Internet range, a message whose envelope sender is at one of this server’s active domains (or a
domain a route treats as local) needs authentication too, whatever the recipient, so an outside server or service
that delivers to a local mailbox in the name of one of your own domains gets this 530 and must authenticate.
Weakening the IP-range requirement instead of fixing the client invites spam through
your server.
530 Must issue STARTTLS first. — the port’s connection security is STARTTLS
Required and the client tried to carry on in plain text. Fix the client: enable
STARTTLS for that port, or point it at the right port.
530 A SSL/TLS-connection is required for authentication. — the IP range the client
connects from requires TLS for authentication (the range’s Require TLS for auth option, on
Sign-in & access → IP ranges), so AUTH is not offered before STARTTLS and is
refused with this reply if it is sent. IMAP answers BAD A SSL/TLS-connection is required for authentication.
and POP3 -ERR. New in 6.3.4, a new installation (not an upgrade) starts with the option on for the
Internet range, so a client on another machine that does not start TLS meets this reply; an upgraded server has it
only where someone set it, for example by accepting the setup checklist’s Passwords only over TLS fix. Clients on
the server itself, and other servers delivering on port 25, are not affected by the new installation’s default. Fix the
client: STARTTLS on 587, 143 or 110. Do not clear the range’s option to make an old client work.
Some routers and firewalls inspect and rewrite SMTP traffic (“SMTP fixup” on older Cisco equipment, ALG features elsewhere). They can mangle the authentication exchange before it reaches hMailServer. If a correctly configured client still cannot authenticate, disable SMTP inspection on the network path.
535 Authentication failed. Restarting authentication process. — wrong username or
password, or the account is disabled. The username is the full address
([email protected]). Since 6.3.4 a password hash stored before the upgrade, for a password
with a character above U+00FF (Cyrillic, Greek or CJK letters, for example), is refused even to the right
password, and the application log names the account: set that password again. ASCII and Latin-1
passwords sign in as before.
Repeated
535s from one IP will trip auto-ban (§17.1): the reply becomes535 Authentication failed. Too many invalid logon attempts.and the IP is then refused at connection. If a user reports being locked out after mistyping a password, that is auto-ban doing its job — the ban expires (by default, three failures ban the address for 60 minutes), or you can lift it early by deleting the expiring range listed under Sign-in & access → IP ranges.
550 Login credentials no longer valid. Please re-authenticate. — one session can
carry many messages, and hMailServer checks the credentials afresh for each of them, so
disabling an account or changing its password bites immediately rather than when the
session eventually closes. The client should reconnect and authenticate again. Before 6.3.4 a
session that signed in without a password (SCRAM-SHA-256, XOAUTH2, OAUTHBEARER,
EXTERNAL or GSSAPI) got this reply at its second message whatever the account’s state; since
6.3.4 its account is checked again instead: still active, in an active domain and not held for review,
among other checks.
550 Delivery is not allowed to this address. — the IP-range rules refused the
recipient: the range the client connects from does not allow this combination of sender and
recipient (each local or external) at all. The two ranges hMailServer ships with allow all four
combinations and require authentication for external-to-external relaying, so an unauthenticated
relay attempt against them gets 530 SMTP authentication is required. (above), not this
reply: a 550 comes from a range that has been changed or added. A sender counts as local
only when its domain is an active domain hosted here, or a route says so. Check Sign-in & access →
IP ranges before changing anything, and re-read §17.1 before you loosen it.
Recipient and sender rejections
| Reply | When it is sent | What to do |
|---|---|---|
550 Unknown user |
The recipient domain is hosted here, but no matching account exists and the domain has no catch-all | Expected for mistyped addresses. If the account should exist, check the spelling in Domains. Since 6.3.4 an address an administrator has declared a spam trap answers with the same words, and the connecting address can then be auto-banned (SpamTrapBanMinutes, 1440 by default; the ban is listed on Sign-in & access → IP ranges) |
550 Account is not active. / 550 Alias is not active. / 550 Distribution list is not active. |
The recipient object exists but is disabled | Re-enable it, or accept that mail to it is refused |
550 Domain has been disabled. |
The whole recipient domain is inactive | Accounts & domains → Domains → the domain’s Properties → General tab: make the domain active again |
550 Not authorized sender. / 550 Not authorized owner. / 550 Not authorized domain. |
The sender is not permitted to post to this distribution list — the text names which permission mode refused | Adjust the list’s sender permissions if they should be |
550 Recipient not in route list. |
The recipient domain matches a route configured to accept only listed addresses, and this address is not listed | Add the address to the route (chapter 14), or loosen the route’s address checking |
550 <address> is on the suppression list of <domain>: … |
New in 6.3.4, and only while bounce or complaint processing is on (both off by default): mail from that sending domain to the address bounced permanently (or drew a complaint, or an administrator listed it), so with the default action, refuse, a submission to it is refused at RCPT TO; a message already queued is returned in a non-delivery report with the same sentence |
Check the address with the sender. If it works again, remove the entry on Monitoring & troubleshooting → Suppression list |
550 A valid address is required. / 550 The address is not valid. |
The recipient or sender address is syntactically invalid | A client defect — the address cannot be parsed |
550 Invalid syntax. Syntax should be MAIL FROM:<mailbox@domain>[crlf] |
Malformed MAIL FROM command (a matching reply exists for RCPT TO:<mailbox@domain>[crlf]) |
A client defect |
550 Sender address must be specified. |
The server is configured to refuse an empty sender address, and the client used one | Empty senders carry bounces and delivery reports — refusing them breaks standards and breaks other servers’ sender verification. Reconsider the setting |
550 Sender domain does not have any MX records. |
The MX check is enabled and the claimed sender domain publishes no MX records | Legitimate senders occasionally fail this. The remote administrator must fix their DNS — or you can disable the check |
550 Blocked by SPF. |
The message was rejected on the sender domain’s SPF policy; if the record supplies an explanation, it is appended in parentheses | The sending server is not authorised by the sender’s SPF record. Usually spam; occasionally a misconfigured forwarder — SRS at the forwarding end fixes that (§17.6). Since 6.3.4 SPF follows RFC 7208, and a client on 127.0.0.0/8 or ::1 no longer passes before the policy is read: a content filter that hands mail back to the server on the same machine should authenticate, or connect from an IP range with anti-spam off |
550 with other text (554 when the refusal comes after the body) |
The spam score reached the delete threshold, and the reply carries the first failing test’s text. DNS blacklist and SURBL entries carry their own, in the Reject message column of Spam & virus filtering → DNS blacklists and Spam & virus filtering → SURBL servers | The text is the first failing test’s. For a DNS blacklist or SURBL entry it names the list, and the sending IP or a URL in the body is listed there |
550 Mail from <name> is refused by this server's policy. |
New in 6.3.4. The envelope sender’s domain or the HELO name is black on Spam & virus filtering → Domain white and black list, checked at MAIL FROM |
Working as configured. Remove or narrow the entry if the sender is wanted |
550 <address>: this mailbox accepts mail from named domains only. / 550 <address>: this account may send to named domains only. |
New in 6.3.4. Restricted delivery: the mailbox, or its whole domain, may exchange mail only with named domains | Working as configured. The named domains are on the domain’s Restricted delivery tab |
550 to the second recipient of an empty sender, and to its DATA |
New in 6.3.4, off until RejectMultiRcptNullSender is on: a delivery report has exactly one recipient, so a null sender naming a second is refused |
Working as configured. With BanMultiRcptNullSender on, the address is also auto-banned (24 hours by default) |
550 Too many delivery attempts before the greylisting delay had passed. … |
New in 6.3.4, off until GreyListingAbuseThreshold is set: one sender, recipient and connecting address were tried that many times before their greylisting window opened, and are locked out for a while |
A server that retries from its queue at ordinary intervals rarely meets it; one that delivers many queued messages to one mailbox down one connection can, since every RCPT TO before the window opens counts. The 550 is permanent to the sender, so a wanted sender caught by it is stranded until the lock lifts, after GreyListingAbuseLockMinutes (60 by default); Clear greylisting triplets (the Greylisting tab of Spam & virus filtering → Anti-spam settings) lifts every lock at once, at the cost of greylisting every sender once more. For a bulk sender that delivers many messages to one mailbox at once, raise or clear the threshold |
552 with the reason, to an authenticated sender at MAIL FROM |
New in 6.3.4. The account has reached its quota’s send-stop share, set for the server or the domain (off by default) | Make room in the mailbox, or raise its size limit; mail to the account is still received |
550 The host name specified in HELO does not match IP address. — the optional HELO
host check in the anti-spam settings verified that the name the sending server announced
resolves to the IP it is connecting from, and it did not. Legitimate servers fail this
surprisingly often, which is why the check is off by default. If a wanted correspondent
is refused: tell their administrator their HELO name is wrong, or disable the check, or
configure spam handling to tag rather than reject so the mail still arrives marked as
suspect (chapter 10).
550 Mail server configuration error. Too many recursive forwards. — hMailServer
follows aliases and forwards to find the end recipient, and it went round in circles
or ran out of road. Two configurations cause nearly all of these:
- An alias loop —
[email protected]points at[email protected], which points back at[email protected]. There is no end recipient. - A catch-all pointing at nothing — the domain’s catch-all names an address that is not an existing account.
Walk the chain in Domains — every alias, forward and catch-all must terminate at a real, active account.
Message-content and storage rejections
| Reply | When it is sent | What to do |
|---|---|---|
552 Message size exceeds fixed maximum message size. |
The message exceeds the maximum size set in the SMTP settings or on the recipient domain | Raise the limit, or the sender shrinks the message. The reply states both sizes |
554 5.7.1 Rejected |
An OnSMTPData or OnAcceptMessage event script rejected the message without giving a reason |
Your script’s logic decided this — check the script |
554 5.7.1 <text> |
An OnSMTPData or OnAcceptMessage script rejected the message with a reason — the reply is the script’s text verbatim after the code |
As above; the text is whatever the script supplied |
453 4.7.0 <text> |
An OnSMTPData or OnAcceptMessage script refused the message for now, with its own text: a temporary refusal, so the sender retries |
As above |
554 Tagged as Spam by SpamAssassin |
SpamAssassin’s verdict pushed the message over the rejection line | Working as configured. Tune thresholds per §10.1 if legitimate mail is caught |
451 Rejected - No data saved. |
The message file could not be written to disk — the data directory is not writable by the service account. A 4xx, so the sender retries |
Fix permissions on the Data directory for the account the service runs as. Since 6.3.4 a new installation runs the service as NT SERVICE\hMailServer, which reaches only the folders it is granted: a data directory outside the program folder must be approved and granted by hMailServer.exe --confirm /GrantServiceAccess, run elevated from Bin (--report-only lists what would be granted and what the service cannot reach, and changes nothing; §17.3) |
451 Your message was received but it could not be saved. Please retry later. |
The database insert failed after the message was received. A 4xx, so the sender retries |
See §32.2 — the error log immediately before holds the cause |
550 with a rule’s text, or naming the rule |
New in 6.3.4. A mail flow rule whose action is to reject with a text, or an outbound content policy (a card number, an IBAN, an attachment type) that blocks, refused the message at the end of DATA. A reject with no text of its own names the rule |
Working as configured. Check the rule that fired, among the server’s rules (Mail flow & delivery → Rules) or the domain’s own |
554 Rejected - Message containing bare LF's. — the SMTP standard requires every
line to end with CRLF (carriage return + line feed). This message contained lines ending
in a bare LF, and the option to reject such messages is enabled. Spam tools and buggy
software are the usual senders — but so are hand-rolled scripts.
If your own application triggers this: terminate every line of the message, headers and
body alike, with \r\n (VBScript: vbCrLf). A gotcha for Unix-hosted senders: Linux’s
native line ending is LF alone, and some Unix mail systems convert LF to CRLF
automatically — so a message already using CRLF can come out as CR CR LF. Generate CRLF
exactly once.
32.5 SMTP errors from remote servers
When hMailServer delivers mail, the remote server’s replies decide the message’s fate:
- 4xx — the message stays in Monitoring & troubleshooting → Delivery queue and is retried on the schedule on the Delivery tab of Mail flow & delivery → Delivery of e-mail. The queue shows the last reply received, which is your primary diagnostic.
- 5xx — hMailServer gives up and bounces the message to the sender, quoting the remote reply.
Common remote replies and what they mean for you:
| Remote reply (typical text) | Meaning | What to do |
|---|---|---|
550 Mailbox unavailable / 550 User unknown |
The recipient does not exist on that server. (hMailServer’s own wording for the same situation is 550 Unknown user — if you see “Mailbox unavailable”, it came from the far end) |
Check the address. If it is definitely right, the recipient’s server or DNS is misconfigured — their problem to fix |
450/451 Try again later (greylisting) |
The remote server is greylisting you | Nothing. hMailServer retries automatically and the retry is accepted |
421 Too many connections/messages |
You hit the remote server’s rate limits | Wait — retries handle it. Since 6.3.4 hMailServer also backs off by itself: a 421, or a 4xx with one of the providers’ rate-limit codes or phrases, holds deliveries to that destination alone for OutboundBackoffMinutes (2 by default, doubling to OutboundBackoffMaxMinutes, 60; 0 turns it off). The queue then shows a 4.4.5 deferral whose reason names the destination and the reply it gave, the application log says Delivery deferred by this server's own shaping, not by a remote server., and such a wait does not spend the message’s retries until it has been queued longer than its retry schedule spans. Through a smart host the relay is one destination, so one such reply holds everything sent through it. To stay under large providers’ limits permanently, set MaxOutboundPerDestinationPerMinute (§17.5, new in 6.2), which defers rather than bounces |
550/554 naming a blacklist (Spamhaus etc.) |
Your IP is on a DNS blacklist | Check the IP at mxtoolbox, fix the cause (open relay? compromised account? missing PTR?), then use the list’s delisting form |
5.7.x policy rejections from Google, Microsoft and other large providers |
Your sending reputation or DNS is inadequate — missing PTR, SPF, DKIM or DMARC | Work through §19.4. PTR first; it is the biggest single factor |
550 Sender verification failed — the receiving server tried to verify that your
sender address really exists before accepting the mail. It looked up your domain’s MX
records, connected to the host they name — which should be your server — and probed
RCPT TO: your sender’s address with an empty envelope sender. Three things break it:
- Your MX records are wrong, so the verifier connected to the wrong machine.
- The sender account does not exist on your server.
- You refuse empty sender addresses, so the verifier’s probe was rejected at
MAIL FROM— see550 Sender address must be specified.in §32.4. This is the self-inflicted version; allow empty senders.
Bounce texts hMailServer generates about its own delivery attempts
“Could not connect to <IP> on port 25 since this would mean connecting to
myself.” — loop protection. Before connecting out, hMailServer checks that the
destination IP and port are not ones it is itself listening on; delivering to yourself
for an address you do not host would loop forever, so the attempt fails with this text
instead. It appears in the delivery queue and the TCP/IP log, and in the eventual bounce
once the retries are exhausted. Two configurations cause it:
- The smart host points at this machine. Check the smart host on the Relayer tab of Mail flow & delivery →
Delivery of e-mail — if it says
localhost,127.0.0.1, the server’s own name or your own domain, that is the fault. The smart host is someone else’s server you relay through (§14.1), never your own. - An MX record points at this server, but the domain is not added here. Either add the domain under Domains, or fix the MX record.
A bounce saying No mail servers appear to exists for the recipient's address.
(the wording, typo included, is hMailServer’s) — DNS answered, and the recipient’s domain publishes
nowhere to deliver mail (enhanced code 4.1.2). Its companion, Unable to find the recipient's
email server. The DNS query has failed. (4.4.4), means the lookup itself failed: check your
resolver and Connections & protocols → DNS resolver. Both are class 4, so the message is retried
before it bounces, and the error log carries the DNS error code (§32.3).
A delivery through a relay or route that signs in with OAuth2 waits in the queue with a
450, and the error log has HM5912 — new in 6.3.4. The host is on
OutboundOAuth2Hosts and its connection security is none, or STARTTLS (optional) against a server that
does not offer it, and hMailServer no longer sends its token in the clear. Set the route’s or the relay’s
connection security to SSL/TLS or STARTTLS (required); Exchange Online offers STARTTLS on 587.
32.6 Deeper troubleshooting
Chapter 19 is the checklist; run it first. These techniques are for when the checklist passes and the problem persists.
Speak SMTP by hand
A manual session separates network problems from policy problems in under a minute.
From any machine with the telnet client (on Windows it is an optional feature) — or
openssl s_client, which also handles the encrypted ports:
# Plain session to port 25
telnet mail.yourcompany.com 25
# STARTTLS session — also proves the certificate and TLS configuration
openssl s_client -starttls smtp -connect mail.yourcompany.com:25 -brief
Then type a session and watch the replies:
220 mail.yourcompany.com ESMTP
EHLO test.example.net
250-mail.yourcompany.com
250 ...capabilities...
MAIL FROM:<[email protected]>
250 OK
RCPT TO:<[email protected]>
250 OK
QUIT
If the connection opens but a command is refused, the refusal text plus §32.4 tells you exactly which rule fired. If the connection does not open at all, it is network — work through §19.2.
Prove outbound port 25 works
Mail sitting in the queue with connection errors usually means your ISP blocks outbound port 25 — very common on consumer lines. Test against a real, known-good MX host:
Resolve-DnsName -Type MX gmail.com # pick any MX host from the answer
Test-NetConnection gmail-smtp-in.l.google.com -Port 25
TcpTestSucceeded : False against several different MX hosts means the port is blocked.
Your options: ask the ISP to open it, or relay outbound mail through a smart host
(§14.1).
Follow one message end-to-end in the logs
Enable debug logging under Monitoring & troubleshooting → Logging, reproduce the problem once, then
turn it off — it is verbose (§16.2). Read the whole session in order: connection, HELO,
envelope, the anti-spam verdicts, and the final reply. The last line before a refusal
names the check that refused. With JsonLogging=1 (new in 6.0), each line is a JSON
object, which makes filtering one session out of a busy log trivial.
For persistent watching, the OnError event (chapter 23) fires whenever an error is
logged — the script receives the severity, error code, source and description, and a few
lines there can raise an alert instead of waiting for a user to complain. Since 6.3.4 the server also raises alerts of its own, a failed backup and an expiring certificate among them. They are on by default, but nothing is mailed until AlertRecipient and AlertSenderAddress are set: in the Control Deck’s Settings view or with PUT /api/v1/settings, where they are alert_recipient and alert_sender_address, or over COM. If you run
Prometheus (metrics are new in 6.0), trend hmailserver_messages_deferred_total and
_bounced_total (§16.3): a deferral spike points at a network, DNS or remote-policy
problem; a bounce spike points at bad addresses or a reputation problem.
“Someone is sending spam through my server”
The symptom: Monitoring & troubleshooting → Delivery queue fills with messages you do not recognise, to recipients you have never heard of. Two causes, in order of likelihood:
- A compromised account. The spammer is authenticating with stolen credentials, so
the relay rules are satisfied. The SMTP log shows which account — change its password
immediately.
MaxSubmissionsPerIPPerMinute(§17.5) caps the damage next time. Since 6.3.4, compromised-account detection judges every authenticated submission against the account’s own history and raises theaccount.compromisedalert when one has the shape of a takeover (mailed onceAlertRecipientandAlertSenderAddressare set). It starts inwatchmode and holds nothing: setcompromise_detection_modetoenforcein the Control Deck’s Settings view (it has no Control Panel page) to have it hold the messages and lock the account, and review what it holds in the Deck’s Held for review view. - An open relay. An IP range permits external-to-external delivery without authentication. Check Sign-in & access → IP ranges, then run the test below.
Either way, delete the junk from the queue before it drains — every delivered spam message damages your IP’s reputation.
32.7 Testing your own server for open relay
An open relay accepts mail from anyone, to anyone — and once found, it becomes a spam cannon within hours, not days. hMailServer’s defaults are safe (chapter 17), but you should verify after installing and after any change to IP ranges. Only ever test a server you operate.
The manual test
From a connection outside your network — a phone hotspot will do — open a session to your port 25, do not authenticate, and try to relay:
220 mail.yourcompany.com ESMTP
EHLO relaytest.example.net
250 ...
MAIL FROM:<[email protected]>
250 OK
RCPT TO:<[email protected]>
530 SMTP authentication is required.
That 530 is the correct answer on the IP ranges hMailServer ships with, which allow
external-to-external delivery only after authentication: an unauthenticated stranger may not send through
you. A range that does not allow that delivery at all refuses it with 550 Delivery is not allowed to this address.
instead, which is just as correct. If you instead get 250 OK on the RCPT TO, you are an open relay — stop and fix
it now.
Test from outside. A session opened on the server to 127.0.0.1 matches the My computer range, not the Internet range a stranger meets, and any internal ranges you have added may allow more than the Internet range does — so a test run on the server, or from inside your network, proves nothing. The test must arrive the way a spammer would.
Online testers
Web-based relay testers probe a series of relay tricks (unqualified recipients, %-hack addresses, mismatched envelope forms) in one pass — MxToolbox’s SMTP diagnostics at https://mxtoolbox.com/diagnostic.aspx include an open-relay probe. They use throwaway addresses and are safe to run against your own server. Bear in mind that if your server is open, a thorough test successfully relays a real probe message — one more reason to fix a failure immediately rather than re-running the test.
False positives
A tester may warn about relay when nothing is wrong. The classic combination:
- A default domain is configured, so a recipient given without a domain
(
RCPT TO:<webmaster>) has the default domain appended —[email protected]. - That domain has a catch-all address, so every address in it is accepted.
Put together, your server accepts RCPT TO:<any-random-string> — and the tester flags
it. But the mail is delivered to your own catch-all mailbox, never relayed onward, so it
is not an open relay. It is still worth asking whether you want both settings: a
catch-all plus a default domain accepts a great deal of junk addressed to nobody.
If the test genuinely fails
- Sign-in & access → IP ranges — find the range that permits unauthenticated external-to-external delivery and remove that permission, or require SMTP authentication for it as the shipped ranges do. This is the fault.
- Empty the delivery queue of spam (Monitoring & troubleshooting → Delivery queue).
- Check your IP against the major blacklists at mxtoolbox and use the delisting forms — after the fix, not before, or you will be relisted immediately.
32.8 Codes in the error log
The error log prefixes its own numbers with HM — HM6364, HM10045. Most are
internal, but these are the ones worth recognising on sight.
| Code | What it means | What to do |
|---|---|---|
| HM6364 | Assertion failed: <expression> (<file>, line <n>). This binary keeps its assertions; the server carried on past this one. Severity Critical | You are running a diagnostic build. Only the assertion-enabled build can report this; the binary that ships never does. Report it with the expression, file and line, and move back to a release build for production |
| HM5528 | PreferredHashAlgorithm is set to a value that is not a password-hashing scheme this server will store a new secret under |
PBKDF2 (4) is used instead. Set it deliberately to 3, 4, 5 or 7 (§17.2) |
| HM5607 | PreferredHashAlgorithm is 5 (Argon2id) but this build’s OpenSSL has no Argon2id KDF |
scrypt (7) is used instead. OpenSSL 3.2 or later has Argon2id |
| HM5562 | SpamAssassinUser contains a control character and has been ignored |
No User: header is sent to spamd. Fix the value on Spam & virus filtering → Anti-spam settings, SpamAssassin tab (§10.3), or over REST or COM, and it applies when saved; on Linux hmailserver --set-setting stores it too, and a running service takes it at its next save, reinitialise or restart. Since 6.3.4 the database holds it, and a line edited in hMailServer.ini is reported as HM5804 and not applied |
| HM6410 — HM6413 | Linux only. The stored-secret key file could not be created (6410), its mode lets other accounts read it (6411), it could not be opened or is not exactly 32 bytes (6412), or a secret could not be protected (6413) | The message names the chmod that fixes 6411. After a restore, the does not exist form of 6412 means the key file was not restored with the data directory — without it every stored password has to be re-entered (chapter 15) |
| HM6414 | Linux only. A stored secret is a Windows DPAPI blob, which nothing on Linux can open | This is what an INI carried across from a Windows installation produces. Re-enter the password in plain text |
| HM6416 | Linux only. A secret could not be protected at all | It is not stored. On Linux this build never falls back to the old fixed-key scheme on its own — fix the store rather than working around it. (On Windows a DPAPI failure falls back to that scheme instead, so that no secret is lost.) |
| HM5804 | A [Settings] line in hMailServer.ini was changed away from the value the server last wrote there. Since 6.3.4 the database holds these settings and the file is its copy |
The stored value is used, the key is named and, where the service may write the file, the line is put back. Make the change in the Control Panel, over REST or COM, or on Linux with hmailserver --set-setting (chapter 21). On a warm standby, or after rolling back a database migration, it is the file’s copy catching up, not a fault |
| HM5807 | hMailServer.ini has a [SettingsOverride] section. Named at every start and reinitialise for as long as the section is there |
Its values are used over whatever is stored. It is the deliberate way round a database that cannot be read; take it out once it is no longer needed, because a value saved for a key it forces is stored but not in effect until the override is removed (HM5808) |
| HM7495 | At a start (new in 6.3.4): the database records a stored-secrets key, or holds values sealed under one, and this server has no key, a key line that does not open here, or a key that is not the database’s. Said at each start until it is mended. Beside it the error log names what will not open: HM7492 once for each [Settings] secret, and HM7493 once for the second factors, naming the first account met. /readyz still answers 200 on such a server (§33.7) |
Mail is still served, but every second factor sealed under that key refuses every code, and the sealed [Settings] secrets read as not set, so SRS, BATV and HS256 tokens fail, and a /metrics protected by a token or password answers 503, or, on a loopback address, answers without a credential. Stop the service, import the key exported where it was made (hMailServer.exe --import-stored-secrets-key FILE from an elevated prompt, sudo hmailserver --import-stored-secrets-key FILE on Linux) and start it again. A server that made a key of its own needs its [Security] StoredSecretsKey line removed first; never copy that line from another machine, where it is sealed under that machine’s own DPAPI or key file |
| HM5018 | On Windows, a rule’s Run function action names a function that is not a plain identifier (ASCII letters, digits, _ and $, not starting with a digit): refused since 6.3.4, each time the rule matches. Reported once per load instead, it means an event reached a script loaded in a language this build does not run, or a call built for VBScript met JScript after a reload, or the reverse |
The function is not run. Give it a plain name, in the event script and in the rule (§27.3). On Linux such a name writes a Script Error line to the error log instead |
| HM5912 | A route or the SMTP relay whose host is on OutboundOAuth2Hosts would have sent its OAuth2 token without TLS: its connection security is none, or STARTTLS optional against a server that does not offer it (new in 6.3.4) |
Nothing is sent: the delivery fails with a temporary 450 4.7.0 and the message waits for the retries the route allows. Set the connection security to SSL/TLS or to STARTTLS required (chapter 14) |
| HM10045 | Unknown error during a SQL Server Compact schema upgrade on 6.2.25 |
See §32.2 — the upgrade succeeded; the verification was the defect. Fixed in 6.2.26 |
33. Operations cookbook
Installing a mail server is a day’s work. Running one is a decade’s. This chapter is a set of recipes for the jobs that surface only after months of uptime: knowing what is actually on the disk, moving to new hardware, outgrowing the built-in database, keeping the database healthy, automating the routine work, running on a connection that was never meant to host a mail server — and, at the other end of the scale, running a standby machine so that losing one is survivable. Each recipe stands alone — read the one you need.
33.1 The installation on disk
Everything hMailServer owns lives under the installation directory —
C:\Program Files\hMailServer by default. Knowing what each part is tells you what to
back up, what to prune, and what you can always get back from the installer.
| Path | What it is | Back up? |
|---|---|---|
Bin\ |
The programs: the server itself (hMailServer.exe) and the database tools (DBSetup, DBSetupQuick, DBUpdater), plus the bundled database client libraries — MariaDB Connector/C (as libmysql.dll), PostgreSQL’s libpq and OpenSSL |
No — reinstallable |
Bin\hMailServer.INI |
The configuration kept out of the database (chapter 21): the database connection, the directories, the administrator password, the [LDAP], [SendingLimits] and [SendingLimitsOverrides] sections, and PasswordPepper. Since 6.3.4 every other [Settings] key is stored in the database and the file’s [Settings] section is the server’s copy of them. Once the installation has one, the file also holds the stored-secrets key, sealed under this machine’s DPAPI on Windows, so a copy of the file is no copy of the key (§33.4) |
Yes |
Bin\hMailServerApiKeys.ini |
The REST API’s key store (chapter 24): a SHA-256 digest of each key, written by the service when a key is made or revoked | Yes, if you use API keys |
ControlPanel\ |
New in 6.2. The .NET 10 Control Panel (hMailCP.exe), which replaced the retired Administrator |
No — reinstallable |
Addons\ |
The Data Directory Synchronizer, a wizard that reconciles database rows with the files under Data\, and — since 6.2.12 — the Import Tool (§33.8) |
No — reinstallable |
Data\ |
The mail itself. Files in the root of Data are messages not yet delivered — the queue. Subfolders hold delivered mail, organised by domain and account, beside stores of the server’s own such as Blobs, Discovery, Files, Lists and Recoverable; since 6.3.4 a new domain may not be named after one of them |
Yes — irreplaceable |
Data\ACME\ |
The ACME account key and automatically issued certificates | Yes — it travels with Data\ |
Database\ |
hMailServer.sdf, the built-in database |
Yes, if you use the built-in engine |
DBScripts\ |
The SQL scripts that create and upgrade the schema. Upgrade scripts are named UpgradeXXXXtoYYYYZZZZ.sql — old schema, new schema, database engine |
No — ships with the installer |
Events\ |
Your event scripts — the code behind Maintenance → Event scripts | Yes — it is your code |
Languages\ |
Translations for administration text, loaded by the server at startup and served to admin clients over the COM API | No — reinstallable |
Logs\ |
Log files, controlled by Monitoring & troubleshooting → Logging | Optional — useful for audit, regenerable otherwise |
Temp\ |
Scratch space — for example, attachments extracted as .tmp files for virus scanning |
No — safe to empty with the service stopped, but leave Temp\IMAPAppend: since 6.3.4 its markers let the next start remove the file of an IMAP APPEND a crash or a power cut interrupted |
WebAdmin\ |
New in 6.x. The Control Deck: index.html, which the REST API listener (chapter 24) serves at its root, and, since 6.3.4, the Deck’s language catalogues in languages\. No relation to the removed PHP WebAdmin |
No — reinstallable |
Message files carry the .eml extension; an installation with a long history may also
hold .hma files written by much older versions. Both are ordinary mail messages and
both are still served. Since 6.3.4 that holds while MessageStoreCompression is off, as
shipped. With it on, a message stored in a mailbox is a deflated container rather than plain
RFC 822, each attachment of at least MessageStoreDedupMinKB (256 KB) is kept once under
Data\Blobs, and every client still sees the message as it arrived; the queue is never
compressed. A script that opens the path Message.Filename gives over COM, or anything
else outside the server that reads the files, reads the container: read the message through the
API instead, or turn compression off, stop the service and run
hMailServer.exe --compress-store --decompress to put the files back as plain RFC 822.
Two things live outside this tree: an external database, if you use one (§33.3), and
your DKIM key files, which sit wherever you put them when you generated them — chapter
15 reminds you to back both up. Since 6.3.4 a domain whose DKIM keys rotate keeps them under
Data\DKIM instead. One more file belongs outside it: the one you export the
stored-secrets key to (§33.4), kept with the database backups but stored apart from them.
Never delete message files by hand. Every file under
Datahas a matching row in the database. Delete the file and the row points at nothing; delete the row and the file is orphaned on disk. Remove mail through a client, a rule or the API, and let the server keep the two sides in step.MessageStoreConsistencyCheckset to 1 (Maintenance → Server limits & expert settings, card Message store durability) makes the server cross-check rows against files periodically and report drift.
Leftovers from 5.x. An installation upgraded from the original hMailServer may still
carry pieces this fork no longer uses — a PHPWebAdmin folder (the PHP web interface,
removed for good reasons in chapter 25) and the retired Administrator’s hMailAdmin.exe
in Bin. They are inert. Once you have a verified backup, they can go. Do not confuse
them with two folders that are current: Languages is still shipped and loaded, and the
fork’s own WebAdmin (table above) shares nothing but a name with the old PHP interface.
33.2 Moving hMailServer to a new machine
Hardware ages, Windows versions expire, and sooner or later the server must move. The move is a restore onto fresh metal — which means chapter 15 is a prerequisite, and the first time you do this it should be a rehearsal onto a test machine, not the real thing under pressure.
The plan
- Check the new machine against the chapter 3 checklist — 64-bit Windows, always on, and a disk sized for the mail store you actually have now, not the one you had at installation.
- Install the same version of hMailServer, in the same path. If the old server runs
6.3.4 in
C:\Program Files\hMailServer, install exactly that on the new one. A different version or a different directory is where migrations go wrong. Since 5.4 a message row holds a bare file name and the path is rebuilt from the data directory; a database from before 5.4 can still hold full paths. A new 6.3.4 installation runs the service asNT SERVICE\hMailServer, which reaches only the folders it is granted, where the old server may still run as LocalSystem. After the restore, run Grant access (Maintenance → Server limits & expert settings, card Windows service account): it names every certificate or DKIM key file outside those folders, which the service cannot read. Move such files intoData\Certificatesor approve their folder for Read. The archive you restore from is read the same way, so keep it in a folder the service may read. LocalSystem, which the installer’s service-account page also offers, reads them wherever they are, but the setup checklist reports it as action needed. - Back up the old server. Use Maintenance → Backup & restore with everything
selected — remember the built-in backup does not include messages unless you tick the
option (chapter 15). Let the delivery queue empty first: mail still in it is not carried by
the archive. For a large store, the external-tools approach in §33.5 is
faster: stop the service, copy
Data\, dump the database natively, and takehMailServer.INI,Events\, your DKIM keys and any certificate and key files kept outsideData\— and, since 6.3.4, the stored-secrets key, exported withhMailServer.exe --export-stored-secrets-key FILEfrom an elevated prompt (see the caveat below). - Stop the old server for good. Once the backup is taken, stop the hMailServer service on the old machine and set it to Disabled. Anything it accepts after this moment is not in your backup and will be stranded.
- Restore on the new machine — data directory and database, in the
order chapter 15 gives. Do not copy the old
hMailServer.INIover the new one: its database password and, since 6.3.4, its stored-secrets key line are sealed under the old machine’s DPAPI and open nowhere else. Keep the new file’s own[Database]and[Directories], and carry across what only the file holds:PasswordPepper, if one was set, and the[LDAP],[SendingLimits]and[SendingLimitsOverrides]sections if you use them. Since 6.3.4 the[Settings]keys arrive with the database. - Re-enter protected secrets. See the caveat below.
- Fix up the network. See the checklist below.
- Confirm, then decommission. Send a message in from an outside address and out to one; log in over IMAP; watch Monitoring & troubleshooting → Server status and Monitoring & troubleshooting → Live logs. Keep the old machine powered off but intact for a week or two — it is your rollback.
The DPAPI caveat, again. As chapter 15 explains, with
ProtectStoredSecretsWithDPAPI=1(the default) stored passwords are encrypted with machine-scoped Windows DPAPI and cannot be decrypted on the new machine. Which of them come across depends on how the data moved. The database connection password in a copiedhMailServer.INInever does: keep the new file’s own[Database]section, as step 5 says, or re-enter the password — for an external database, runBin\DBSetup.exeto do it (§33.3 says what that needs underNT SERVICE\hMailServer). Route, smart-host relayer and external-account passwords and certificate key passphrases come across through the built-in backup and restore, which carries them in a portable form and protects them again on the new machine; after a database copied with its own tools, re-enter them in the Control Panel (a certificate’s passphrase on Certificates & encryption → SSL certificates). Each domain’s own relay settings (host, port, user name, password and whether it requires authentication) are in no archive, so re-enter them after a restore, on the Relay tab of the domain’s dialog; a copied database brings them but not the password, so re-enter that. The same goes, since 6.3.4, for the backup passphrase, which is in no archive and does not open on another machine: set it again on the new machine (Maintenance → Backup & restore, Encryption), or after a move through an archive its backups are written unencrypted, and after a copied database they fail. The SNMP agent’s passwords, the object store’s secret key and the sandbox’s bearer token are in no archive either; enter them again after a restore, and place any eDiscovery legal holds again, since a server rebuilt from an archive has no case and no hold. If you would rather have portable database copies, chapter 15 describes the trade-off of turning the setting off. Budget for this step; a migration that forgets it produces a server that cannot reach its own database.Since 6.3.4, the stored-secrets key. Every account’s second factor and the
[Settings]secrets (SRSSecret,BATVSecret,OAuth2HmacSecretand the rest) are sealed in the database under the installation’s stored-secrets key, once it has one, and the key is in no database and no backup. A move through the built-in backup does not need it: the archive carries those secrets opened, and the restore seals them under the new machine’s own key. A database moved with its own tools does. Before the new machine’s service first starts on it, remove the[Security] StoredSecretsKeyline the new machine’s installer made, then import the file exported in step 3 (or the one exported when the key was made) withhMailServer.exe --import-stored-secrets-key FILEfrom an elevated prompt. Never copy the line from the old machine. Without the key the error log saysHM7495at every start, every second factor refuses every code, and those settings read as not set.A Linux-to-Linux move is easier here and not elsewhere: the secrets are encrypted under
<DataFolder>/.hmailserver-secret-key, so copying the data directory and the database together carries the passwords with them. Copy that file with everything else, preserving its 0600 mode and its ownership. Since 6.3.4 the stored-secrets key line in/etc/hmailserver/hMailServer.iniis sealed under that file too, so bring the configuration across with it, or export and import the key withsudo hmailserver --export-stored-secrets-key FILEandsudo hmailserver --import-stored-secrets-key FILE.
Network and DNS follow-ups
| Check | Why |
|---|---|
| Keep the host name if you can | If mail.example.com simply resolves to a new IP, your MX, certificate and client configurations all survive unchanged |
| Update the A record | Point the host name at the new IP; lower its TTL a day before the move so the change propagates quickly |
| PTR record | The new IP needs reverse DNS — ask the new machine’s provider (§3.4). Without it, deliverability drops immediately |
| Windows Firewall | The installer does not open ports; re-run the rules from §4.4 |
| Router / cloud port forwards | Point them at the new machine |
| SpamAssassin and ClamAV | If they run on other hosts, confirm the new server can reach them |
Data\ACME |
If it came across with Data\, your certificates and keys are intact. If not, ACME re-issues — but a new key invalidates any published TLSA records (chapter 9) |
| Monitoring & troubleshooting → Diagnostics | Run the self-test last, with the local-domain and test-domain boxes filled in: left empty, the MX and local-connect tests are skipped silently, and the outbound test too when no smart host is set. It checks the installation paths, the outbound connection (port 25, or the smart host), the backup destination, your MX records and a connection in to them, the message file locations, whether localhost has been auto-banned, and any error log; the PTR record and the scanners you check yourself |
Finally, tell your users — only if something they see has changed. If the host name survived the move, nobody needs to know it happened.
More: Moving to new hardware, and why Windows to Linux is a rebuild on the forum.
33.3 Switching database engines
The built-in database (SQL Server Compact) is the installer’s default: it runs inside the hMailServer process and needs no external software. Microsoft stopped maintaining it long ago, and it has hard limits that one day you may hit.
When to switch
- The 4 GB ceiling.
Database\hMailServer.sdfcannot grow past 4 GB. That is room for references to millions of messages, but a busy server gets there. - Recoverability. If an
.sdffile corrupts — power loss, disk failure — there is very little tooling to repair it. The server engines have mature backup and repair tools. - Scale. Hundreds of thousands of messages, or many accounts, run faster on a real server engine.
- Integration. If you report on or integrate with the mail database, you want an engine with proper client tooling.
The choice of target follows §3.5: MySQL/MariaDB for most production use (the client library is bundled), Microsoft SQL Server — including Express — if you already run it, PostgreSQL for larger installations, or if you already know and like it.
The procedure
The built-in backup is the vehicle: it captures domains, accounts, settings and messages in an engine-neutral form, and restores them into whatever database the server is then connected to.
- Prepare the target engine. Install it, create an empty database, and create a
user with full rights on that database only. Since 6.3.4 (schema 6102) every MySQL and MariaDB
table is
utf8mb4, which needs MySQL 5.7.9 or MariaDB 10.2.2 or later; make the databaseutf8mb4too (CREATE DATABASE hmailserver CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;). For SQL Server with Windows authentication on the same machine, a service that runs asNT SERVICE\hMailServerneeds a login of that name (CREATE LOGIN [NT SERVICE\hMailServer] FROM WINDOWS) and a user in the database withdb_owner. - Back up everything — Maintenance → Backup & restore, messages included (chapter 15). Take this backup at a quiet moment, immediately before the switch — nothing that arrives between backup and switch comes across. Let the delivery queue (Monitoring & troubleshooting → Delivery queue) empty first: mail still in it is not carried by the archive, and nothing queues it again after the restore. It is both the migration vehicle and the safety net.
- Run
Bin\DBSetup.exe. Since 6.3.4 a service that runs asNT SERVICE\hMailServer, a new installation’s account, may not writehMailServer.INI, and DBSetup’s change to[Database]is refused before anything is made, so first move such a service to LocalSystem (Maintenance → Server limits & expert settings, card Windows service account, Move to LocalSystem, then restart the service, which keeps its old account until it restarts). Point DBSetup at the new engine; it creates the hMailServer schema there and updates the connection settings inhMailServer.INI. The schema is created at the current version (6108 at 6.3.8 — chapter 18), soDBUpdateris not involved. Restart the service when it finishes, so the server comes up connected to the new database, and afterwards move it back with Move to NT SERVICE\hMailServer and another restart. - Restore the backup through Maintenance → Backup & restore (an encrypted archive asks for its passphrase). Then enter again what no archive carries: each domain’s own relay settings (host, port, user name, password and whether it requires authentication), on the Relay tab of the domain’s dialog in Accounts & domains → Domains, and, since 6.3.4, the backup passphrase (Encryption, on the same backup page), the SNMP agent’s passwords and community, the object store’s secret key or SAS, and the sandbox’s bearer token. The new database holds none of them, and until the passphrase is set again every backup is written unencrypted, with the server’s stored secrets and every account’s second factor in the clear. Place any eDiscovery legal holds again as well: the new database has no case and no hold.
- Verify. Run Monitoring & troubleshooting → Diagnostics, check Monitoring & troubleshooting → Server status, send a message in and out, and spot-check a mailbox over IMAP.
Three things worth knowing:
- Your messages do not move. The
Data\directory stays exactly where it is; only the rows referencing it change homes. - Same hMailServer version throughout. An engine switch is not the moment to upgrade as well. One change at a time.
- The new database password is a stored secret, protected by DPAPI like the rest (chapter 15).
Keep the old Database\hMailServer.sdf untouched until you are confident, then archive
it with your backups.
33.4 Database maintenance
The database needs less attention than people expect — hMailServer creates and upgrades its own schema, and never needs manual table surgery. What it does need is engine-native care, an eye on growth, and one obscure ceiling checked once a year.
Engine-native care
| Engine | Backup | Integrity |
|---|---|---|
| Built-in (SQL CE) | The built-in backup (chapter 15) — there is no separate tooling | Few options if the file corrupts; this is a reason to switch (§33.3) |
| MySQL / MariaDB | mysqldump on a schedule |
CHECK TABLE; OPTIMIZE TABLE occasionally after large deletions |
| SQL Server | Native BACKUP DATABASE or a maintenance plan |
DBCC CHECKDB; index and statistics maintenance |
| PostgreSQL | pg_dump |
Autovacuum handles routine upkeep; leave it on |
Since 6.3.4, keep the stored-secrets key with these backups. Every account’s
second factor and the [Settings] secrets are sealed in the database under the
installation’s stored-secrets key, once it has one: the Windows installer makes it on a new
installation, while an upgraded installation, and a Linux server installed from the packages, has none until an administrator runs
--create-stored-secrets-key (sudo hmailserver --create-stored-secrets-key on Linux). The key is never in the database or in any backup, so a
dump, a SQL Server backup or a copy of hMailServer.sdf restored without it brings back
every second factor and [Settings] secret unopenable. Export it once, from an elevated
prompt beside hMailServer.exe, with hMailServer.exe --export-stored-secrets-key FILE
(sudo hmailserver --export-stored-secrets-key FILE on Linux), and keep the file with the
database backups, stored apart from them. The built-in backup does not need it: it carries those
secrets opened.
Between the database and the message store, MessageStoreConsistencyCheck set to 1 (Maintenance
→ Server limits & expert settings, card Message store durability) has the server periodically confirm that message rows
and message files still agree — cheap insurance after any crash or restore.
What is safe to prune
| Safe to remove | Never remove by hand |
|---|---|
Old logs — better, let LogDeleteDays do it (chapter 16) |
Files under Data\ — every one is referenced by a database row (§33.1) |
The contents of Temp\, with the service stopped, except Temp\IMAPAppend (§33.1) |
Rows in the hm_ tables — the server owns its schema |
| Backup archives you have superseded (keep several generations) | Lines in hMailServer.INI you do not recognise |
| 5.x leftover folders, after a verified backup (§33.1) | The delivery queue — the files in the root of Data\ are undelivered mail |
The honest answer on database size is that it tracks message count. The levers are
account quotas (MaxSize), public-folder discipline, and users who never empty Trash —
not database tricks.
The folder-UID ceiling
Every message added to an IMAP folder takes the next value from that folder’s UID
counter — the foldercurrentuid column in hm_imapfolders — and copies count, so a
message delivered to an inbox and later copied to another IMAP folder has consumed a
UID in each folder it touched. IMAP requires these identifiers to fit in 32 bits —
below roughly four billion — and the server holds them as exactly that. The ceiling is
per folder, not per server: a folder through which a couple of billion messages have
ever passed is past the halfway mark, however small the store looks today, and
IMAP-heavy use gets there faster.
Check where you stand by running this against the hMailServer database:
SELECT MAX(foldercurrentuid) FROM hm_imapfolders;
Almost every installation will find the answer laughably far from four billion — this is
a large-hosting concern, not a small-office one. If yours is genuinely approaching it,
note that a rebuild does not reset the counters: the built-in backup carries each folder’s
UIDNEXT and each message’s UID, and a restore into a freshly created database puts
them back (chapter 15), so a rebuild is no remedy. Plan any change to such a folder as a maintenance window,
not an emergency.
33.5 Automating maintenance
Before scripting anything, note what the server already automates: LogDeleteDays
prunes logs, ACME renews certificates, auto-ban handles password guessers, and the backup
has a scheduler of its own — ScheduledBackupTime for a daily run or
ScheduledBackupIntervalMinutes for an interval, with ScheduledBackupKeepCount and
ScheduledBackupMaxAgeDays for retention (§15.2). Since 6.3.4 the server also raises
alerts: they are on, with seven of their eleven conditions, backup.failed and
certificate.expiring among them, but nothing is mailed until AlertRecipient and
AlertSenderAddress are set. What scripts are still for is the
things the server has no opinion about: copying the archive off the machine, reports, and
the odd repair.
Anything the Control Panel can do, a script can do, over the same COM API (chapter 23).
On Windows the REST API (chapter 24) is an equally capable alternative; on Linux, where
there is no COM and no Control Panel, it and the Control Deck are the only routes there
are — so write portable automation against the API rather than against COM. Since 6.3.4 two
command-line clients speak it on either platform: hmctl, which the Linux packages
install, and the HmailServer PowerShell module; hmctl backup start runs the
server’s own backup. For anything unattended an API key is a better credential than the
administrator password: it can be read-only, restricted to one domain and revoked on its own.
A scheduled backup script
The BackupManager object starts the backup you have configured in Maintenance →
Backup & restore. A task that starts it runs the backup on a schedule of your own instead
of the built-in one, and can then copy the archive off the machine.
# hms-backup.ps1 — start the configured hMailServer backup
try {
$app = New-Object -ComObject 'hMailServer.Application'
$app.Authenticate('Administrator', 'your-admin-password') | Out-Null
$app.BackupManager.StartBackup()
}
catch {
Add-Content 'C:\Scripts\hms-backup-failures.log' "$(Get-Date -Format s) $_"
exit 1
}
Register it with Task Scheduler from an elevated PowerShell:
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File C:\Scripts\hms-backup.ps1'
$trigger = New-ScheduledTaskTrigger -Daily -At 02:30
Register-ScheduledTask -TaskName 'hMailServer backup' -Action $action `
-Trigger $trigger -User 'SYSTEM' -RunLevel Highest
The script contains the administration password. Keep it in a directory only Administrators and SYSTEM can read, and never in a location that syncs or gets committed anywhere.
Check for failure, every run. Since 6.2.10 a refused API call fails properly instead of reporting success (chapter 23 has the history), so a wrong password or insufficient rights now raises an error your
catchblock will see. An unattended script without error handling is a backup that silently stopped months ago.
For large mail stores, wrap the built-in backup with an external copy — or replace it,
backing up the data directory separately as chapter 15 suggests: a nightly robocopy
mirror of Data\ while the server runs, then a brief service stop and a second
robocopy pass that copies only the delta. That keeps the consistent-copy window to
seconds. Read robocopy’s switches carefully first; its mirror mode deletes as well as
copies.
Repair recipe: updating folder UID counters
One specific symptom — new messages not appearing in IMAP folders until the service is restarted — has one specific fix: bringing each folder’s UID counter back up to the highest message UID it actually contains.
' Update IMAP folder UID counters.
' Run ONLY for the symptom described above, and back up the database first --
' this touches the counter of every folder. The script stops and restarts the server.
Dim app
Set app = CreateObject("hMailServer.Application")
Call app.Authenticate("Administrator", "your-admin-password")
Call app.Stop()
Call app.Utilities.PerformMaintenance(1) ' 1 = update IMAP folder UIDs
Call app.Start()
MsgBox "Finished"
Save it as a .vbs file, fill in the password, and run it by double-clicking. It is a
repair tool, not routine maintenance — do not schedule it.
33.6 Running on a dynamic IP or home connection
Chapter 3 told you to get a static IP, and chapter 3 was right. But a home lab, a hobbyist domain or a test bed can run on a dynamic address if you go in with accurate expectations.
| You want to | On a dynamic IP |
|---|---|
| Receive mail directly | Works, if your ISP leaves inbound port 25 open — with brief gaps when the address changes |
| Send mail directly | Barely. Residential ranges are pre-listed on blacklists, and the PTR record is your ISP’s generic name, which you cannot change (§19.4) |
| Send through a smart host | Works well. This is the configuration to run |
Dynamic DNS
Your MX record must point at a host name, and that host name must always resolve to your current address. A dynamic-DNS provider gives you both: a host name, and an update client — run on the server or, better, on the router — that rewrites the A record whenever your ISP moves you.
- Register a host name with a dynamic-DNS provider and get its updater running.
- Point your domain’s MX record at that host name (chapter 8 covers the mechanics).
- Forward port 25 — and the client ports you use — from the router to the server, and open Windows Firewall (§4.4).
- Set the host name on the SMTP tab of Connections & protocols → Protocols so the server introduces itself consistently.
When the address changes, there is a window where the record is stale. Sending servers retry failed deliveries for days, so a prompt updater turns an IP change into a delay of minutes, not lost mail. A broken updater, on the other hand, turns it into a silent outage — monitor it.
Sending: use a smart host
Do not fight the reputation system; route around it. Configure a smart host in Mail flow & delivery → Delivery of e-mail (Relayer tab) exactly as chapter 14 describes — your ISP’s SMTP server, or a relay service such as SendGrid, Mailgun or Amazon SES — and let its static, well-kept IP do the talking.
Two DNS consequences:
- SPF must authorise the relay, not your home IP — use the
include:the relay service documents. - DKIM stays yours. Sign your own mail (chapter 8); the signature survives the relay and proves the mail is really from your domain regardless of whose IP delivered it.
Certificates
ACME’s http-01 challenge needs inbound port 80 forwarded to the server, and the
dynamic-DNS host name listed in AcmeDomains (chapter 9). That done, certificates renew
themselves like on any other installation — have your clients connect using that host
name so the certificate always matches.
Know when to stop. A dynamic-IP installation is a fine lab and an acceptable personal server. The moment lost or junked mail costs you money, the arithmetic changes: a small VPS with a static IP, open port 25 and a proper PTR record costs a few pounds a month and removes every limitation in this section. The chapter 3 checklist was written for that server.
33.7 A warm standby: active/passive high availability
New in 6.x. hMailServer contains no clustering code, and that is a decision, not a
gap: a single active node cannot split-brain. What the fork supports — and documents in
the repository’s HighAvailabilityRunbook.md — is an active/passive pair: two
identically configured machines, exactly one of which runs the hMailServer service at
any moment, with failover performed by the infrastructure around the server rather than
by the server itself.
Four pieces are shared:
| Piece | Requirement |
|---|---|
| Database | An external engine (§33.3) — the built-in database is local-only and disqualifies itself. Both nodes carry identical [Database] settings in hMailServer.INI |
| Message store | Both nodes point at the same Data directory on shared storage — SAN, NAS or a cloud file share — with identical access for both service accounts. Keep it on the same failover boundary as the database; the rows and the files must move together |
| Virtual IP | Clients connect to a floating VIP or load balancer, never to a node’s own address |
| Health check | The VIP routes on the /readyz probe — 200 only when the server is running and the database is connected |
Enable the probes on both nodes. Since 6.3.4 these are stored settings, kept in the shared
database, not lines to edit in hMailServer.INI: set them in the Control Panel, over REST
(PUT /api/v1/settings/ini/{name}), with hmctl, or on Linux with
hmailserver --set-setting NAME=VALUE, then reinitialise (Apply now) or restart
the service, because the listener binds its port and address when it starts. They are shown here in
the [Settings] form they have always been written in. On a non-loopback bind
/metrics also wants MetricsServerAuthToken, though the probes answer without it:
[Settings]
MetricsServerPort=8080
MetricsServerBindAddress=0.0.0.0
MetricsServerAuthToken=<32+ random characters>
ShutdownDrainSeconds=30
The passive node’s service stays stopped (set it to Manual), so its /readyz
refuses connections and it is never routed to. On a graceful stop, the active node
flips /readyz to 503 first, stops accepting connections and pauses delivery, then waits up to
ShutdownDrainSeconds for the sessions and deliveries in flight before its listeners come down.
Since 6.3.4 a planned failover can drain before anything stops: POST /api/v1/server/drain on A
closes its SMTP, IMAP, POP3 and ManageSieve listeners, pauses its delivery queue and answers
/readyz with 503 while what is in flight finishes, and GET /api/v1/server/drain says
drained is true when a stop would be instant. Then stop A, watch its /readyz go dark,
move the VIP, start B, wait for its /readyz to return 200, check B’s error log for
HM7495, HM7492 and HM7493 (a node without the stored-secrets key still answers 200), and send a test message
through. Reverse the steps to fail back.
Fence before you fail over. In an unplanned failover, power off or isolate the failed node from the storage and database network before starting the standby. Two nodes writing the same message store is the split-brain this design exists to prevent, and it corrupts mailbox state. One more habit: configuration made in the Control Panel lives in the shared database, so the standby inherits it, and since 6.3.4 that includes every
[Settings]key butPasswordPepper. The[Settings]section of B’s ownhMailServer.INIis only a copy: B’s start names each setting changed since asHM5804, which is the copy catching up, not a fault, so do not edit it to match. What is per node is yours to keep in step:[Directories],[Database]and[Security];PasswordPepper, which must be the same on both or no account whose password is an Argon2id or scrypt hash signs in on B; and, since 6.3.4, the stored-secrets key, carried withhMailServer.exe --export-stored-secrets-key FILEon A and--import-stored-secrets-key FILEon B (if B’s installer made a key of its own, remove B’s[Security] StoredSecretsKeyline first, or the import is refused), never by copying its line. Do not copy A’s INI over B’s wholesale: its database password and that key line are sealed under A’s own DPAPI. And the passwords the server has to use rather than check — the smart host, routes, external accounts, the per-domain relay, and certificate key passphrases — are DPAPI-protected in the shared database, so re-enter them on B after a failover, and on A after failing back. The backup passphrase, the Web Push key, the import and single sign-on client secrets and the sandbox token are kept the same way; while a backup passphrase is set, B’s backups fail until it is entered again there.
More: Warm standby and high availability: what a second machine can and cannot do on the forum.
33.8 Importing accounts and mail: the Import Tool
New in 6.2.12. Addons\ImportTool replaces the old VB6 migration wizards with one
supported tool for the two imports that still matter when mail moves into hMailServer
from somewhere else:
- Accounts from a text file. One account per line, comma-separated — the same format the old wizard used. Each line is validated individually, and the run ends with a created/updated/failed summary instead of stopping at the first malformed line.
- Messages from mbox files. Each mbox file becomes one IMAP folder in the account
you choose, streamed through the COM API (
Utilities.ImportMessageFromFileToIMAPFolder, chapter 31) — so files of any size work, and the imported messages are ordinary mail the server indexes like any other. Unix (LF) and Windows (CRLF) mailboxes are both handled, as are mboxrd>Fromquoting and dot-stuffed copies saved from SMTP sessions — all documented defects of the VB6 tool, all fixed.
Maildir, since 6.2.27. Point the tool at the directory holding cur, new and
tmp and it imports the INBOX and every Maildir++ folder beside it, taking the flags the
file names carry — ;2, and !2, are accepted as well as :2,, since a colon cannot
appear in a Windows file name — and normalising line endings to CRLF. An import into a
folder a client already has open now appears at once: the folder is refreshed, as it is
after a delivery.
mbox is the lingua franca of mail exports: Thunderbird stores folders as mbox natively,
Google Takeout delivers Gmail as one, and every Unix mail system can produce one. For
.eml exports — one file per message — script ImportMessageFromFile directly;
chapter 31 shows how. Under NT SERVICE\hMailServer, a new 6.3.4 installation’s service
account, the server reads such a file only where that account may, so put it in a folder the
service has been granted (§17.3).
For the wider job — moving from another IMAP server, from mbox, from a Maildir, from
Outlook, from the upstream hMailServer in place, or accounts in bulk — the repository’s
Migration.md is the reference. Mirroring folders over IMAP (§29
FetchAccount.MirrorFolders) is usually the least painful route into hMailServer from a live
server. Since 6.3.4 two more routes are built in. A migration copies many mailboxes from
another IMAP server, Google Workspace or Microsoft 365 as jobs, one per mailbox, throttled and
resumed without copying anything twice, with contacts and calendars beside the mail. And the server
reads Outlook data files (PST) itself, as a migration of kind pst: copy the file into
<PstImportDirectory>\<domain>\ (the setting is empty as shipped, so no PST job runs
until an administrator names a directory) and start the job over REST
(POST /api/v1/migrations), with hmctl or from the Control Deck. The Import Tool does
not read PST files, and Outlook over IMAP remains the route for a file the reader refuses.
The tool runs on the server itself — it authenticates to the COM API as an administrator — and needs the .NET 10 Desktop Runtime the installer already provides. Re-running an account import is safe: existing accounts are reported as updated, not duplicated.
More: Bringing mailboxes in: IMAP mirror, Google, Microsoft 365, mbox, Maildir and PST on the forum.
34. Standards, protocols and interoperability
Email works because everyone implements the same RFCs. This chapter is the inventory: which standards hMailServer implements, how to see for yourself what your server advertises, the IMAP behaviours worth knowing before your users ask, where IPv6 support stands, and answers to the questions the rest of this documentation does not cover.
Configuration lives elsewhere — chapters 8, 9, 10 and 17 tell you how to turn things on. This chapter is the map of what is there.
34.1 Supported RFCs
Everything below is built in. The SMTP and IMAP extensions are advertised automatically
the moment a client says EHLO or asks for CAPABILITY — for most of them there is nothing to enable.
(The IMAP IDLE, SORT, QUOTA and ACL toggles on the IMAP tab of Connections & protocols → Protocols exist, but
all four are on by default. Off as shipped, among others: ManageSieve, noted below; XCLIENT; and, on IMAP, the SASL
mechanisms (AUTH=PLAIN, AUTH=SCRAM-SHA-256 and its -PLUS form) and SASL-IR,
which Allow SASL PLAIN authentication and Allow SASL initial client response on the same tab
turn on. Until then an IMAP client signs in with LOGIN.)
Core protocols
| RFC | Standard | Notes |
|---|---|---|
| 5321 | SMTP | Server-to-server delivery, port 25 |
| 6409 | Message submission | Authenticated sending by your users, port 587 |
| 5322 | Internet Message Format | The syntax of the messages themselves |
| 3501 | IMAP4rev1 | The default IMAP dialect |
| 9051 | IMAP4rev2 | New in 6.x. Always advertised; a client opts in per session with ENABLE IMAP4rev2 |
| 1939 | POP3 | Download-and-delete mail access |
Calendars and contacts. Beside the mail protocols, the web services listener, off until it is given a port, serves each account’s address books over CardDAV (RFC 6352, since 6.3.3) and, new in 6.3.4, its calendars and tasks over CalDAV (RFC 4791), on an iCalendar (RFC 5545) reader and writer of the project’s own. Clients find both through
/.well-known/carddavand/.well-known/caldav, and both are served over HTTPS only. Invitations travel by mail as iMIP (RFC 6047).
SMTP extensions
| RFC | Extension | What it adds |
|---|---|---|
| 3207 | STARTTLS | Upgrades a plaintext session to TLS |
| 4954 | AUTH | SASL authentication for submission |
| 2920 | PIPELINING | New in 6.x. Batches commands into fewer round trips |
| 6152 | 8BITMIME | New in 6.x. 8-bit message content without re-encoding |
| 3030 | CHUNKING / BDAT | New in 6.x. Transfers message data in binary-safe chunks |
| 3461 | DSN | New in 6.x. Senders can request delivery status notifications |
| 2034 | ENHANCEDSTATUSCODES | New in 6.x. Three-part status codes (RFC 3463) on every ESMTP reply |
| 6531 / 6532 | SMTPUTF8 / EAI | New in 6.x. Unicode email addresses and headers |
| 3030 | BINARYMIME | New since 6.2.21. Advertised alongside CHUNKING; a binary message is relayed as it is to a remote that accepts BINARYMIME |
| 3464 | DSN report format | New in 6.2.24. Bounces carry the machine-readable delivery-status part, not just prose |
| 5034 | POP3 AUTH / SASL line limits | Changed in 6.2.25. The AUTH line and the SASL responses after it accept up to 12288 octets on SMTP and POP3 (they were 510 and 500) — which is what lets a real RS256 bearer token fit |
| — | XCLIENT | New since 6.2.21. Postfix’s de-facto extension, not an RFC. Advertised only when SMTPXClientEnabled=1 and the peer is in SMTPXClientTrustedIPs |
A client that opens with the legacy
HELOinstead ofEHLOgets classic replies — extensions and enhanced status codes are only offered to ESMTP sessions, as the standards require.
Changed in 6.2.24. A client that sends
MAIL FROMorAUTHstraight after the STARTTLS handshake without a secondEHLOnow gets503 Bad sequence of commands. RFC 3207 has always required that secondEHLO; the server used to tolerate its absence.
IMAP extensions
| RFC | Extension | What it adds |
|---|---|---|
| 2177 | IDLE | Push notification of new and changed messages |
| 5256 | SORT and THREAD | Server-side sorting of large mailboxes; THREAD (both algorithms) since 6.2.20 |
| 4314 | ACL | Per-folder permissions, used by public folders |
| 9208 | QUOTA / QUOTA=RES-STORAGE | Clients can display mailbox quota usage. RFC 9208 obsoletes RFC 2087; RES-STORAGE is the only resource advertised, because storage is the only quota the server enforces |
| 6851 | MOVE | New in 6.x. Atomic move of messages between folders |
| 2971 | ID | New in 6.x. Client and server identify their implementations |
| 6154 | SPECIAL-USE | New in 6.x. Standard folder roles — \Sent, \Trash, \Junk, \Drafts, \Archive. CREATE-SPECIAL-USE (RFC 6154 §6) is advertised too — it is the atom a client checks before sending CREATE ... USE (\Sent) |
| 4978 | COMPRESS=DEFLATE | New in 6.2.28 (IMAPCompressionEnabled, on by default). Compresses both directions of a session. Changed in 6.3.4: advertised and taken only once the client has signed in (before that COMPRESS is answered NO), and withdrawn once compression is on; STARTTLS is refused once a session is compressed |
| 7889 | APPENDLIMIT | The APPEND size limit is discoverable — the bare atom before authentication, the exact value for this account afterwards |
| 7888 | LITERAL- | Non-synchronising literals up to 4096 bytes. LITERAL- rather than LITERAL+ deliberately, so an oversized APPEND can still be refused with TOOBIG before the data is sent |
| 5819 | LIST-STATUS | LIST RETURN (STATUS ...) answers each mailbox’s STATUS inline — one round trip at client start-up instead of one per mailbox |
| 8970 | PREVIEW | A server-generated body snippet, so a client renders its message list without fetching bodies |
| 3502 | MULTIAPPEND | Several messages in one APPEND, atomically |
| 8508 | REPLACE | Replace a message in one command instead of APPEND + STORE \Deleted + EXPUNGE |
| 8514 | SAVEDATE | The SAVEDATE FETCH item and the SAVEDBEFORE/SAVEDON/SAVEDSINCE search keys, backed by the column schema 6013 added |
| 5464 | METADATA | Annotations on mailboxes and on the server, over the table schema 6014 added |
| 8474 | OBJECTID | Stable object ids — EMAILID survives COPY, MAILBOXID survives RENAME. THREADID is answered NIL, which the RFC provides for |
| 3516 | BINARY | FETCH BINARY[] and BINARY.SIZE[] decode a part’s transfer encoding server-side; APPEND accepts the literal8 form |
| 8437 | UNAUTHENTICATE | An authenticated session returns to the not-authenticated state, for connection reuse. Advertised only once authenticated |
| 5032 | WITHIN | SEARCH OLDER and YOUNGER |
New in 6.x. The capability line also carries a set of smaller extensions added across the 6.x releases:
UIDPLUS(RFC 4315),UNSELECT(RFC 3691),ENABLE(RFC 5161),ESEARCH(RFC 4731),CONDSTOREandQRESYNC(RFC 7162),LIST-EXTENDED(RFC 5258),SEARCHRES(RFC 5182),STATUS=SIZE(RFC 8438) andUTF8=ACCEPT(RFC 6855). The classicNAMESPACE(RFC 2342),CHILDREN(RFC 3348) andSASL-IR(RFC 4959) carry over from the original.
Section 34.3 covers the ones with practical consequences.
Mail filtering
| RFC | Standard | Notes |
|---|---|---|
| 5228 | Sieve | New in 6.x. The standard mail filtering language — see chapter 12 for the supported command set |
| 5804 | ManageSieve | New in 6.x. Lets mail clients upload and manage Sieve scripts — off by default, see chapter 12 |
| 5173 | Sieve body extension |
New in 6.2.21. Filters can test the message body — decoded text, named MIME types, or raw — see chapter 12 |
| 5490 / 5463 / 5183 / 5260 / 5235 / 7352 / 5293 / 5229 / 5429 / 6609 / 5435 | Sieve extensions | New since 6.2.21. mailbox, ihave, environment, date/index, spamtest/spamtestplus, duplicate, editheader, variables, reject/ereject, include and enotify — plus regex, which never became an RFC. §12.2 has the whole table |
| 5804 | ManageSieve capability response | Corrected since 6.2.21. A SASL mechanism that would be refused on a cleartext connection is no longer advertised, and STARTTLS is offered wherever a certificate is available (§12.2) |
Authentication
| RFC | Standard | Notes |
|---|---|---|
| 7677 | SCRAM-SHA-256 and SCRAM-SHA-256-PLUS | New in 6.x. The password never crosses the wire; -PLUS adds TLS channel binding |
| 7628 | OAUTHBEARER | New in 6.x. OAuth2 bearer tokens; the de facto XOAUTH2 variant is also accepted |
| 6238 | TOTP | New in 6.x. Six-digit two-factor codes for administrator logon — enforced by the server itself since 6.2.25 (§17.4) — and for accounts. Since 6.3.4 a code the server has accepted is refused if it comes again, as the RFC requires |
| 4422 App. A | SASL EXTERNAL | New in 6.2.25. A client certificate verified against the port’s CA logs on as the mailbox its addresses name — on SMTP, IMAP and POP3 |
| 4752 | GSSAPI (Kerberos) | New in 6.3.2. Kerberos single sign-on on SMTP, IMAP and POP3, on Windows only; off until GssapiEnabled is set to 1 |
PLAIN and LOGIN remain available for ordinary password authentication, PLAIN on SMTP and IMAP only once it is allowed on the SMTP and IMAP tabs of Connections & protocols → Protocols, where it is off as shipped (§34.2). Chapter 17
explains when to prefer SCRAM.
Sender authentication and transport security
| RFC | Standard | Notes |
|---|---|---|
| 7208 | SPF | Checked on inbound mail as part of anti-spam scoring. Changed in 6.3.4: an implementation of RFC 7208 replaced the RFC 4408 library the server had carried. Loopback addresses no longer pass unconditionally before the policy is read, so a content filter that re-injects mail should authenticate or connect from an IP range with anti-spam off; lookups follow the server’s own resolver settings on Windows, and the check works on Linux, where every result had been TempError. Only a fail scores, as before |
| 6376 | DKIM | RSA signing and verification |
| 8463 | Ed25519 DKIM | New in 6.x. Modern short-key signatures, alongside RSA |
| 7489 | DMARC | New in 6.x. Policy evaluation on inbound mail. Since 6.3.4 the aggregate reports other servers send about your own domains are also read and charted, once a domain names a report mailbox (§8.6) |
| 8617 | ARC | New in 6.x. Sealing of mail this server relays onward, a forwarded copy included, as well as mail from a hosted domain, with a hosted domain’s DKIM key; off until ArcSealingEnabled is set on Certificates & encryption → Transport security (chapter 26) |
| 8461 | MTA-STS | New in 6.x. Honoured outbound; hMailServer can also host your own policy |
| 8460 | TLS-RPT | New in 6.x. Daily reports to recipient domains about TLS failures encountered delivering to them, sent once a sender address is set (TlsRptFromAddress, §9.4); until then the statistics are collected and nothing is sent. Since 6.3.4 the reports other servers send about your own domains are read and charted too, once a domain names a report mailbox (§8.6) |
| 6698 / 7672 | DANE / TLSA for SMTP | New in 6.x. Certificate pinning in DNS, enforced outbound |
| 4033–4035 / 5155 | DNSSEC | New in 6.x. In-process validation of the server’s own lookups: the MX and TLSA records DANE rests on, DKIM keys and DMARC policies and, since 6.3.4, SPF records, which until then did not go through the server’s resolver. Corrected in 6.2.27: a DS query answered with nothing used to be treated as an unsigned delegation — which is exactly what a stripping attacker shows a resolver. The validator now requires the parent’s proof of absence in the authority section (RFC 4035 §5.2, RFC 5155 §8), and a delegation without one under a signed parent is Bogus, not Insecure. Diagnostics.DnssecChainStatus reports what it concluded |
| 8555 | ACME | New in 6.x. Automatic Let’s Encrypt certificates |
| 5246 / 8446 | TLS 1.2 / TLS 1.3 | The only protocol versions enabled by default — TLS 1.3 support and the 1.2/1.3-only default are new in 6.x |
| 8314 | Implicit TLS | Ports 465, 993 and 995 |
New in 6.x. SRS and BATV are also implemented (chapter 17), but neither ever became an RFC — both follow the widely deployed draft specifications, as does everything that interoperates with them. In practice the missing RFC number changes nothing.
Key exchange. The default group list is
X25519MLKEM768:SecP256r1MLKEM768:X25519:secp384r1:secp256r1 — post-quantum hybrids
first, classical curves behind them (TlsKeyExchangeGroups, §21). An OpenSSL older than
those hybrids rejects the list and the fallback handles it; since 6.3.0 that condition is
reported once rather than once per listener and once per outbound delivery.
Message encoding
Message bodies and headers are handled per MIME (RFC 2045–2047): encoded body parts, encoded words for non-ASCII header text, and RFC 2231 parameter encoding for things like attachment file names in other character sets. With SMTPUTF8 (above), headers can also simply be UTF-8.
34.2 Seeing it for yourself
Do not take a table’s word for it — ask the server. Any machine with OpenSSL will do; it does not have to be the server itself.
For SMTP, connect and read the EHLO response:
openssl s_client -connect mail.yourcompany.com:25 -starttls smtp -quiet
Type EHLO test.example.org and the server lists its capabilities — something like:
250-mail.yourcompany.com
250-SIZE 20971520
250-8BITMIME
250-PIPELINING
250-CHUNKING
250-BINARYMIME
250-SMTPUTF8
250-ENHANCEDSTATUSCODES
250-DSN
250-AUTH LOGIN PLAIN SCRAM-SHA-256 SCRAM-SHA-256-PLUS
250 HELP
For IMAP:
openssl s_client -connect mail.yourcompany.com:993 -quiet
A new installation has no port 993 until you add one; its IMAP listener is 143, with STARTTLS, which openssl s_client -connect mail.yourcompany.com:143 -starttls imap -quiet reaches.
Type a CAPABILITY and you will see something like (one line on the wire, wrapped
here):
* CAPABILITY IMAP4 IMAP4rev1 IMAP4rev2 CHILDREN IDLE QUOTA QUOTA=RES-STORAGE SORT ACL
AUTH=PLAIN AUTH=SCRAM-SHA-256 AUTH=SCRAM-SHA-256-PLUS SASL-IR NAMESPACE
RIGHTS=texk MOVE ID SPECIAL-USE CREATE-SPECIAL-USE UNSELECT UIDPLUS ENABLE STATUS=SIZE
ESEARCH SEARCHRES CONDSTORE QRESYNC LIST-EXTENDED LIST-STATUS UTF8=ACCEPT WITHIN
THREAD=ORDEREDSUBJECT THREAD=REFERENCES APPENDLIMIT LITERAL- PREVIEW MULTIAPPEND
REPLACE SAVEDATE METADATA OBJECTID BINARY
a OK CAPABILITY completed
The exact lists vary with your settings and the port you connect to. SMTP’s AUTH PLAIN appears
only once plain-text authentication is allowed on the SMTP tab of Connections & protocols → Protocols,
and IMAP’s AUTH= mechanisms and SASL-IR only once they are switched on, on its IMAP tab; each
is off as shipped. AUTH=EXTERNAL appears only when the client presents a certificate that the port’s CA
verifies and that names an address. Since 6.3.4 COMPRESS=DEFLATE is offered only after sign-in, beside
UNAUTHENTICATE and the account’s exact APPENDLIMIT= value, so ask for CAPABILITY
again after LOGIN to see them. If something you expect is missing, check those tabs and the port’s
configuration under Connections & protocols → TCP/IP ports before assuming a fault.
More: IMAP and POP3 on the server: capabilities, switches, shared mailboxes, search limits on the forum.
34.3 IMAP details worth knowing
Server-side sorting (SORT)
Without SORT, a client that wants the ten newest messages in a 10,000-message folder must download headers for all 10,000 and sort them itself. With SORT, it asks the server for the ten newest and gets exactly that. On large mailboxes the difference is dramatic, which is why every serious IMAP client uses it when offered.
The THREAD half of RFC 5256 — server-side conversation threading — is implemented
as of 6.2.20, with both algorithms: ORDEREDSUBJECT and REFERENCES (real
References-chain threading). Clients that ask for conversation trees get them from the
server; clients that thread locally carry on as before. Changed in 6.3.4: SORT uses every
criterion a client gives it, REVERSE reverses only the criterion it precedes, and ties go to sequence
order; both THREAD algorithms build the trees the RFC describes. A client can therefore list the same folder
in a different order than under 6.3.3.
Push mail (IDLE)
A client that issues IDLE on a folder is told the moment a message arrives or changes,
instead of polling. This is how phones and desktop clients get near-instant new-mail
notification from hMailServer with no extra configuration.
One protocol limitation to know: IDLE reports changes within the selected folder. There is no standard mechanism for telling a connected client that another client has created a new folder — the second client sees it the next time it refreshes its folder list. This is IMAP, not a fault.
Who names the folders (SPECIAL-USE)
hMailServer does not decide that your sent mail lives in a folder called “Sent items” — the client creates that folder and files messages into it. Historically this meant two clients on one account could each create their own sent folder with different names.
The SPECIAL-USE extension fixes the ambiguity: folders carry role attributes
(\Sent, \Trash, \Junk, \Drafts, \Archive), so any client that supports the
extension agrees on which folder does what, whatever it is displayed as. If a user wants
a folder renamed, they rename it in their mail client — not on the server.
Atomic moves (MOVE)
Before MOVE, “move to folder” was three operations — copy, flag as deleted, expunge —
and a dropped connection halfway could leave the message in both places or flagged in
neither. MOVE does it as one atomic operation. Clients use it automatically when
advertised.
Identification (ID)
ID lets client and server exchange implementation names and versions. Its practical
value is diagnostic: when one particular client misbehaves, the ID exchange in the IMAP
log tells you exactly which client and version you are dealing with.
Quota and ACL
The QUOTA extension exposes the account’s Maximum size setting to the client, so users can see how full their mailbox is without asking you. ACL is what public folders (chapter 13) use for per-account and per-group rights.
The folder separator is a dot
By default, hMailServer uses . as the IMAP folder hierarchy separator: Projects.2026
is a folder named 2026 inside Projects.
Consequence: with the default separator, a folder name cannot itself contain a dot. A user trying to create a folder called
example.comwill getexamplecontainingcom. The separator is configurable —Settings.IMAPHierarchyDelimiterin the COM API (chapter 23) — and hMailServer refuses to change it while any existing folder name contains the new character. Change it on a new server or not at all; connected clients cache folder lists built with the old separator.
34.4 IPv6
hMailServer is dual-stack: IPv4 and IPv6 both work, for both delivery and client access. There is no IPv6 on/off switch in hMailServer itself — an operating system with IPv6 enabled is all it needs, on either platform. The one wrinkle: the service takes stock of the operating system’s capabilities when it starts, so if IPv6 is switched on after installation, restart the service before expecting it to be used.
One Linux-specific caveat: the server’s own DNS client passes over IPv6 nameservers in
/etc/resolv.confwhile the file lists an IPv4 one, and up to 6.3.5 always, so a resolver reachable only over IPv6 was not used for lookups; from 6.3.6 the first two IPv6 nameservers are asked when no IPv4 one is listed (§32.3). That is about resolving names, not about delivering over IPv6, which works.
Everywhere an address is accepted — TCP/IP ports, IP ranges, incoming relays, the SMTP
relayer, routes — both IPv4 and IPv6 forms are valid, and the database stores either. The one
exception is a name server of your own (DNSServer, on Connections & protocols → DNS resolver),
which is a single IPv4 address: an IPv6 one is reported as HM4401 and the system’s resolver is used
instead.
Outbound needs no configuration: with IPv6 available on the machine, hMailServer can
deliver to IPv6 destinations as it is. By default it resolves IPv4 addresses first;
IPv6 preferred (on the General tab of Maintenance → Advanced;
Settings.IPv6PreferredEnabled in the COM API) makes delivery look up AAAA records before
A records. Changed in 6.3.4: an outbound bind address (Bind outbound connections to
IP, on the Delivery tab of Mail flow & delivery → Delivery of e-mail) may name one address of
each family, separated by a comma, and the one of the destination’s family is used; an address of one family
no longer stops delivery to the other.
Listening on IPv6 is not enabled by default. Two steps:
- Add ports. Under Connections & protocols → TCP/IP ports, add a port per protocol
bound to the IPv6 address you want to listen on. The IPv6 equivalent of
0.0.0.0(all addresses) is::. - Add IP ranges. Under Sign-in & access → IP ranges, add an IPv6 internet
range from
::toffff:ffff:ffff:ffff:ffff:ffff:ffff:ffffwith the same settings as your existing internet range, and a loopback range::1to::1matching “My computer”.
The confusing failure mode: with a port but no matching IP range, hMailServer accepts the IPv6 connection and then immediately drops it, because no range grants the client anything. If IPv6 clients connect and instantly disconnect, this is why.
Two words of caution. Most mail on the internet still moves over IPv4, so IPv6 is an addition, never a replacement — your IPv4 setup must stay correct. And the large providers police IPv6 senders more strictly than IPv4: expect a missing PTR record or unaligned SPF/DKIM to be refused over IPv6 where IPv4 might have been tolerated.
34.5 Frequently asked questions
Does hMailServer run on anything other than Windows?
Yes, since 6.3.0 — Linux on x86-64 and AArch64, built from the same source tree as
the Windows build, which is unchanged. It is packaged as a .deb, an .rpm and
an AppImage, with a PKGBUILD for Arch in the source tree — 6.3.8 for both architectures, with a .deb or
an .rpm of each supported Debian, Ubuntu, Fedora, Enterprise Linux and openSUSE release’s own, the
AArch64 ones cross-compiled and tested under emulation, and a container image for linux/amd64 and linux/arm64; a
Nix flake and an Alpine APKBUILD are in the source tree. The packages install a systemd unit that runs the server as its own user, and configuration
under /etc/hmailserver. 64-bit only either way; 32-bit builds were dropped in 6.0.
Chapter 35 is the walk-through.
What differs, stated plainly:
- No Control Panel and no COM. A Linux server is administered through the
hmailservercommand line, the REST API (chapter 24) — withhmctl, a command-line client over it, since 6.3.4 — and the Control Deck in a browser (chapter 36). Its INI file keeps[Database],[Directories],[LDAP]and the other sections outside[Settings]. Since 6.3.4 the database holds the[Settings]keys (every one butPasswordPepper), changed over the REST API (PUT /api/v1/settings/ini/{name}, orhmctl ini set) or withhmailserver --set-setting NAME=VALUE; the Control Deck edits only the four of them its Settings view carries (chapter 21), and a value edited into the file is reported asHM5804and not applied. Event scripts run there in JavaScript since 6.3.4, in an engine built into the server; VBScript and JScript stay on Windows, whose scripting host runs them (§28.2). - PostgreSQL or MySQL/MariaDB only. Microsoft SQL Server and the built-in SQL Server Compact database are refused by name (§3.5).
- Per-domain DKIM signing is set over the REST API or on the Control Deck. Up to
6.3.2 there was no route that wrote it, so a domain that had to DKIM-sign its outbound mail was not
one to run on Linux; since 6.3.3
PUT /api/v1/domains/{domain}takes it with the rest of the domain (§24.15). - Moving a Windows installation to Linux is not a supported migration. The schema and the on-disk message format are the same on both, so the pieces exist — but there is no tested path, and this manual will not pretend otherwise (§18.2).
Do I need an internet connection to run it?
No. hMailServer runs happily on a closed network — as an internal mail system, or as a real SMTP/IMAP endpoint for testing software — with the obvious limitation that it cannot exchange mail with the outside world.
How many domains and accounts can one server handle?
hMailServer imposes no limit of its own. Capacity is set by hardware and usage patterns: installations with tens of thousands of accounts on a single server exist, and a hundred active IMAP users cost more than a thousand occasional POP3 users. For anything beyond a small installation, use a server database rather than the built-in one (§3.5).
Do I need separate pop., smtp. and imap. host names?
No. Technically, one name — mail.yourcompany.com — serves every protocol; all that
matters is that whatever name clients use resolves to your server and appears on your
TLS certificate (chapter 9). If you want users to type nothing at all, enable automatic
client configuration (§7.2) instead of multiplying host names.
If my server is offline for a few hours, is mail lost?
No. SMTP is store-and-forward: a sending server that cannot reach you queues the message and retries, typically for days, before giving up and bouncing it. Downtime delays inbound mail; it does not lose it. Only an outage longer than the sender’s retry window turns delay into a bounce.
Why are messages stored as files on disk rather than in the database?
Deliberately. Files stream to clients with flat memory usage, where database storage
would mean reading whole messages into memory; virus scanners want files to scan; and an
administrator can open any message in a text editor or delete it directly, no SQL
required. The database stores the index — accounts, folders, message metadata — and the
.eml files under Data are the mail. Since 6.3.4 three options, each off as shipped, change
what is there: storage compression keeps a message as a compressed container, mailbox encryption at rest
encrypts one account’s messages to a key of its own, and object storage keeps the files in an S3-compatible
store or Azure Blob Storage, the database still the index. Clients see no difference; a text editor, or a
script that opens Message.Filename, does.
Why doesn’t hMailServer store attachments separately?
By default it does not, because a message is not “a body plus attachments” — it is one continuous MIME byte
stream, delivered to clients exactly as received. Splitting attachments out means
decoding messages on arrival and rebuilding them on every fetch: more CPU and
more memory. Extracting attachments is the client’s job. Since 6.3.4 the server can,
off as shipped: with MessageStoreCompression on, a stored message is compressed and each attachment
of at least MessageStoreDedupMinKB (256 KB once decoded, unless changed) is kept once for every message
on the server that carries the same bytes. Clients still receive the original message byte for byte. The switch
is on the Message store tab of Maintenance → Performance, and applies to the next message stored.
More: Message storage: compression, de-duplication, object storage and encryption at rest on the forum.
Why are there empty two-character directories in the data directory?
Message files are split across sub-directories named 00–FF under each account,
because Windows handles enormous directories poorly and busy mailboxes would otherwise
put tens of thousands of files in one folder. The sub-directories are created on demand
and only removed when the account is deleted, so empty ones are normal and harmless.
There are at most 256 per account.
Can my users have non-ASCII email addresses?
Yes — SMTPUTF8 (RFC 6531/6532, new in 6.x) is advertised automatically, so
addresses like bjö[email protected] work. The caveat is the rest of the world: every
server a message crosses must also support SMTPUTF8, and one that does not cannot accept
mail involving a Unicode address at all. Give internationalised users an ASCII address
as well until the domains they write to have caught up.
Can printers, scanners and applications send mail through hMailServer?
Yes. Create a dedicated account for the device, and have it submit on port 587 with
authentication — with TLS, which a new 6.3.4 installation requires before a password from any address but
this computer’s (the Internet IP range’s Require SSL/TLS when authenticating). A device that
cannot start TLS is refused at sign-in until an IP range covering its own address alone leaves that option
off, and its password then crosses your network in the clear. Do not solve this by letting the
device’s IP range relay without authentication; that is one careless subnet away from an
open relay (chapter 17). An application can instead use the send API that 6.3.4 added: an API key made a
sender, with an address and ceilings of its own, posts to /api/v1/send.
What happened to PHPWebAdmin and the classic Administrator?
Both are gone. The classic Administrator was retired in 6.2.0 in favour of the Control Panel, which does everything it did and more; PHPWebAdmin was removed outright in 6.2.10 because it stored the administrator password in plaintext and required DCOM opened up for the web server. Chapter 18 covers what replaced them and how the transition works.
Is the fork still compatible with software written for the original hMailServer?
Yes, by design. Everything the original exposed through the COM API is still there — the
fork adds to it (the Diagnostics and MessageIndexing objects, Account.SieveScript,
and more; chapter 23) but removes nothing — mail clients see the same protocols plus new
extensions, and almost everything the fork adds is additive and off by default. The one
behavioural change worth knowing before 6.3.4: since 6.2.10, COM methods correctly report failure on
refused calls where they previously claimed success — scripts that check return values
(as they always should have) now find out when a call was denied (chapter 23). 6.3.4 adds more a
program can notice, among them: a COM change to a route, an incoming relay or another shared
configuration object takes effect at Save(); IMAP answers some commands as the RFCs say
where it did not; a one-time code is taken once, so a script that sent the administrator’s password
and a code with every REST request must now sign in once (POST /api/v1/session) or use an API key,
or only its first request in each half minute is accepted; and a [Settings] line edited in
hMailServer.ini is ignored in favour of the value the database holds, reported as HM5804
and, where the service may write the file, put back (§18.5).
35. Running hMailServer on Linux
hMailServer has been a Windows program since 2002. Since 6.3.0 it is not only that: the
same source tree builds, installs, runs and delivers mail on x86-64 and AArch64 Linux, as a
systemd service, from a .deb, an .rpm or an Arch
PKGBUILD. That is why the release is numbered 6.3 and not 6.2.29. The Windows
build is unchanged in what it compiles and how it behaves.
What crossed is the server — the SMTP, POP3 and IMAP engines, delivery, routing, rules, anti-spam, anti-virus, ACME, the backup manager, the REST API, the Control Deck and the self-service portal. What did not cross is everything built on Windows-only interfaces: the Control Panel, the COM API, the VBScript and JScript event engine and the SQL Server backends (6.3.4 brought event scripts across in JavaScript instead). This chapter says where that line falls, then takes you from a bare machine to a running mail server.
Read §35.1 before you install anything. There are capabilities this platform does not have today, and finding that out after the mailboxes are populated is the wrong order.
Everything in chapters 1–34 that is not about the Control Panel still applies: the
protocols, the delivery path, DNS, DKIM, SPF, DMARC, rules, routes and the settings in
chapter 21 are the same code with the same behaviour. Where a chapter tells you to click
something in the Control Panel, the equivalent here is the REST API (chapter 24), the
Control Deck (chapter 36) or, since 6.3.4, hmctl, the command-line client over that API that the packages install as /usr/bin/hmctl. Where it names a path under C:\Program Files\hMailServer,
§35.6 gives the Linux one.
35.1 What runs on Linux, and what does not
What is not there
| Missing | What it means for you |
|---|---|
| VBScript and JScript event scripts. | Up to 6.3.3 there were no event scripts here at all: the script engine is the Windows Active Scripting host, any non-empty script file was reported as uncompilable, and no handler flag was ever believed to be registered. Since 6.3.4 a Linux server runs event scripts in JavaScript, in the QuickJS engine built into the server, for the same events under the same function names with the same objects, less the members only COM can offer (a message’s Attachments and Headers among them; §28.2); VBScript and JScript are refused at load. The language setting is seeded VBScript on every platform, so set it to JavaScript before switching scripting on. |
| The COM API (chapter 23). | The ATL administration API is not compiled. Every third-party COM script and every tool built on one stops at the Windows boundary. |
| The Control Panel and the Windows tools. | Every .NET project under source/Tools — ten of them — targets net10.0-windows. No Control Panel, no DBSetup, no DBSetupQuick, no DBUpdater, no Import Tool, no Data Directory Synchronizer. The database tools are replaced by --create-database and --upgrade-database, and the Control Panel by the Control Deck and the REST API (§35.10). Since 6.3.4 accounts can be imported from a spreadsheet over REST and with hmctl; the Import Tool’s mbox and Maildir imports and the Data Directory Synchronizer have no replacement. |
| SQL Server and SQL Server Compact. | Refused by name, in two places, and neither refusal is a connection error. PostgreSQL and MySQL/MariaDB are the backends here (§35.4). |
Message catalogues (Languages). |
Not shipped; their only reader is the COM interface. The installation-paths diagnostic prints the line and does not count its absence as a failure. |
| Self-update, up to 6.3.3. | The update checker works and knows which package your machine would install, but a server from 6.3.0 to 6.3.3 reads the former GitHub repository’s feed, which has answered 404 since 18 September 2026, until UpdateFeedUrl=https://updates.progressiverobot.com/releases.json points it at the project’s own; 6.3.4 reads that one without being told. Up to 6.3.3 the apply step refuses with a Medium 6401: the helper it runs is a Windows program. Since 6.3.4 the apply runs through a root helper outside the server’s unit (§35.13) — but 6.3.4 itself carries no Sigstore bundle, so it is installed with apt, dnf or pacman like the releases before it. |
| Negotiate binds for LDAP. | [LDAP] BindMethod=1 is Windows SSPI. It reports HM6420 once and refuses the logon as a directory failure rather than a wrong password. Use BindMethod=0 with Security=2 (LDAPS) or 1 (StartTLS). A GSSAPI bind through Cyrus SASL is not written, the directory’s certificate must chain to the system trust store, and there is no private-CA setting. |
| A re-keying command. | The stored-secret key file is made once and never rotated. Replacing it by hand means re-entering every stored password; nothing re-encrypts them. Since 6.3.4 the stored-secrets key’s line in hMailServer.ini is sealed under it too, so a replaced key file also means importing that key again from its export (sudo hmailserver --import-stored-secrets-key FILE, then sudo systemctl restart hmailserver). |
| A tested path from Windows. | Moving a Windows installation to Linux is not a supported migration (§35.14). |
| AArch64 on real hardware. | Up to 6.3.3 the ARM packages were built and installed by CI on GitHub’s ARM runners on every push. GitHub suspended the project’s account on 18 September 2026 and the project has no ARM machine, so 6.3.4 and 6.3.5 have no AArch64 package. From 6.3.6 GitLab CI cross-compiles the AArch64 packages and AppImage and installs and runs them under emulation (qemu-user), and the release carries them. Nobody has installed an ARM package on ARM hardware yet. |
What is the same as Windows
- The protocol engines and delivery: SMTP, POP3, IMAP, submission, the queue, routing, rules, greylisting, SPF, DKIM verification, DMARC, ARC, MTA-STS, TLS-RPT, DANE, the anti-virus integrations and the archive.
- The database schema — 6108 at 6.3.8, the same number the Windows build needs.
- The on-disk message format: one file per message, one directory per domain and per mailbox.
- The REST API (chapter 24), the Control Deck and the portal (chapter 36). The Deck is the
same
index.htmlthe Windows installer ships — one file in the repository — and CI compares the page the Linux server serves against it byte for byte. - ACME, the backup manager, the diagnostics, the crash record and the log format.
- Directory authentication as a simple bind over LDAPS or StartTLS. The OpenLDAP arm records the same result codes, so the wrong-password versus directory-down classification is the same decision on both platforms.
Stored secrets work differently, and better. Windows protects route, fetch-account and relay passwords, private-key passphrases and the administrator’s TOTP secret with machine-scoped DPAPI, which does not travel. Here they are AES-256-GCM under a key file the installation owns, so the data directory and the database move together and every stored password opens on the new machine. The price is that the key file is part of the backup (§35.12). Since 6.3.4 what the database holds and every node must open — each account’s TOTP second factor and the
[Settings]secrets — is sealed under the installation’s stored-secrets key instead (HMK1:), once one is made (§35.3, step 5). Its line inhMailServer.iniis sealed under the key file, so that key travels in its export, or in the configuration backed up together with the key file.
35.2 Supported platforms and packages
x86-64 and AArch64. Up to 6.3.3 both were built natively in CI on GitHub’s
runners, ubuntu-latest and ubuntu-24.04-arm, and both were packaged. 6.3.4 was built
on the project’s own x86-64 machine and 6.3.5 by the project’s GitLab CI, and both were published for
x86-64 alone. From 6.3.6 the release carries both again: GitLab CI builds each package in its distribution
release’s own container, cross-compiles the AArch64 ones, and installs and starts every one in a clean container
of its release, the AArch64 ones under emulation (qemu-user), since the project has no ARM machine. No other
architecture is built.
| Distribution | Package | Notes |
|---|---|---|
Debian 12 and 13, Ubuntu 24.04 and 26.04 (from 6.3.6: a .deb of Debian 12’s, Debian 13’s and Ubuntu 24.04’s own, and the generic one, built on Ubuntu 26.04, for each architecture). Up to 6.3.5 only the generic one, which needs glibc 2.38 or later, so not Debian 12. |
.deb |
Library dependencies resolved by dpkg-shlibdeps, so libpq comes with it. Maintainer scripts create the user, the directories and the systemd enablement. |
Fedora, RHEL and derivatives (from 6.3.6: an .rpm of Fedora 44’s and 43’s and Enterprise Linux 10’s and 9’s own, for each architecture, and the generic one. Up to 6.3.5 only the generic x86-64 one, which Enterprise Linux 9 refuses, its glibc and libstdc++ being older than the package asks for) |
.rpm |
rpm’s own auto-requires; scriptlets do the same work in the shape rpm expects. |
| openSUSE and SUSE | .rpm |
From 6.3.6: an .rpm of Leap 16.0’s and Tumbleweed’s own, for each architecture. Up to 6.3.5 the generic RPM, not tried there: zypper is not dnf. |
| Arch | PKGBUILD |
makepkg -si, from the release’s source tarball since 6.3.6 (from the git tag before), built without the live update. No maintainer scripts: a sysusers.d file declares the user and a tmpfiles.d file the directories, and pacman’s own hooks run them. Arch packages do not enable services, so there the unit is neither enabled nor started. |
| Alpine Linux | APKBUILD |
From 6.3.6 the server builds and runs on musl. platform/packaging/alpine/APKBUILD builds the server, an OpenRC service and hmctl from the release’s source tarball with abuild, without the live update, naming apk upgrade hmailserver. Not in Alpine’s archive yet. |
| NixOS and Nix | flake | From 6.3.6 a flake of the project’s own at the repository’s root, with a NixOS module, services.hmailserver: nix run gitlab:hmailserver/hmailserver/v6.3.8. Built without the live update, naming nix flake update hmailserver. Not in nixpkgs. |
The generic x86-64 .rpm is built on Ubuntu, so its automatic requires name Ubuntu sonames; CI
installs it in a Fedora container with --nodeps for that reason. The release’s own package of each
Fedora, Enterprise Linux and openSUSE release is built in that release’s container, and is the one to install. On Fedora 43 or 44 or Enterprise Linux 10, dnf install ./<file>.rpm resolves the real dependencies from your own repositories. Enterprise Linux 9 refuses the generic package, because its glibc and libstdc++ are older than the package asks for; from 6.3.6 it has its own. From 6.3.6 every .rpm asks for glibc’s extra character-set converters, glibc-gconv-extra on Fedora and Enterprise Linux and glibc-gconv-modules-extra on openSUSE, which glibc only recommends and the distributions’ container images lack: required where dnf is installed on Fedora or Enterprise Linux, so dnf installs them with the package, and recommended elsewhere, since on openSUSE the live update installs with rpm -Uvh, which installs nothing beside the package (zypper installs the recommendation unless solver.onlyRequires is on). Without them the server converts no text in a character set but Unicode’s, US-ASCII, UTF-7, ISO-8859-1, ISO-8859-15 and Windows-1252, which every .rpm up to 6.3.5 did silently; from 6.3.6 the server says so at its start and in hmailserver --check-config. On Fedora or Enterprise Linux a server without them whose dnf has no repository enabled (an unregistered RHEL, say) has the live update to 6.3.6 or later refused until glibc-gconv-extra is installed. An AppImage runs on the host’s glibc and cannot require the package: install it by hand.
Up to 6.3.1 the packages installed only on the distribution release they were built on. 6.3.0 and 6.3.1 linked Boost dynamically, and a Boost library’s soname carries its version (
libboost_thread.so.1.83.0), so the.deb— built on Ubuntu 24.04 — was refused by Ubuntu 26.04 on 11 September 2026 for five Boost sonames and nothing else. Up to 6.3.1, match the distribution release, or build from source. Linking Boost statically, which removes the dependency altogether, is in the tree and is not in 6.3.1. From 6.3.2 Boost is linked statically and the package installs on the next distribution release as well; CI installs the.debon Ubuntu 26.04 on every run. 6.3.5 is built on Ubuntu 26.04, in the CI imagehmailserver-ci/linux:26.04-dad0ab749505, as 6.3.4 was, and its packages ask for what 6.3.4’s did. Its.debinstalls on Ubuntu 26.04 and 24.04 and asks for glibc 2.38 or later, so it does not install on Debian 12. Its.rpminstalls on Fedora 43 and 44 and Enterprise Linux 10 and is refused by Enterprise Linux 9, whose glibc and libstdc++ are older than it asks for. On anything older, build from source.
The release assets
One release carries every platform at one version. The current release is
6.3.8; its page, which
updates.progressiverobot.com/download/ leads to,
lists every file with its SHA-256, and every file has a Sigstore bundle beside it,
<file>.cosign.bundle.
/download/hmailserver_amd64.deb
and /download/hmailserver.x86_64.rpm
on the same site lead to the newest stable release’s packages, and
/download/hmailserver_arm64.deb, /download/hmailserver.aarch64.rpm,
/download/hMailServer-x86_64.AppImage and /download/hMailServer-aarch64.AppImage to the
others. The set is:
| Asset | For |
|---|---|
hmailserver_6.3.8_amd64.deb, hmailserver_6.3.8_arm64.deb |
The generic .deb, Ubuntu 26.04’s build — x86-64 and AArch64; the names a server’s update check asks for |
hmailserver_6.3.8-1~<release>_<arch>.deb |
Debian 12 (debian12), Debian 13 (debian13) and Ubuntu 24.04 (ubuntu24.04), each release’s own — amd64 and arm64 |
hmailserver-6.3.8-1.x86_64.rpm, hmailserver-6.3.8-1.aarch64.rpm |
The generic .rpm: the x86-64 one built on Ubuntu, the AArch64 one Fedora 44’s build; the names a server’s update check asks for |
hmailserver-6.3.8-1.<release>.<arch>.rpm |
Fedora 44 and 43 (fc44, fc43), Enterprise Linux 10 and 9 (el10, el9), openSUSE Leap 16.0 (suse.lp160) and Tumbleweed (suse.tw), each release’s own — x86_64 and aarch64 |
hMailServer-6.3.8-x86_64.AppImage, -aarch64.AppImage |
Try it out only — see below |
hmailserver-6.3.8-appimage-runtime-sources.tar.gz, hmailserver-6.3.8-appimage-bundled-sources.tar.gz |
The source of the libraries linked into the AppImages’ runtime and of those they carry in usr/lib, which their licences ask for |
hmailserver-6.3.8.tar.gz |
The source tarball, reproducible, with nothing non-free in it |
hmailserver-chart-6.3.8.tgz |
The Helm chart, naming this release’s container image |
hmailserver-linux-6.3.8-SHA256SUMS.txt |
Checksums of every Linux file, the two sources files and the chart (6.3.5’s covered its two packages; 6.3.4’s was hmailserver-6.3.4-SHA256SUMS.txt, for every file, with an SSH signature) |
hMailServer-6.3.8-x64.exe, hMailServer-6.3.8-arm64.exe |
The Windows installers, x64 and ARM64 (a preview), on the same release |
hmailserver.spdx.json, hmailserver.cyclonedx.json |
Software bills of material of the source tree |
<file>.cdx.json |
From 6.3.4, the software bill of materials of the installer, the .deb and the .rpm, one each, beside it; from 6.3.6, of every compiled file and the tarball |
<file>.cosign.bundle |
From 6.3.5, each file’s Sigstore bundle, beside it; from 6.3.6 also as <file>.sigstore.json, byte for byte, beside each file but a compiled file’s or the tarball’s SBOM, and neither beside the provenance, itself a bundle |
hmailserver-6.3.8.intoto.sigstore.json |
From 6.3.6, the release’s SLSA v1 build provenance, naming every file above by its SHA-256 |
hmailserver-6.3.8.intoto.jsonl |
From 6.3.8, the provenance’s DSSE envelope as one line of JSON, the form OpenSSF Scorecard counts as a release’s provenance |
The names are load-bearing. A Linux server’s update checker builds the name its package manager and architecture would install — it probes for
/usr/bin/dpkg, then/usr/bin/rpm— and matches an asset on exact equality, so CPack’s names are uploaded as they are and never tidied. Note the RPM’s-1release field: the asset ishmailserver-6.3.8-1.x86_64.rpm. The 6.3.0 release notes’ upgrading section wrote it without the-1; the release page is right.
Signatures, and which one applies here. Since 6.3.1 the Windows installer
on the same release carries an Authenticode signature, issued to Progressive Robot Ltd and
countersigned. None of the Linux assets does, and none ever will: there is no
Authenticode for a .deb, an .rpm or an AppImage. Up to 6.3.3 what covers every
asset on both platforms is Sigstore, keylessly — each file has a .cosign.bundle
beside it, and verifying against that bundle is the check that means something here. 6.3.4 has no
bundle: it was built and signed by hand after GitHub suspended the project’s account, so on Linux
its check is hmailserver-6.3.4-SHA256SUMS.txt and the SSH signature beside it, made with the
key that signs the release tags. ssh-keygen -Y verify (OpenSSH 8.1 or later) checks the signature
against .gitlab/allowed_signers in a clone of the repository, and must say
Good "file" signature for [email protected]. The
Windows installers on releases before 6.3.1 carry no signature and never will, because a
published release is immutable. And the signature buys less on Windows than it sounds like it
should: the elevation prompt names the publisher instead of reading Unknown publisher,
but SmartScreen still warns, because reputation attaches to a file that does
not change and a new installer every few weeks never is one.
ssh-keygen -Y verify -f .gitlab/allowed_signers -I [email protected] -n file -s hmailserver-6.3.4-SHA256SUMS.txt.sig < hmailserver-6.3.4-SHA256SUMS.txt
sha256sum -c --ignore-missing hmailserver-6.3.4-SHA256SUMS.txt
From 6.3.5 every file has a bundle again, made by the project’s GitLab CI pipeline for
the release’s tag and checked with the server’s own verifier before the release was published. For
6.3.8’s packages, signed at the project’s path in the group hmailserver:
sha256sum -c --ignore-missing hmailserver-linux-6.3.8-SHA256SUMS.txt
cosign verify-blob hmailserver_6.3.8_amd64.deb \
--bundle hmailserver_6.3.8_amd64.deb.cosign.bundle \
--certificate-identity 'https://gitlab.com/hmailserver/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.8' \
--certificate-oidc-issuer https://gitlab.com
cosign verify-blob-attestation --new-bundle-format \
--bundle hmailserver-6.3.8.intoto.sigstore.json --type slsaprovenance1 \
--certificate-identity 'https://gitlab.com/hmailserver/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.8' \
--certificate-oidc-issuer https://gitlab.com hmailserver_6.3.8_amd64.deb
The second checks the file against the release’s build provenance, and says Verified OK only when
the file’s SHA-256 is one it names. Any other file of the release is checked the same way. On a server from 6.3.4
on, hmailserver --verify-update <file>, with the bundle beside the file, makes the check its live
update makes and prints the identity, which must end in @refs/tags/v6.3.8. 6.3.5 was signed before the
project moved, so its identity names the project’s former path:
sha256sum -c --ignore-missing hmailserver-linux-6.3.5-SHA256SUMS.txt
cosign verify-blob hmailserver_6.3.5_amd64.deb \
--bundle hmailserver_6.3.5_amd64.deb.cosign.bundle \
--certificate-identity 'https://gitlab.com/Progressiverobot/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.5' \
--certificate-oidc-issuer https://gitlab.com
cosign verify-blob hmailserver-6.3.5-1.x86_64.rpm \
--bundle hmailserver-6.3.5-1.x86_64.rpm.cosign.bundle \
--certificate-identity 'https://gitlab.com/Progressiverobot/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.5' \
--certificate-oidc-issuer https://gitlab.com
For 6.3.3 and the releases before it, the bundle as well:
sha256sum -c --ignore-missing hmailserver-linux-6.3.3-SHA256SUMS.txt
cosign verify-blob hmailserver_6.3.3_amd64.deb \
--bundle hmailserver_6.3.3_amd64.deb.cosign.bundle \
--certificate-identity-regexp '^https://github\.com/Progressiverobot/hmailserver/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
The PKGBUILD, and what the AppImage is for
The Arch PKGBUILD lives in the tree at
hmailserver/source/Server/platform/packaging/PKGBUILD. It fetches the tag from
the public repository rather than using whatever tree it was copied out of, which is what
makes the result reproducible by somebody who does not have your working copy. From 6.3.6 what it fetches is the
release’s source tarball, from the project’s package registry, and it builds the server as a distribution does,
without the live update, naming makepkg -si as the way to update. It is not a release file.
The AppImage is not for running mail. It runs as whoever started it — no
hmailserver user, no unit, none of the hardening in §35.8. Its store, logs and
configuration go under
${HMAILSERVER_HOME:-${XDG_DATA_HOME:-$HOME/.local/share}/hmailserver}, which is
exactly where a mail store should not be. It cannot bind port 25 — an
ordinary user has no CAP_NET_BIND_SERVICE, and the listener ports are database
rows rather than configuration keys — and it still needs a database, because there is no such
thing as a self-contained hMailServer. Use it to see the thing work on a laptop. Install the
.deb or the .rpm to run mail.
Building from source
# Debian, Ubuntu
sudo apt install cmake ninja-build clang \
libssl-dev zlib1g-dev libpq-dev libldap-dev \
libboost-thread-dev libboost-chrono-dev libboost-filesystem-dev libboost-regex-dev \
rpm file
# Fedora, RHEL
sudo dnf install cmake ninja-build clang \
openssl-devel zlib-devel libpq-devel openldap-devel boost-devel rpm-build dpkg
export CC=clang CXX=clang++
cmake -S hmailserver/source/Server -B build/linux -G Ninja \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr
cmake --build build/linux
cmake --build build/linux --target package # produces the .deb and the .rpm
The dependencies are OpenSSL 3.0 or newer, zlib, libpq, OpenLDAP (both
libldap and liblber, named explicitly so the link survives
--as-needed), Boost 1.83 or newer and iconv, which on glibc is part of the C
library. clang is a preference, not a requirement: GCC 13 and later builds
the same tree with export CC=gcc CXX=g++; CI built with both until GitHub became unavailable on 18 September 2026, and GitLab CI’s linux-build job builds with clang. The Arch
PKGBUILD still exports clang and still carries a comment saying GCC cannot
compile one of the headers; that comment is out of date — it was believed rather than
measured.
-DCMAKE_INSTALL_PREFIX=/usr matters more than it looks: only with that prefix
does GNUInstallDirs answer /etc and /var. With any
other prefix the configuration installs to <prefix>/etc/hmailserver, where
nothing reads it.
35.3 Installing
The whole sequence, in order. The package installs the server enabled and stopped, on purpose: it has no database yet, and nothing in a package can know which backend you have or its credentials. A server started here would fail to reach a database it has never been told about and would say so in the log once a second until somebody noticed.
1. Install the package
sudo apt install ./hmailserver_6.3.8-1~debian13_amd64.deb # your release's own .deb (§35.2); on Ubuntu 26.04: hmailserver_6.3.8_amd64.deb
sudo dnf install ./hmailserver-6.3.8-1.fc44.x86_64.rpm # your release's own .rpm: fc43, el10, el9; on openSUSE, zypper install ./hmailserver-6.3.8-1.suse.tw.x86_64.rpm
If your backend is MySQL or MariaDB, install its client library now — the server opens it at run time and no package manager will pull it in (§35.4). What the maintainer script then does, and nothing more:
| Step | What happens |
|---|---|
| Create the identity | The hmailserver system group and user: no password, no login shell, home /var/lib/hmailserver. |
| Create the directories | /var/lib/hmailserver with its temp, events and database subdirectories, and /var/log/hmailserver, all 0750 hmailserver:hmailserver; /etc/hmailserver at 0750 root:hmailserver and the configuration at 0640. The store underneath is deliberately not walked and not chowned — on a large installation that is a multi-hour pass over millions of files, and anywhere it would quietly undo a deliberate ownership choice. |
| Register the unit | daemon-reload, then enable hmailserver.service. Enabled, not started. Since 6.3.4 the .deb and the .rpm also enable and start hmailserver-update.path, the root path unit a live update is applied through (§35.13); nothing happens through it until the update check is on and an install is asked for. |
| Say what to do next | Printed on a first install only. |
2. Create the database user
sudo -u postgres createuser --pwprompt --createdb hmailserver
3. Edit the configuration
sudoedit /etc/hmailserver/hMailServer.ini
[Database]
Type=PostgreSQL
Server=localhost
Database=hmailserver
Username=hmailserver
Password=the-password-you-just-set
Passwordencryption=0
Port=5432
Internal=0
Leave [Security] AdministratorPassword alone. The next step writes it.
4. Set the administrator password
sudo hmailserver --set-admin-password
It reads the password from standard input with the terminal’s echo off, hashes it with
PBKDF2 — exactly as the Control Panel hashes it on Windows, by the same code — writes it into
the file preserving owner, group and mode, and zeroes its own copy. The password is never an
argument, so it is never in the process list or the shell history. This runs as
root, because the file is 0640 root:hmailserver. A pipe works for an
unattended install: printf '%s\n' "$PW" | sudo hmailserver --set-admin-password.
5. Create the schema
sudo -u hmailserver hmailserver --create-database
Since 6.3.4 a database this command creates is marked as a new installation’s, and it leaves a one-line file, .new-installation, in the DataFolder the configuration names; a database it finds already holding a schema gets neither. The first start then secures the new one (step 7). A DataFolder outside the unit’s ReadWritePaths (/var/lib/hmailserver and /var/log/hmailserver) is read-only to the service, so add it with a drop-in (sudo systemctl edit hmailserver, ReadWritePaths= under [Service]) before that first start.
The stored-secrets key (since 6.3.4). Each account’s TOTP second factor and the [Settings] secrets are sealed in the database under a key kept in hMailServer.ini, never in the database or a backup archive. Run as the service user, the command above cannot write the root-owned file, so it makes none and says so. Make it, and export it at once, to keep with the database backups but apart from them:
sudo hmailserver --create-stored-secrets-key
sudo hmailserver --export-stored-secrets-key /root/hmailserver-stored-secrets.key
Until a key is made those secrets are kept as written, and the setup checklist says so. A database dump restored without the key brings every second factor and [Settings] secret back unopenable.
6. Check it before starting anything
sudo -u hmailserver hmailserver --check-config
Configuration file: /etc/hmailserver/hMailServer.ini
Program directory: /usr/share/hmailserver/
Data directory: /var/lib/hmailserver
Log directory: /var/log/hmailserver
Database type: 3
Database type: 0 means the Type key did not take. Run it as the
service user and without --config, so the packaged server has to find
/etc/hmailserver/hMailServer.ini on its own — which is exactly what CI does, for
the same reason. Since 6.3.4, sudo -u hmailserver hmailserver --check-database goes one step further: it makes one connection with the [Database] settings, says whether it is encrypted and whether the certificate was checked, and exits 0 connected, 1 not, 2 not configured.
7. Start it
sudo systemctl start hmailserver
sudo systemctl status hmailserver
journalctl -u hmailserver -f
A freshly created database holds four listeners: SMTP on 25 and 587, POP3 on 110
and IMAP on 143, bound to every address. Up to 6.3.3 all four started with connection security set to none. Since 6.3.4 the first start secures a new installation: before every start the unit runs hmailserver --apply-new-install-defaults --if-marked, which does nothing unless step 5 left its .new-installation file. At the first start after step 5, if it comes within a day of it by the machine’s clock, that step gives the server a self-signed certificate (RSA 2048, 825 days, under /var/lib/hmailserver/Certificates), STARTTLS, offered and optional, on all four listeners, and TLS required for authentication on the Internet IP range, so that a password from another machine never crosses the network in the clear; the My computer range is left as it was. A first start more than a day after step 5 leaves the database alone; then run sudo -u hmailserver hmailserver --apply-new-install-defaults by hand, with the server stopped. Mail clients warn about the self-signed certificate: replace it with ACME or your own (§35.9). The listeners are
database rows, not configuration keys, so §35.9 and chapter 22 are where you turn them into
something you would expose to the internet.
8. Turn on the administration interface
[Settings]
RestApiPort=8045
RestApiBindAddress=127.0.0.1
Since 6.3.4 the server keeps those in the database. Lines that were in the file at step 3, before the first start, were taken in at that start; a line edited into the file after that start is reported in the error log as HM5804 and not applied, so the packaged RestApiPort=0 stands and a reload brings up no listener (chapter 21). Store them with the command line instead, as root:
sudo hmailserver --set-setting RestApiPort=8045
sudo hmailserver --set-setting RestApiBindAddress=127.0.0.1
sudo systemctl reload hmailserver
curl -u Administrator http://127.0.0.1:8045/api/v1/status
Then open http://127.0.0.1:8045/ for the Control Deck. §35.10 is the rest.
35.4 Creating the database
PostgreSQL
libpq is a link-time dependency. The packages declare it automatically and
your package manager installs it alongside; the Arch package names
postgresql-libs. Nothing else is needed.
# Either: let the service role create the database itself
sudo -u postgres createuser --pwprompt --createdb hmailserver
# Or, the tidier policy: no CREATEDB, and make the empty database yourself
sudo -u postgres createuser --pwprompt hmailserver
sudo -u postgres createdb --owner hmailserver hmailserver
For a database on another host, encrypt the connection. Both keys are commented out in the packaged file:
PostgreSQLSslMode=verify-full
PostgreSQLSslRootCert=/etc/ssl/certs/ca-certificates.crt
libpq’s own default is prefer, which encrypts when the server offers it and
verifies nothing. Set require at least, and verify-full with the CA
when the database is elsewhere. A mode the server does not recognise refuses the connection and
says so, rather than falling back to the default. Since 6.3.4 the [Database] keys TlsMode and TlsCaFile say the same for every backend; for PostgreSQL they become sslmode and sslrootcert when the two keys above are not set, and those two win when they are.
MySQL and MariaDB
The client is loaded with dlopen at run time rather than linked, so
nothing pulls it in for you.
| Distribution | Client package |
|---|---|
| Debian, Ubuntu | libmariadb3 |
| Fedora, RHEL | mariadb-connector-c |
| Arch | mariadb-libs |
The server asks the dynamic linker for four sonames, in this order:
libmariadb.so.3
libmysqlclient.so.21
libmysqlclient.so
libmariadb.so
MariaDB Connector/C is first because it is the client this program bundles on Windows and
the one most distributions install. There is no “the library beside the executable” here: a
shared object is found along the linker’s own search path — ld.so.conf,
LD_LIBRARY_PATH, the program’s RPATH — so what is named is a soname,
not a path. If none of the four loads, the server reports a Critical 5094
listing what it tried and the linker’s own last error.
[Database]
Type=MySQL
Server=localhost
Database=hmailserver
Username=hmailserver
Password=...
Port=3306
Write the port out. The value goes into the connection string as it stands and there is no useful default. PostgreSQL wants
5432, MySQL and MariaDB want3306. MySQL alone falls back to 3306 whenPort=0; PostgreSQL would try to connect to port 0.
Since 6.3.4 the connection is encrypted when the database server offers TLS. Up to 6.3.3 the server never asked MariaDB Connector/C for TLS, so the connection was plaintext even to a server that offered it. Now, with nothing set, it uses TLS whenever the server offers it, without checking the certificate, and connects without it when the server offers none. Under [Database], TlsMode=require refuses a connection without TLS (through MariaDB Connector/C, only after the login exchange) and TlsMode=verify-full also checks the certificate against TlsCaFile, a PEM file, or the system’s store when that is empty, but from 6.3.8, through MariaDB Connector/C, refuses a connection to a database server on this machine without TlsCaFile, with HM7535 (name in TlsCaFile the CA that issued the database server’s certificate, or set TlsMode=require); --check-database (§35.3, step 6) says what the connection got. The database server must be MySQL 5.7.9 or MariaDB 10.2.2 or later, since every table is utf8mb4 from schema 6102. Oracle’s libmysqlclient.so.24, MySQL 8.4’s client, is not among the four names above.
The Type key, and the two that are refused
Type= |
Reported as | On Linux |
|---|---|---|
PostgreSQL |
Database type: 3 |
Works, through libpq |
MySQL |
Database type: 1 |
Works, through the dlopened client |
MSSQL |
Database type: 2 |
Refused by name. SQL Server is reached through ADO, which is Windows-only |
MSSQLCE |
Database type: 4 |
Refused by name. SQL Server Compact is Windows-only |
| anything else, or empty | Database type: 0 |
The server refuses to start rather than half-starting |
Spellings are compared case-insensitively. The refusal happens twice over and neither is a
connection error. --create-database and --upgrade-database refuse
before touching the network, on standard error, exit 2 —
[Database] Type is SQL Server, which this build does not have: ADO and SQL Server Compact are
Windows. PostgreSQL and MySQL are the backends here.
— and at run time the connection factory reports a Critical 6390 rather
than quietly returning an empty connection the caller would dereference. The consequence is
worth stating plainly: an INI carried across from a Windows installation is repointed
here or it does not start. Internal=0 is likewise the only value that
works; Internal=1 marks the Windows installer’s built-in database, which is SQL Server Compact (and, in installations from long ago, the bundled MySQL server),
and there is none here.
What --create-database does
It is what DBSetupQuick does on Windows, without COM. It reads [Database],
refuses the two Windows backends by name, and then:
- Tries the named database first. If it opens and
hm_dbversionhas a row, the create script is not run: it must never run over an existing schema. Since 6.3.4 a database that already holds this build’s schema, or an older one, is left as it is with exit status 0 and nothing marked (an older one is moved on by--upgrade-database), and a newer one exits 1. Up to 6.3.3 it refused any existing schema, naming that version and--upgrade-database. - If it opens and holds no hMailServer schema — the usual shape when you ran
createdbyourself — it fills the empty database. - Otherwise it connects to the server with no database named, runs
CREATE DATABASEin the backend’s dialect, reconnects, and runsCreateTablesPGSQL.sqlorCreateTablesMySQL.sqlfrom<ProgramFolder>/DBScripts.
The create script produces the current schema outright, so a fresh database needs no upgrade afterwards.
Database hmailserver created on PostgreSQL at schema version 6105, from
/usr/share/hmailserver/DBScripts/CreateTablesPGSQL.sql.
Since 6.3.4 it goes on to say whether the first start will secure the new database (§35.3, step 7) and, run as the service user, that it made no stored-secrets key and what to run instead (step 5).
Proven, not assumed. The full sequence — create the schema, start the server, create an account over REST, submit a message over authenticated SMTP, deliver it, read it back over IMAP with its subject intact — was run against PostgreSQL 18 on 8 September 2026 and against MariaDB 11.8 on 9 September 2026.
35.5 The configuration file
One file, hMailServer.ini, with the sections and keys chapter 21 documents.
What differs here is where it is found and who may write it.
Where it is looked for, and in what order
--config <file>, if given. Applied before anything else reads the configuration, so it wins outright.hMailServer.inibeside the running executable — the directory/proc/self/exeresolves to. Tried first so a build tree or an unpacked tarball needs no argument./etc/hmailserver/hMailServer.ini— used when there is no file beside the executable and either that file exists or the directory/etc/hmailserverexists.
The second half of that last condition is deliberate. After an apt-get remove,
dpkg’s conffile record keeps the file from coming back on a reinstall; without the directory
test, --set-admin-password would create /usr/bin/hMailServer.ini and
write the hash there — a shadowing configuration nobody knows they have.
hmailserver --help states the same order.
The comment and encoding rules
The file is a from-scratch reimplementation of the Win32 profile API, so it keeps Win32’s rules rather than inventing POSIX ones:
- Only
;starts a comment, and only at the start of a line. - Text after a value on the same line is part of the value.
Type=PostgreSQL ; the backendsets the type to the stringPostgreSQL ; the backend, which matches no backend name, so the server reportsDatabase type: 0. This is why every note in the packaged file is on a line of its own. - A line beginning
#is a key whose name begins with#, as Win32 makes of it — not a comment. - UTF-8 without a byte order mark. “Narrow” means UTF-8 here, not a Windows ANSI code page; a file that is not valid UTF-8 is round-tripped as Latin-1 rather than turned into question marks.
- Comments, blank lines and key order all survive a write by the server, so an edited file stays an edited file.
- Section and key names match case-insensitively; whitespace around a key or value is separator, not content; a matching pair of quotation marks around a value is stripped.
- A missing file, section or key gives the caller’s default, silently. A key that is
present but empty does not — it reads as an empty string, and as
0where an integer is wanted. Since 6.3.4 neither applies to a[Settings]key the database holds: its stored value is used, so deleting the line does not return it to its default, and emptying it is reported as HM5804 and not applied (chapter 21).
Who may write it
Only root. The COM administration API, which rewrites it on Windows, is not in this build, so the file is
0640 root:hmailserver — the service reads it through the group and cannot write
it — the unit mounts /etc read-only for the service, and
Passwordencryption=0, plain text, is this file’s normal state here.
The command-line verbs that write it run as root for that reason: --set-admin-password and, since 6.3.4, --set-setting, --create-stored-secrets-key, --import-stored-secrets-key and --forget-sealed-secrets (§35.7). Since 6.3.4 [Settings] is the database’s copy (chapter 21), and the server writes each stored value back into it. Here it cannot, so the copy is not refreshed and the database’s values are used: a value stored over the REST API is in effect, but it is reported as HM5806 when it is stored and as HM5803 at every start until the key’s line in the file holds the same value. --set-setting writes the line as well as the database, which ends that report.
--check-configis a report, not a validator. It returns 0 unconditionally, so it cannot be a pass/fail gate in a script — read its output. It prints only three of the six directories. The diagnostic that lists all six and says whether each exists is Installation paths, reached over the API or the Control Deck rather than from the command line.
The keys the packaged file sets
Every key below is a [Settings] key, and since 6.3.4 the database holds them: the server’s first start takes in the values the file sets, and after it a line edited in the file for a key the database holds is reported as HM5804 and not applied. A line for a key the database has never held, such as the two commented out below, is taken in at the next start or reload. Change a key with sudo hmailserver --set-setting NAME=VALUE, over the REST API (PUT /api/v1/settings/ini/{name}) or with hmctl ini set (chapter 21).
| Key | Packaged | Meaning on Linux |
|---|---|---|
ProtectStoredSecretsWithDPAPI |
1 |
Named for the Windows store and kept for compatibility; here 1 means a key file (§35.12). 0 deliberately selects the old fixed-key Blowfish scheme — “portable and protected from nobody who has the binary”. This build never falls back to it on its own. Since 6.3.4 0 also keeps an account’s second factor and the [Settings] secrets as written, rather than sealed under the stored-secrets key. |
RestApiPort |
0 (off) |
The only administration surface here. It will not start until AdministratorPassword is set. |
RestApiBindAddress |
127.0.0.1 |
TLS is required unless the address is exactly 127.0.0.1, localhost or ::1. |
RestApiCertificateFile, RestApiPrivateKeyFile |
commented out | Needed to bind anything but loopback. |
WindowsEventLogEnabled |
1 |
syslog here, identity hmailserver, facility LOG_MAIL — so operational events reach the journal. |
WindowsEventLogLevel |
2 |
1 = Critical, 2 = + High, 3 = + Medium, 4 = everything. Protocol chatter never goes to syslog whatever this says. |
LogDeleteDays |
0 |
Leave at 0 while the logrotate rule is in place (§35.11). |
ShutdownDrainSeconds |
0 |
How long a stop waits for sessions in flight. 0 drops a delivery mid-DATA. Raise TimeoutStopSec with it. |
SepSvcLogs |
0 |
One combined log, which makes the logrotate glob simplest. 1 splits SMTP, POP3 and IMAP. |
MinimumFreeDiskSpaceMB / DiskSpaceWarningThresholdMB |
100 / 1024 |
Below the floor, mail is refused with a temporary error rather than accepted and possibly lost. The threshold is where you are told, well before that. |
35.6 The filesystem layout
| Path | Owner, mode | What lives there | Back up? |
|---|---|---|---|
/usr/bin/hmailserver |
root |
The server. One file. | No |
/usr/bin/hmctl |
root |
Since 6.3.4: the command-line client over the REST API (§35.10), including hmctl ini set for the [Settings] keys. |
No |
/usr/bin/tlds.txt |
root |
Up to 6.3.5 only: SURBL’s two-level domains of 2006, read from the executable’s own directory, and 4335 when it was missing. 6.3.6 removed it: the Public Suffix List compiled into the server decides, 4335 is retired, and the upgrade removes the file. | No |
/usr/bin/dh2048.pem |
root |
The finite-field Diffie-Hellman group, also read from the executable’s own directory. Without it every TLS context reports a critical 5603 and comes up without it: the server keeps running, and keeps accepting TLS over ECDHE, while writing a critical error for every listener and every outbound delivery. | No |
/usr/share/hmailserver/DBScripts/ |
root |
Schema creation and upgrade scripts. Read from ProgramFolder. |
No |
/usr/share/hmailserver/WebAdmin/index.html |
root |
The Control Deck, served at GET /. Read from ProgramFolder. |
No |
/usr/lib/systemd/system/hmailserver.service |
root |
The unit. Replaced on upgrade — change it with a drop-in (§35.8). | No |
/usr/lib/hmailserver/hmailserver-update, and the units hmailserver-update.path and hmailserver-update.service |
root |
Since 6.3.4: the live update’s apply step, a shell script run as root outside the server’s unit. When a request appears in /run/hmailserver, the path unit, which the .deb and .rpm enable (on Arch, run sudo systemctl enable --now hmailserver-update.path), starts the service unit, which runs the script (§35.13). |
No |
/etc/hmailserver/ |
root:hmailserver 0750 |
The configuration directory, and a good place for certificate and key files. | Yes |
/etc/hmailserver/hMailServer.ini |
root:hmailserver 0640 |
The configuration. Marked so an upgrade never overwrites it: a conffile in the .deb, %config(noreplace) in the .rpm, backup= in the PKGBUILD. |
Yes |
/etc/logrotate.d/hmailserver |
root |
The rotation rule. Also a conffile. | If edited |
/var/lib/hmailserver/ |
hmailserver 0750 |
The mail store. One directory per domain, one per mailbox, one file per message. The path that grows, and the one to put on the volume with the space. Since 6.3.4 a domain cannot be named after a directory the server keeps here — temp, events and database among them — and a domain that already has such a name is reported as HM7597 at every start: rename it. |
Yes — irreplaceable |
/var/lib/hmailserver/.hmailserver-secret-key |
hmailserver 0600 |
32 random bytes, made when first needed — by the server the first time a secret is stored or, since 6.3.4, by a root-run verb such as --create-stored-secrets-key, for the service’s user — and never rewritten. Every stored password is encrypted under it. Since 6.3.4 it also seals the stored-secrets key line in hMailServer.ini. That key, which opens the second factors and [Settings] secrets in the database, is in no backup archive and no database dump: export it with sudo hmailserver --export-stored-secrets-key FILE and keep the file with the database backups, stored apart from them (§35.7). |
Yes — with the database |
/var/lib/hmailserver/ACME/ |
hmailserver |
The ACME account key and the issued fullchain.pem and privkey.pem, unless AcmeCertificateDirectory points elsewhere. |
Yes |
/var/lib/hmailserver/Certificates/ |
hmailserver |
Since 6.3.4, on a new installation: the self-signed certificate and its key (mode 0600) that the first start makes for the listeners (§35.9). | Yes |
/var/lib/hmailserver/temp/ |
hmailserver 0750 |
Scratch space, on the same volume as the store on purpose: a virus scanner writes a message here before handing the path to an external scanner, and a backup stages and verifies its archive here. PrivateTmp=yes does not affect it — the server does not use /tmp. |
No |
/var/lib/hmailserver/events/ |
hmailserver 0750 |
Where event scripts live: EventHandlers.js, in JavaScript, since 6.3.4 (§28.2). Up to 6.3.3 it was created and stayed empty, because there was no script engine. |
No |
/var/lib/hmailserver/database/ |
hmailserver 0750 |
Only the two Windows-only backends put anything here. This build has neither, so it exists and stays empty. | No |
/var/log/hmailserver/ |
hmailserver 0750 |
The logs (§35.11). | Optional |
Two different lookup mechanisms, and it matters which.
dh2048.pem(and, up to 6.3.5,tlds.txt) is read from the running executable’s own directory;DBScriptsandWebAdminare read fromProgramFolderin the configuration. Move either pair and its partner has to move with it.
The six [Directories] keys point at exactly this layout:
ProgramFolder=/usr/share/hmailserver,
DataFolder=/var/lib/hmailserver, LogFolder=/var/log/hmailserver,
TempFolder=/var/lib/hmailserver/temp,
EventFolder=/var/lib/hmailserver/events,
DatabaseFolder=/var/lib/hmailserver/database. ProgramFolder points
at the package’s data directory rather than at /usr/bin because the executable is
one file and everything it reads from ProgramFolder is under
/usr/share.
Removal and purge
- Upgrade is not removal. The
.deb‘sprermdeliberately does nothing on an upgrade, so the new binary is unpacked under the running process and the service restarted afterwards rather than stopped twice. - Remove stops the service and undoes the systemd enablement.
- Purge (dpkg only; rpm has none) also removes the configuration file, and
removes
/etc/hmailserveronly if it is empty — a certificate you put beside the configuration keeps the directory standing. Since 6.3.4, once the key has been made (sudo hmailserver --create-stored-secrets-key), that file also holds the stored-secrets key ([Security] StoredSecretsKey), which no backup archive carries and without which the second factors and[Settings]secrets sealed in the database do not open: export it first (sudo hmailserver --export-stored-secrets-key FILE) if the database is to be used again. /var/lib/hmailserverand/var/log/hmailserverare always kept, and the script says so on its way out. A package manager is the wrong thing to be deciding that a mailbox is finished with. The database is untouched for the same reason.- The
hmailserveruser and group are kept, so what is left keeps an owner with a name. A numeric owner with no name is how a store becomes unreadable to the reinstall that was meant to pick it up.
# When you are certain, and only then
sudo rm -rf /etc/hmailserver /var/lib/hmailserver /var/log/hmailserver
sudo deluser --system hmailserver # userdel hmailserver on RPM distributions
35.7 Command-line reference
Up to 6.3.3 the Linux binary had exactly eight options, the ones in the first table below;
6.3.4 adds the verbs in the second. Any other argument prints
Unknown option: <argument> and Try --help. to standard error
and exits 2.
| Flag | What it does | Exit |
|---|---|---|
--help, -h |
The usage block, beginning hMailServer 6.3.8. Parsed immediately — nothing after it is read. |
0 |
--version |
Prints hMailServer 6.3.8 build 47. |
0 |
--config <file> |
Sets the configuration file, overriding the search order in §35.5. A missing argument prints --config needs a file name. |
2 on a missing argument |
--foreground |
Accepted and ignored. The foreground is already the only behaviour — the process never daemonises and never writes a pid file. The option exists so a unit file can say so. | — |
--check-config |
Reads the configuration, prints five lines, exits without opening a listener or the database. | 0 always |
--set-admin-password |
Reads a password from stdin, PBKDF2-hashes it, writes it to [Security] AdministratorPassword. |
0; 2 when empty (No password was given; nothing was changed.) |
--create-database |
Creates the database and runs the create script (§35.4). Since 6.3.4 it marks a database it creates or fills as a new installation’s and leaves .new-installation in the DataFolder for the unit’s first start (§35.8), and it leaves a database that already holds this build’s schema, or an older one, as it is. Run as the service user it cannot write the configuration, so it makes no stored-secrets key and says what to run instead. |
0, also for a database it leaves as it is (up to 6.3.3 that was 1); 1 on any connection, CREATE or script failure, or a database newer than the build; 2 when [Database] names no Type or Database, or a Windows-only backend |
--upgrade-database |
Walks the upgrade-script chain to the schema this build needs (§35.13). | 0 on success or “nothing to do”; 1 for no hm_dbversion row, a database newer than the build, a broken chain, a failed script, or a script that runs without advancing the version; 2 as above |
| (no flag) | Runs the server: install the crash oracle, initialise, open the listeners, wait for a signal. | 0 on a clean stop; 1 if initialisation fails (hMailServer could not start: ...) or the listeners cannot be opened |
Among these eight, when several are given the first of these wins: --set-admin-password, then
--create-database, then --upgrade-database, then
--check-config, then run the server. --config and
--foreground are modifiers and apply wherever they appear.
Since 6.3.4 the binary also has these verbs:
| Flag | What it does | Run as |
|---|---|---|
--check-database |
Makes one connection to the database with its TlsMode and TlsCaFile and says whether it is encrypted and whether the certificate was checked, or why there is no connection. Changes nothing, so it runs beside a running service. Exit 0 connected, 1 not, 2 not configured. |
The service user |
--set-setting NAME=VALUE, --set-setting NAME |
Stores one [Settings] value in the database and writes the file’s copy: the way in for the settings needed before the REST API can be reached, RestApiPort above all. With the name alone the value is read from standard input, and a secret’s value is refused on the command line. Refuses a name the server does not read, a port outside 0 to 65535 and a bind address that is not an IP address or localhost. A running server takes the value at its next save, reinitialise or restart. |
root |
--apply-environment |
Stores every setting the environment names (HM_SETTING_<key>, HM_PROPERTY_<name>, their _FILE forms and the container’s HM_REST_*), each checked first: if one is refused nothing is stored and it exits 1. An administrator’s change to a setting it names is then refused until a run without it. It writes the file’s copy too, so the file must be writable: on a package installation, whose file is root’s, it refuses before storing anything. The container’s entrypoint runs it, as the user that owns the image’s configuration. |
The service user |
--apply-profile NAME [--confirm] [--once] [--if-new], --revert-profile |
Applies one of the four setting profiles, or puts back what the last one changed. --confirm is needed where an entry closes a listener, trusts an address range, weakens a check or shortens a window; --if-new does nothing on a server with ten accounts or more. Exit 0 done or nothing to do, 1 refused or failed, 2 no such profile or no database, 3 an entry needs --confirm, 5 --if-new on a server in service. |
The service user, with the server stopped |
--apply-new-install-defaults [--if-marked [<file>]] |
A new installation’s secure defaults (§35.9), only on a database --create-database marked and still as the create script left it: otherwise exit 5 and nothing changed, and exit 2, the mark kept, when the database cannot be read well enough to tell. The unit runs it with --if-marked before every start (§35.8), where a mark more than a day from the machine’s clock also gives exit 5, the mark kept for a run by hand. |
The service user, with the server stopped. Refuses root |
--sample-domain create, --sample-domain remove |
Makes, or removes again, the throwaway example.test: three mailboxes, a list, a rule and eight messages. The mailboxes’ one password is minted when the sample is made and shown once. Remove it before the server takes real mail. Exit 0 done, 2 refused, 1 could not. |
The service user |
--measure-store, --compress-store [--decompress], --recount-store |
What compressing the message store and holding its large parts once would save, the rewrite either way, and a recount of the shared parts. The rewrite and the recount are refused while the server runs (exit 4). | The service user |
--encrypt-store, --decrypt-store |
Encrypt what a mailbox encrypted at rest stored before encryption was switched on, or, for one account or domain, decrypt every file encrypted to its key with the domain’s recovery passphrase, read from HMAILSERVER_RECOVERY_PASSPHRASE and never from the command line. After decrypting, switch the mailbox’s encryption off before the server starts again. Refused while the server runs (exit 4). |
The service user, with the server stopped |
--copy-store-to-object-storage, --copy-store-from-object-storage |
Copy the message store into the object store the settings name, or back, each file verified and a stopped run resumed. Exit 0 nothing failed, 1 a name not copied or verified, 2 no store configured. | The service user |
--verify-update <file> |
Checks a downloaded package or AppImage against the Sigstore bundle beside it, as the server does before applying one; exit 0 when a signer the server trusts made it. The live update’s root helper runs it again before installing. | The live update’s root helper, as root |
--create-stored-secrets-key, --export-stored-secrets-key <file>, --import-stored-secrets-key <file>, --forget-sealed-secrets <key id> |
Make the key the second factors and [Settings] secrets are sealed under in the database, export it to keep with the database backups but apart from them, import it on another node that shares the database, or, with the key lost for good, clear what was sealed under it. Restart the service after an import. --forget-sealed-secrets is refused while the server runs (exit 4). |
root |
The updater’s flags do not exist here. Options such as
--service,--installer,--rollbackand--rollback-versionbelong to the Windows live-update helper, which is excluded from the Linux build. Passing any of them givesUnknown option:and exit 2. Since 6.3.4 a Linux server’s live update is applied by a root helper outside the unit,/usr/lib/hmailserver/hmailserver-update(§35.13), which checks the package with--verify-update.
| Signal | Effect |
|---|---|
SIGTERM, SIGINT |
Stop. The wait loop ends, the listeners stop accepting, and the application drains for ShutdownDrainSeconds before exiting. |
SIGHUP |
Re-read the configuration, the way the Control Panel’s Apply now does. Logged as SIGHUP: re-reading the configuration. This stops and restarts every listener inside the running process — sessions in flight are dropped. |
SIGPIPE |
Ignored. A peer closing a socket mid-write must not kill the process. |
The handlers are installed without SA_RESTART, on purpose: a blocking read in
a worker should come back with EINTR during a stop rather than hold the shutdown
open.
35.8 The systemd service
On Windows the server registers itself with the Service Control Manager from inside its own
process. Here the supervisor is declared rather than programmed, and
/usr/lib/systemd/system/hmailserver.service is the whole of that declaration.
[Unit]
Description=hMailServer, a mail server for SMTP, IMAP and POP3
Wants=network-online.target
After=network-online.target
After=postgresql.service mariadb.service mysql.service mysqld.service
[Service]
Type=simple
ExecStartPre=-/usr/bin/hmailserver --apply-new-install-defaults --if-marked
ExecStart=/usr/bin/hmailserver --foreground
User=hmailserver
Group=hmailserver
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
TimeoutStartSec=300
TimeoutStopSec=300
WorkingDirectory=/var/lib/hmailserver
RuntimeDirectory=hmailserver
ReadWritePaths=/var/lib/hmailserver /var/log/hmailserver
[Install]
WantedBy=multi-user.target
Three lines are new in 6.3.4. ExecStartPre runs before every start and exits at
once, opening nothing, unless --create-database left .new-installation in the
DataFolder; at the first start after it, it applies a new installation’s secure defaults
(§35.9) to a database marked within a day of the machine’s clock, and removes the file either way; a
database it cannot reach at that one try leaves the file, so the next start asks again. The - forgives a failure and not a timeout, so
TimeoutStartSec=300 gives its one database attempt room. A DataFolder moved outside
ReadWritePaths needs a drop-in adding it before that first start (below).
RuntimeDirectory=hmailserver is /run/hmailserver, where the server leaves a live
update’s request for the root helper (§35.13).
network-online rather than network, because a listener binds an
explicit address whenever a bind address is not 0.0.0.0, and a bind to an address
the machine does not have yet fails outright rather than waiting.
The four database units are After= only, and deliberately not
Requires= or Wants=: the backend is as often on another host, and a
hard requirement on a unit that is not installed would refuse to start the server at all.
Naming an absent unit in After= is a no-op, so all four can be listed. The server
survives a database that is not up yet in any case — it retries
ConnectionAttempts times, ConnectionAttemptsDelay seconds apart.
It runs as hmailserver:hmailserver. Never root. Run
everything that touches the store or the database as the same user; only
--set-admin-password and, since 6.3.4, --set-setting and the four stored-secrets
key verbs are root, because the configuration file is root-owned and they write it or read the key
from it. --apply-new-install-defaults refuses root, since the certificate key it writes
must be the service’s.
sudo systemctl start hmailserver
sudo systemctl stop hmailserver
sudo systemctl reload hmailserver # SIGHUP: re-read the configuration
sudo systemctl status hmailserver
journalctl -u hmailserver -f
journalctl -u hmailserver --since "1 hour ago" -p warning
sudo -u hmailserver hmailserver --check-config
sudo -u hmailserver hmailserver --upgrade-database
reloadis not free. It sendsSIGHUP, which is Reinitialize: the listeners stop, the configuration is re-read, and they start again inside the running process. Connections in progress are dropped. Do it when nothing is mid-delivery. It is offered asreloadrather than done automatically for that reason, and log rotation deliberately does not use it.
TimeoutStopSec and ShutdownDrainSeconds are a
pair. The server’s drain setting decides how long it waits for active sessions; the
unit’s timeout is the ceiling systemd puts on that, after which it kills the process whatever
the server was waiting for. The unit’s value must be comfortably larger. Raise both together,
never just one.
The hardening
| Directive | Value | Why |
|---|---|---|
AmbientCapabilities, CapabilityBoundingSet |
CAP_NET_BIND_SERVICE |
Ports 25, 110, 143, 465, 587, 993 and 995 are below 1024, as are ACME’s port 80 and, since 6.3.4, the SNMP agent’s UDP 161, and the process never runs as root. The bounding set is emptied of everything else. |
NoNewPrivileges |
yes |
Ambient capabilities survive it, which is why both can be set. |
ProtectSystem |
strict |
/usr, /boot and /etc read-only, everything under /var read-only except the two ReadWritePaths. /etc/hmailserver is deliberately on the read-only side: the configuration is root’s, and the verbs that write it run as root (§35.5). Since 6.3.4 the server’s own write of a stored setting back into the file’s copy fails here by design: the stored value is used, and the key is reported as HM5806 when it is stored and HM5803 at each start until the line holds the stored value, which --set-setting writes. |
ProtectHome, PrivateDevices |
yes |
|
PrivateTmp |
yes |
The server’s temporary files go to TempFolder, not /tmp, so this costs nothing. The exception is a Custom or ClamWin scanner, which hands a file path to an external program: a helper started outside this unit cannot see into a private /tmp. clamd is unaffected — it is reached over TCP with INSTREAM. |
ProtectKernelTunables, ProtectKernelModules, ProtectKernelLogs, ProtectControlGroups, ProtectClock, ProtectHostname |
yes |
|
ProtectProc / ProcSubset |
invisible / pid |
/proc/self/exe is the only thing under /proc this program reads — it is how the installation directory is found, and therefore how the configuration is found at all. Both settings leave a process’s own entries alone. |
RestrictNamespaces, RestrictRealtime, RestrictSUIDSGID, LockPersonality, RemoveIPC |
yes |
|
RestrictAddressFamilies |
AF_INET AF_INET6 AF_UNIX AF_NETLINK |
AF_UNIX is syslog and the name service switch. AF_NETLINK is what glibc’s getaddrinfo uses to enumerate local addresses, and without it name resolution degrades in ways that are hard to attribute afterwards. |
SystemCallArchitectures / SystemCallFilter / SystemCallErrorNumber |
native / @system-service / EPERM |
|
UMask |
0027 |
A message file is readable by the group and by nobody else. The store holds other people’s mail and the logs hold their addresses. |
LimitNOFILE |
65535 |
One connection is one descriptor, and so is every open message file and database connection. The usual 1024 is reached by a few hundred concurrent IMAP sessions, and what happens then is accept() failing on a healthy server. |
What is deliberately not enabled
The unit lists six options it does not set, each with its reason, so nobody adds them from a checklist:
DynamicUser=yes— the mail store outlives every version of the program; a uid allocated afresh on each start would leave it owned by a user that no longer exists.PrivateUsers=yes— it puts the service in a user namespace, whereCAP_NET_BIND_SERVICEapplies to that namespace and not to the host’s ports. The server could not bind 25.MemoryDenyWriteExecute=yes— not verified against this program. The MySQL/MariaDB client is loaded withdlopen, and an unverified W^X policy that breaks the database client breaks the server. Very likely safe, which is not the same as known to be.StateDirectory=andLogsDirectory=— they would create and own the two directories. The package creates them instead, so that removing the package, and nothing else, decides what happens to a mail store.IPAddressDeny=anywith an allow list — an MTA talks to every mail server on the internet. There is no list to write.PrivateNetwork=yes— named only because it appears on every hardening list: a mail server with no network is not a mail server.
LimitCORE is commented out rather than set: a core of a mail server holds
message bodies, passwords and TLS keys, so the limit is left at the distribution’s default and
turned on only for the time it takes to catch one.
Changing the unit
Always with a drop-in, because an upgrade replaces the unit file and a drop-in survives it:
sudo systemctl edit hmailserver
[Service]
# A backup destination or an ACME directory outside the store must be named
# here, or the write fails: everything else under /var is read-only.
ReadWritePaths=/srv/backup/hmailserver
TimeoutStopSec=600
ReadWritePaths is the only writable ground the service has for data (since 6.3.4 the unit’s RuntimeDirectory, /run/hmailserver, is writable too, for a live update’s request). An archive
directory, an ACME certificate directory and a backup destination are all
administrator-chosen and may sit elsewhere; a path outside the list is read-only to the
service and the write fails. This is the single most common cause of a Linux installation that
runs perfectly and cannot write a backup.
35.9 Certificates on Linux
Certificates work as chapter 9 describes. Two things are specific to this platform: how you configure them without a Control Panel, and where the files may live.
Since 6.3.4 a new installation starts with one. When the unit’s first start comes within a day of
--create-database, it makes a self-signed certificate for the machine (RSA 2048, 825 days, under
/var/lib/hmailserver/Certificates, recorded as Self-signed (made at installation)),
gives it to the four listeners with STARTTLS optional, and requires TLS for authentication on the
Internet IP range. It acts only on a database still as the create script left it, and never on an upgraded
or attached one. A first start more than a day later changes nothing; with the service stopped,
sudo -u hmailserver hmailserver --apply-new-install-defaults applies the defaults then. Mail clients warn about a
self-signed certificate, so replace it, with ACME or your own.
How a certificate is configured
A certificate here is a database row with a name and two file paths; a listener is another
row naming a certificate by id. Neither is an INI key, so neither is edited in
hMailServer.ini. On Linux you create both over the REST API, or on the Control
Deck’s Certificates and Ports views.
API=http://127.0.0.1:8045/api/v1
AUTH='-u Administrator:your-admin-password'
# Both files must exist or the request is refused, naming the one that does not.
curl $AUTH -H 'Content-Type: application/json' -X POST "$API/certificates" \
-d '{"name":"mail.example.com",
"certificate_file":"/etc/hmailserver/fullchain.pem",
"private_key_file":"/etc/hmailserver/privkey.pem"}'
# A new listener, here SMTP with implicit TLS on 465. The create script's 25, 587, 110 and 143
# are already there, and a POST for an address and port already listened on is refused (409):
# give one of those the certificate with a PUT of its whole record to "$API/ports/<id>",
# the id from GET "$API/ports".
curl $AUTH -H 'Content-Type: application/json' -X POST "$API/ports" \
-d '{"protocol":"smtp","address":"0.0.0.0","port":465,
"connection_security":"tls","certificate_id":3}'
# A listener takes effect on restart - or now, without stopping the process
curl $AUTH -X POST "$API/server/reinitialize"
Paths are checked when the certificate is created, so a typo is refused there rather than saved for the listener to fail on at the next start. A certificate cannot be deleted while a port binds it, and the refusal names the port.
The service must be able to read the key. It runs as
hmailserver, not as root. A private key at0640 root:hmailserverin/etc/hmailserveris readable by the service and by nobody else. A key under/etc/letsencrypt/liveis typically not readable by a non-root service without a deploy hook that copies it or a group that grants it.
ACME
AcmeEnabled=1
[email protected]
AcmeDomains=mail.example.com
AcmeDirectoryUrl=https://acme-v02.api.letsencrypt.org/directory
AcmeHttpPort=80
AcmeReuseKey=1
;AcmeCertificateDirectory=/var/lib/hmailserver/ACME
Since 6.3.4 these are stored settings (chapter 21). A line for a key the database has never held is taken in at the
next start or reinitialise; once the database holds a key, a line edited in the file is reported as HM5804 and not applied. On a running
server set them over the REST API, with hmctl ini set or with
sudo hmailserver --set-setting NAME=VALUE. AcmeEnabled and
AcmeCertificateDirectory take effect at a reinitialise (sudo systemctl reload hmailserver),
the others when saved over the API; a value stored with --set-setting reaches a running
server at its next save, reinitialise or restart.
- Port 80 works without root. The HTTP-01 challenge needs a listener on
AcmeHttpPort, and the unit’sCAP_NET_BIND_SERVICEis what lets a non-root process bind it. Nothing else on the machine may already hold that port. - The default certificate directory is inside the writable ground. Unset,
it is
<DataFolder>/ACME, which is insideReadWritePaths. PointAcmeCertificateDirectoryelsewhere and you must add that path toReadWritePathswith a drop-in, or issuance fails on a read-only filesystem. - Deployment is automatic and visible. After a successful issuance the server creates or updates a certificate record named ACME (automatic) and assigns it to every TLS-enabled listener that has none of its own and, since 6.3.4, to every listener that holds the self-signed certificate a new installation made, logging each assignment; a listener holding a certificate somebody chose is left alone. A listener the assignment could not be saved for is reported as a High 6100 rather than logged as a success, because a port left without a certificate accepts no TLS at all.
To use an external client instead — certbot, acme.sh, your own CA — leave
AcmeEnabled=0, put the PEM files where the service can read them, and create the
certificate record over the API as above. A renewal that replaces the files in place needs a
POST /api/v1/server/reinitialize or a restart to be picked up.
TLS for the administration listener
Its rule is a security gate, not a convenience. TLS is required unless the
bind address is exactly 127.0.0.1, localhost or ::1 —
those three literals and nothing else; 127.0.0.2 and a mapped form are not
exempt. Bound elsewhere without a certificate it refuses to start:
RestApi: Refusing to start - TLS certificate is required unless bound to 127.0.0.1 or ::1.
Set RestApiCertificateFile and RestApiPrivateKeyFile.
RestApiPort=8443
RestApiBindAddress=0.0.0.0
RestApiCertificateFile=/etc/hmailserver/rest.pem
RestApiPrivateKeyFile=/etc/hmailserver/rest.key
Since 6.3.4 those four are stored settings: once the database holds one — RestApiPort and
RestApiBindAddress from the first start, which took them from the packaged file — a line edited in the
file is not applied (§35.5). So store all four with the command line, since the listener may
not be reachable yet, and reload: all four apply at a reinitialise.
sudo hmailserver --set-setting RestApiPort=8443
sudo hmailserver --set-setting RestApiBindAddress=0.0.0.0
sudo hmailserver --set-setting RestApiCertificateFile=/etc/hmailserver/rest.pem
sudo hmailserver --set-setting RestApiPrivateKeyFile=/etc/hmailserver/rest.key
sudo systemctl reload hmailserver
If RestApiCertificateFile is empty, the server uses the ACME directory’s
fullchain.pem and privkey.pem when both are there, and logs that it
has. The simplest safe arrangement on a server with no public administration surface is to
leave it on loopback and tunnel:
ssh -N -L 8045:127.0.0.1:8045 [email protected]
dh2048.pembelongs beside the binary. It is read from/usr/bin/, beside the executable rather than fromProgramFolder. Missing, every TLS context reports a critical 5603 and is built without the finite-field Diffie-Hellman group. The server goes on running and goes on accepting TLS through ECDHE, so the symptom is an error log filling with criticals rather than a listener that stops working. If that is what you are looking at after moving or repackaging the binary, check this file first.
35.10 Administering a Linux server
There is no Control Panel here and no COM. The Windows administration
program is a .NET 10 desktop application that drives the server over COM, and neither is in
this build. Three surfaces replace them: the configuration file (§35.5), which
since 6.3.4 keeps [Database], [Directories], [Security],
[LDAP] and a few other keys while the database holds every other
[Settings] key; the command line (§35.7) for the administrator
password, the schema and, since 6.3.4, a [Settings] value that has to be set before
the API can be reached (--set-setting); and the REST API (chapter 24)
with the Control Deck (chapter 36) and, since 6.3.4, hmctl, a
command-line client over the API, for everything else.
The Control Deck
Turn the API on (§35.3 step 8) and open
http://127.0.0.1:<RestApiPort>/. The page is a file the package installs at
/usr/share/hmailserver/WebAdmin/index.html, found through
ProgramFolder. Sign in as Administrator, with the one-time code when a second factor is
enrolled; the password is exchanged once for an HttpOnly, SameSite=Strict
session cookie, which ends after thirty idle minutes or twelve hours, and is not kept by the
page. Since 6.3.4 an account that holds an administrator role signs in too and sees only what
its role allows; the administrator can also sign in with a passkey enrolled on the Deck’s
Passkeys view, and an address on the server’s list of Deck administrators through the
organisation’s OpenID Connect or SAML identity provider. No API key signs in to it. It has forty views in 6.3.4 (fifteen in
6.3.3), among them Dashboard, Checklist, Domains, Lists, Delivery queue, Settings, Rules,
Routes, Remote domains, Certificates, Ports, Roles, Logs, Audit trail, Update and API keys. Its
settings forms are generated from the server’s own OpenAPI document, so every setting of its
settings groups appears with its type, its permitted words and a mark when it is read-only or write-only. Since 6.3.4 each field kept in the settings
store carries a badge saying when a save applies (on save, after a reinitialise or after a
restart), the saved settings still waiting are listed above the groups with Apply
now, and a setting the environment manages is marked Set by the environment.
A [Settings] key no group carries is set over the API
(PUT /api/v1/settings/ini/{name}) or with hmctl ini set.
If the page says “Web administration page not installed”, that file is missing and the server has fallen back to a built-in stub.
--check-configwill not tell you — the page is not one of the paths it prints. The Installation paths diagnostic does report it, and so does looking at the file.
What the Deck does not do, and the same writes over the API
In 6.3.4 every property the desktop Control Panel writes that the API carries is reached
from a view (458 of them, by the census in DeckParity.md), the domain, the alias, the
change to an existing account and the distribution list below included. What the Deck does not
do is edit the event script, which is a file on the server’s disk (on Linux, in
/var/lib/hmailserver/events), or the alert rules, which are REST and
hmctl api only. For a script, or a session with no browser, the calls below are the
same writes: use curl or any other HTTP client. Since 6.3.4 a one-time code is
accepted once, so a script whose administrator password has a second factor signs in once
(POST /api/v1/session) or uses an API key.
API=http://127.0.0.1:8045/api/v1
AUTH='-u Administrator:your-admin-password'
JSON='-H Content-Type:application/json'
# 1. A domain. 201 on success, 409 if it exists, 400 with the reason if the name is not one.
curl $AUTH $JSON -X POST "$API/domains" \
-d '{"name":"example.com","active":true,"postmaster":"[email protected]"}'
# 2. An alias
curl $AUTH $JSON -X POST "$API/domains/example.com/aliases" \
-d '{"name":"[email protected]","value":"[email protected]","active":true}'
# 3. Changing an account that already exists
curl $AUTH $JSON -X PUT "$API/accounts/[email protected]" -d '{"active":false}'
# 4. A distribution list
curl $AUTH $JSON -X POST "$API/domains/example.com/lists" \
-d '{"address":"[email protected]",
"members":["[email protected]","[email protected]"],"require_auth":false}'
# 5. An account, a POST under the domain
curl $AUTH $JSON -X POST "$API/domains/example.com/accounts" \
-d '{"address":"[email protected]","password":"...","active":true,"max_size_mb":2048}'
The daily jobs
| Job | How |
|---|---|
| Is it up, and is it busy? | systemctl status hmailserver, then GET /api/v1/status — version, state, processed, spam and virus counts, and live SMTP, IMAP and POP3 session counts. The Deck’s dashboard re-reads it every three seconds. |
| Is mail leaving? | GET /api/v1/queue, or the Delivery queue view. A growing queue is the earliest warning you get. POST /api/v1/queue/{id}/retry retries one now; DELETE removes it. |
| What went wrong? | journalctl -u hmailserver for operational events; GET /api/v1/logs and GET /api/v1/logs/{name}?lines=N, or the Logs view, for the server’s own logs (§35.11). |
| Add a domain or a mailbox | The Deck’s Domains view creates the domain and then its accounts; over the API, the POSTs above. |
| Change a setting | The Settings view, or PUT /api/v1/settings and its named groups (/api/v1/settings/antispam, /logging, /backup and the others the OpenAPI document lists), each key applied through the same setter the Control Panel calls, and applied only when every key in the request is accepted. Since 6.3.4 the [Settings] keys are stored in the database: set one with PUT /api/v1/settings/ini/{name}, which takes the administrator password and refuses every API key, or hmctl ini set, never by editing the file, whose edited line is reported as HM5804 and not applied. 211 keys apply when saved, 28 at a reinitialise (the Deck’s Apply now, POST /api/v1/server/reinitialize or systemctl reload) and 2 at a restart. sudo hmailserver --set-setting is for when the API cannot be reached; what it stores reaches a running server at its next save, reinitialise or restart. The file’s own sections, [Database], [Directories] and the rest, are still edited in the file and apply at a restart. Up to 6.3.3 every INI key was the file: edit it and systemctl reload. |
| Add a listener or certificate | The Ports and Certificates views, or §35.9. Then POST /api/v1/server/reinitialize. |
| Restart the services in place | The Ports view has a button. It confirms first, because connections in progress are dropped and the browser session ends with them. The route answers 202 before it happens — poll GET /api/v1/status until it answers again. |
| Run a backup | POST /api/v1/backup starts one with the configured settings; GET /api/v1/backup is the status text and the tail of the backup log. §35.12 for what it does and does not cover. |
| Check for a new version | POST /api/v1/update/check. It names the package your machine would install; up to 6.3.3 nothing can install it, and since 6.3.4 POST /api/v1/update/install applies a signed one (§35.13). |
| Give an integration a credential | POST /api/v1/apikeys, or the Deck’s API keys view — scoped read-only or full, since 6.3.4 with an administrator role, optionally restricted to named domains and source addresses, and expiring after 90 days unless another date is given. The clear-text token exists once, in that response. A key never reaches /api/v1/settings/ini, the audit trail or the alerts, and never signs in to the Deck. |
Mailbox users have the self-service portal at /portal on the same listener
(chapter 36). It is compiled into the binary rather than read from a file, so it is there on a
machine that has nothing else.
35.11 Logging and log rotation
File in /var/log/hmailserver |
What it is |
|---|---|
hmailserver_<date>.log |
The main log. A new one each day. |
ERROR_hmailserver_<date>.log |
The error log. A new one each day. Read this one first. |
hmailserver_awstats.log, _backup.log, _events.log |
Grow without bound. These are the real reason the rotation rule exists. |
hmailserver_SMTP_<date>.log, _POP3_, _IMAP_ |
Only when SepSvcLogs=1. |
crash-oracle.log |
The crash record — see below. |
sudo tail -f /var/log/hmailserver/ERROR_hmailserver_$(date +%F).log
syslog and the journal. WindowsEventLogEnabled=1 is a Windows
inheritance in name only: here it sends operational events — the database
going away, a listener that could not bind, a failed backup, the disk floor — to syslog under
the identity hmailserver with facility LOG_MAIL, and so to the
journal. Same list of events and same throttle as the Windows event log.
A healthy server writes none of them. WindowsEventLogLevel=2
means Critical and High; protocol chatter never goes to syslog whatever it is set to. The
journal also carries the startup line and anything on standard error, because the unit is
Type=simple with --foreground:
hMailServer 6.3.8 is running. Send SIGTERM to stop it.
The whole log to a collector, since 6.3.4. A separate sink, off as shipped,
copies every entry of the server’s own log as RFC 5424 to a syslog collector over UDP, TCP or
TLS, or, with no collector host set, to the journal. It is a copy: the files above are written
as before. It is set with the syslog_* keys of
/api/v1/settings/logging (the Deck’s Settings view; hmctl settings get logging prints them) and takes effect at the next start or reinitialise. Errors are
always sent, whatever categories are chosen.
The crash record. Handlers for SIGSEGV, SIGBUS,
SIGILL, SIGFPE and SIGABRT write a one-line record to
crash-oracle.log before the process dies, then let it die by the signal
it caught, so the kernel’s own core policy applies. During a start-up fault, before the log
directory has been resolved, the record goes to
/tmp/hmailserver-crash-oracle.log instead — worse than the log directory and
better than nowhere. Same file name and record shape as on Windows.
Log rotation
/var/log/hmailserver/*.log {
su hmailserver hmailserver
daily
rotate 14
compress
delaycompress
missingok
notifempty
copytruncate
}
copytruncate, and not a signal. By default the server does
not hold a log file open at all — it opens in append mode, writes the line and closes. It
keeps a handle only for the protocol logs and only when a particular log-mask bit is set, and
the only thing that makes it reopen is the file being gone. logrotate’s
create defeats exactly that check: a fresh file under the old name means
the server sees a file that exists, keeps its old handle, and goes on writing into the rotated
inode. There is no signal to send either — the one signal this process has is
SIGHUP, which restarts every listener, and rotating a log file is not worth
dropping the sessions in flight. copytruncate is safe in both modes: the file is
never replaced, so a held handle stays valid, and it is an O_APPEND handle, so the
next write after the truncation lands at offset zero. The narrow cost is the lines written
between the copy and the truncate. create is deliberately absent — logrotate
ignores it under copytruncate anyway, and writing it would be misleading.
su hmailserver hmailserver is required, not decorative. The
log directory is owned by the service user and is not world-writable, and logrotate refuses to
rotate inside a directory it does not own unless told whose it is.
Use one retention mechanism, never both.
LogDeleteDaysprunes the same date-stamped files from inside the server. Leave it at0while the logrotate rule is in place — two things deleting the same files by two different rules is how a retention policy stops meaning anything. If you would rather the server owned it, setLogDeleteDaysand remove the logrotate rule.
35.12 Backups on Linux
Chapter 15 is the strategy. This is what it means here — and there is one item on the list that does not exist on Windows.
| What | Where | Why |
|---|---|---|
| Messages | /var/lib/hmailserver |
The mail itself. Irreplaceable. |
| The database | Your PostgreSQL or MariaDB server | Accounts, domains, settings, rules, routes, listeners, certificates. |
| The key file | /var/lib/hmailserver/.hmailserver-secret-key |
Every stored password is encrypted under it, and since 6.3.4 so is the stored-secrets key’s line in the configuration. A database restored without it has lost every one of them, and the error log names the file when that happens. |
| The configuration | /etc/hmailserver/hMailServer.ini |
Directories, database credentials, the administrator hash, PasswordPepper (kept nowhere else: without it no Argon2id or scrypt password verifies on a machine restored from these backups) and, since 6.3.4, the stored-secrets key. Since 6.3.4 the REST settings and the rest of [Settings] are held by the database, and the section in the file is its copy. |
| The exported stored-secrets key (since 6.3.4) | The file sudo hmailserver --export-stored-secrets-key FILE wrote |
Each account’s second factor and the [Settings] secrets are sealed under it in the database. It is never in the database or the built-in backup archive, so a database dump restored without the key (this export, or the configuration and the key file above, which carry it too on Linux) brings them back unopenable. Keep it with the database backups, stored apart from them. |
| Certificates and DKIM keys | Wherever you put them; /var/lib/hmailserver/ACME for ACME |
Recreating these is painful and, for DKIM, means a DNS change. |
The key file. Where Windows uses machine-scoped DPAPI, this platform uses
a file: 32 random bytes, mode 0600, made the first time a secret is
stored and never rewritten. Each secret is AES-256-GCM under that key with a 12-byte
nonce and the full 16-byte tag, stored as LINUX1:<base64> so the value says
which store wrote it. It is created through a temporary file and a hard link, which fails when
the name is taken, so two processes protecting their first secret at the same moment end with
one key file between them. It is refused, with the chmod that fixes it,
if any account but its owner can read it (HM6411), and the mode is checked on every
read. Two consequences, cutting in opposite directions: back it up with the
database, because they are useless apart; and it travels — copy the
data directory and the database together and every stored password opens on the new machine,
which Windows DPAPI cannot offer. Since 6.3.4 each account’s second factor and the
[Settings] secrets are sealed under the installation’s stored-secrets key
(HMK1:), once there is one. Run as the service user (§35.3 step 5),
--create-database cannot write the configuration and makes none: make it with
sudo hmailserver --create-stored-secrets-key, and export it at once with
sudo hmailserver --export-stored-secrets-key FILE. Its line in
hMailServer.ini is sealed under this key file, so the configuration and this file
travel together as well.
The built-in backup manager (POST /api/v1/backup) runs 7-Zip, which the
.deb recommends: installed without its recommends it has none, and a backup fails
with “Could not add the backup settings to the archive”. Since 6.3.4 an archive carries each
account’s second factor opened, beside its password hash, so set a backup passphrase, which
encrypts it with AES-256 (encryption_passphrase in
PUT /api/v1/settings/backup, the Deck’s Backup view, or
hmctl backup passphrase set). An encrypted run needs 7-Zip (7zz) or
p7zip-full: 7zr, the reduced p7zip build, cannot encrypt and fails the run. The manager stages into
TempFolder and writes its archive to the destination in the backup settings.
If that destination is not inside ReadWritePaths the write fails.
Add it with a drop-in (§35.8), or take the backup from outside the service, which is usually
simpler:
#!/bin/bash
set -euo pipefail
DEST=/srv/backup/hmailserver/$(date +%F)
mkdir -p "$DEST"
# 1. The database, consistent in itself
sudo -u postgres pg_dump -Fc hmailserver > "$DEST/hmailserver.dump"
# MariaDB: mysqldump --single-transaction --routines hmailserver | gzip > "$DEST/db.sql.gz"
# 2. The store, the key file and the configuration
sudo tar -C /var/lib -cf - hmailserver | zstd -T0 -o "$DEST/store.tar.zst"
sudo cp -a /etc/hmailserver/hMailServer.ini "$DEST/"
chmod -R go-rwx "$DEST"
# 3. Prove the dotfile and the ACME directory were caught
zstd -dc "$DEST/store.tar.zst" | tar -tf - \
| grep -E 'hmailserver-secret-key|hmailserver/ACME/'
Message files and database rows are two halves of one thing. Every file under the data directory has a matching row. A store from one night and a database from another will not agree. Take both at the same point, and take a filesystem snapshot first if the volume supports one.
Restoring: install the same version and let the package
create the user and directories; restore /var/lib/hmailserver in full,
including the key file, then chown -R hmailserver:hmailserver
it and chmod 0600 the key; restore the database and then the configuration,
keeping it 0640 root:hmailserver; if the restored configuration does not hold the
stored-secrets key, import the key’s exported file with
sudo hmailserver --import-stored-secrets-key FILE before the service starts; run --check-config; start the
service and read the error log and GET /api/v1/status.
35.13 Upgrading and rollback
Chapter 18 is the general advice. Here is what a Linux upgrade actually does.
sudo apt install ./hmailserver_6.3.8-1~debian13_amd64.deb # the package you installed from, of the new version, or
sudo dnf install ./hmailserver-6.3.8-1.fc44.x86_64.rpm
On an upgrade, and only on an upgrade, the maintainer script:
- Leaves the running service alone while the new binary is unpacked under it. An upgrade is not a removal, and stopping first would make every upgrade an outage twice as long as it needs to be.
- Runs
--upgrade-databaseas the service user, but only if a database is configured — it greps the INI for aType=line. A failure here is reported, not fatal to the package: the binary is already in place, and the server’s own log is a better place to read why than a half-configured dpkg. Read what it printed. - Runs
systemctl try-restart, which restarts the service if it was running and does nothing at all if it was not. An installation that was never configured stays stopped; one that was configured does not silently keep running the version just replaced.
Your configuration is never overwritten. A new default lands beside it as
.dpkg-dist, .rpmnew or .pacnew; diff those after a
release that adds keys. Since 6.3.4 no key is added to [Settings]: a new setting is
a row in the database.
Upgrading to 6.3.4 moves the settings into the database. The new server’s
first start, which is the maintainer script’s try-restart if the service was
running, makes the database the settings store: keys the 6.3.3 server already mirrored keep
their stored values, and any [Settings] key of the file it never held is taken in. A
[Settings] line edited after the old server last started, one edited to prepare the
upgrade included, is taken for an edit at that first start, reported as HM5804 and
not applied: restart the old server once after the last change, or make the change after
upgrading, over the API, with hmctl ini set or with
sudo hmailserver --set-setting. An upgrade makes no stored-secrets
key (§35.12) and never applies the secure defaults of a new installation; the setup checklist,
the Deck’s Checklist view, says what is left. Running the schema upgrade by hand is safe at any
time:
sudo -u hmailserver hmailserver --upgrade-database
6029 -> 6030: /usr/share/hmailserver/DBScripts/Upgrade6029to6030PGSQL.sql
6030 -> 6031: /usr/share/hmailserver/DBScripts/Upgrade6030to6031PGSQL.sql
Upgraded in 2 steps; the database is at schema version 6031.
That output is from a build at schema 6031 (6.3.0 or 6.3.1) upgrading a database at 6029; run again once the upgrade is done, it reports nothing to do. It scans the scripts directory for the next step rather than guessing the next number, because the chain is not consecutive. It refuses a database from a newer build, and stops rather than looping if a script runs and the version row does not move. 6.3.8 needs schema 6108, as 6.3.6 and 6.3.7 do, and adds no step, so an upgrade from either changes no table. 6.3.6 needs schema 6108, three one-way steps from 6.3.5’s 6105 (6.3.4 needed 6104; 6.3.3 needed 6040; 6.3.0 and 6.3.1 needed 6031, as 6.2.28 did; 6.3.2 moved it to 6038), so an upgrade from 6.3.5 is three steps, one from 6.3.4 four, one from 6.3.3 walks 6.3.4’s sixty-four steps, 6.3.5’s one and 6.3.6’s three in place, and one from anything older the whole chain. On MySQL and MariaDB step 6102 rebuilds every table as utf8mb4, which takes as long as copying the database and needs MySQL 5.7.9 or MariaDB 10.2.2 or later and the ALTER privilege on the database; on an older server the upgrade stops at 6041 with error 1071 and changes nothing (§18.2).
One step in that chain had a known defect until 6.3.2 fixed it. The 6029-to-6030 step deletes orphaned rows before adding seventeen foreign keys, and those deletes run in an order that can re-orphan rows nothing revisits, so the constraint that follows is refused. It fires only on an upgrade from a schema below 6030 against a database that already holds orphaned rows — a database without them upgrades correctly, and
--create-databaseis never on this path. It affects all four backends. When it fires it fails loudly with the engine’s own words and rolls back rather than doing anything quietly, and no installation has reported hitting it. On Linux you can only reach that step with a database carried across from a Windows installation (§35.14), since this platform is itself new in 6.3.0. The fix shipped in 6.3.2, with a gate that upgrades a seeded database: on every push on GitHub Actions up to 6.3.3, and since 6.3.4 as thelinux-db-upgrade-pgsqlandlinux-db-upgrade-mysqljobs of GitLab CI, for a push tomaster, abatch*branch or a release tag; their first run, on 26 September 2026, failed on a fault in the gate itself that 6.3.4 fixes, and both passed in 6.3.5’s tag pipeline on 29 September 2026. Take the database backup first, which §35.12 asks of you in any case.
The update checker. POST /api/v1/update/check reads the
release feed and reports the verdict. A server from 6.3.4 on reads
https://updates.progressiverobot.com, which GitLab Pages serves from the project’s
releases; one from 6.3.0 to 6.3.3 reads the former GitHub repository’s feed, which has answered 404 since 18 September
2026, until UpdateFeedUrl=https://updates.progressiverobot.com/releases.json is added
under [Settings] and the service restarted. On Linux the check builds the asset name
your machine would install, probing for /usr/bin/dpkg first and
/usr/bin/rpm second. On Arch it names nothing; up to 6.3.3 neither did the AppImage,
and since 6.3.4 an AppImage looks for its own. POST /api/v1/update/download fetches
the package and verifies it against its Sigstore bundle. Up to 6.3.3 nothing runs
it: POST /api/v1/update/install hands the file to a helper that is a Windows
program, so on those versions it refuses with a Medium 6401 saying exactly that. Since
6.3.4 it is applied: the server writes a one-file request to /run/hmailserver,
and hmailserver-update.path, a root path unit the package enables, starts
hmailserver-update.service, which checks every line of the request against root’s
own configuration, stops the unit, verifies the package and the rollback image against their
Sigstore bundles again as root, refuses anything that is not a genuine upgrade, installs with
apt-get or dnf, starts the unit, and puts the previous version back when
it does not stay up. sudo /usr/lib/hmailserver/hmailserver-update check says whether
the machine can do it; Arch enables no unit from a package, so there run
sudo systemctl enable --now hmailserver-update.path yourself. 6.3.4 itself carries no
bundle, so it is installed by hand. From 6.3.5 every file carries one, so a server from 6.3.4 on applies a later
release by its live update, a 6.3.4 server without a rollback image (§18.6). On 29 September 2026 a 6.3.4 server from
the .deb on Ubuntu 26.04 installed 6.3.5 so, unattended, and one from the .rpm on Fedora 44 by
hand over the REST API. Up to 6.3.5 a backup destination inside /var/lib/hmailserver fails the backup
taken before an unattended apply (HM5014), and the apply is refused until the destination is moved;
6.3.6 fixes that, but an update from 6.3.4 or 6.3.5 is applied by the version already installed, whose apply step is also
among what 6.3.6’s security fixes replace (chapter 25): there, install 6.3.8 with apt, dnf or
zypper, or turn the apply off until then with systemctl disable --now hmailserver-update.path,
which leaves the check and the download on. From 6.3.6, its own update helper applies 6.3.8 to the generic
.deb and .rpm of either architecture and to both AppImages. From 6.3.6 the release carries
the AArch64 packages and the AppImages again; a server installed from 6.3.3’s trusts only GitHub’s signer
and is upgraded by hand, once. A server installed from one of 6.3.6’s or 6.3.7’s per-release packages is offered the next
release’s generic package, which apt refuses on Debian 12 and dnf on Enterprise Linux 9: there, upgrade with the
package manager and your release’s own package of the next version.
UpdateRequireAuthenticode=1 is no use here either. From 6.3.1 the Windows installer
does carry an Authenticode signature, but the check behind that key is
WinVerifyTrust and exists only on Windows, so rather than report a pass it cannot
make, this build refuses the download with Authenticode signatures can only be checked on
Windows. Up to 6.3.3, updating a Linux installation is the package manager’s job.
Rollback
There is no --rollback here — that belongs to the Windows updater. A rollback
is a package downgrade, and its one real constraint is the schema.
sudo systemctl stop hmailserver
# Across a schema change (6.3.4 back to 6.3.3 is one): restore the pre-upgrade database,
# and /etc/hmailserver/hMailServer.ini with it, here - before the older package
sudo apt install --allow-downgrades ./hmailserver_6.3.3_amd64.deb # or
sudo dnf downgrade ./hmailserver-6.3.3-1.x86_64.rpm
sudo systemctl start hmailserver
- Within a schema version this is straightforward. Keep the previous
package file and its
.cosign.bundlesomewhere reachable without the network; 6.3.4’s packages have no bundle, so for them keephmailserver-6.3.4-SHA256SUMS.txtand its.sig. - Across a schema change it is not. The server refuses to start against a
database newer than the build, with a critical 5011: “The database is
too new for this version of hMailServer.” The upgrade scripts run one way. Rolling back
across a schema change means restoring the database from the backup you took before the
upgrade — which is the reason to take one — and then installing the older package. Restore the
pre-upgrade
hMailServer.iniwith it: a 6.3.4 server that sealed its stored secrets writes the[Settings]secrets into the file asHMK1:envelopes, which an older server reads as they are. Mail that arrived after the backup stays on disk with no row in the restored database, and is invisible.
More: Upgrading from 5.x, 6.2 or 6.3: the 24 changes without a switch, and rolling back on the forum.
35.14 Moving from Windows
This is not a supported migration, and the project does not claim it is. The release notes put it plainly: the database schema is the same and the message store’s on-disk format is the same on both platforms, so the pieces are there; what is missing is a tested path.
That is the whole of the honest position. Nobody has run a Windows installation onto Linux and verified the result, so there is no procedure in this manual to follow and no claim about what would survive. What follows is what is known about the pieces, so you can judge the risk rather than discover it.
What is genuinely shared. The schema — both platforms of 6.3.8 need 6108,
so a PostgreSQL or MySQL/MariaDB database created by a Windows installation is one this build
understands. The message store — one directory per domain and per mailbox, one file per
message named <guid>.eml, the same layout and the same contents. And the
files the server marks with a byte order mark — Sieve scripts and the backup index — are UTF-16LE on both
platforms, so a file written on one reads on the other; the backup and event logs are too on Windows, and from 6.3.6
UTF-8 on Linux, as its other logs are.
What is known to break:
- The configuration, if the backend is SQL Server. An INI carried across
with
Type=MSSQLorType=MSSQLCEdoes not start; it is refused by name (§35.4). Those installations have to migrate the database to PostgreSQL or MySQL first, on Windows, which is a separate exercise. - Every DPAPI-protected secret. Windows encrypts stored passwords with
machine-scoped DPAPI and nothing on Linux can open one. Each is recognised by its version
word and provider GUID, reported once per process as HM6414, and answers
empty. That takes in route passwords, fetch-account passwords, per-domain relay passwords,
private-key passphrases and the administrator’s TOTP secret — every one has to be
re-entered. A
[Database] Passwordencryption=6carried across is the same problem: rewrite it as0with the password in plain text, and rely on the file being0640 root:hmailserver. The smart host’s password, the backup passphrase and the other secrets kept inhm_settingsare machine-bound the same way. Since 6.3.4 an account’s second factor and the[Settings]secrets are not machine-bound: once the installation has a stored-secrets key they are sealed under it (HMK1:), and that key moves only as the file--export-stored-secrets-keywrites, imported withsudo hmailserver --import-stored-secrets-key FILE; never copy the key’s line from the Windows file, which is sealed under that machine’s DPAPI. Where the Windows server has aPasswordPepper, it is in no database and no backup either and is kept as written: copy its value from the WindowshMailServer.iniinto the Linux one before the first start, or no account whose password is an Argon2id or scrypt hash can sign in. - Every event script. VBScript does not run here, and a JScript file is where a JavaScript port starts, not a file that runs unchanged (§28.2).
- Every COM integration. Provisioning, monitoring, billing hooks. Some of what they did has a REST equivalent (chapter 24); some does not yet.
- DKIM key paths. The per-domain key-file setting holds a Windows path such as
C:\keys\mail.pem, which is nothing on Linux. Copy the keys to a path the service can read and point every signing domain at it, withPUT /api/v1/domains/{domain}or on the Control Deck (§24.15). - File ownership and case. NTFS is case-insensitive and ext4 is not, and
this build settles that by lower-casing every directory name it derives from an address, where
the Windows build leaves the component as it found it. A store carried across with a
Test.comdirectory in it is a store this server looks for undertest.com. Everything under it also has to end up owned byhmailserver.
If you attempt it anyway, treat it as a move onto untested ground: do it on a copy with the Windows server still running, and verify that accounts authenticate, that mail delivers, that IMAP folder names with non-ASCII characters are intact, and that every stored password has been re-entered — before you move any DNS. Keep the Windows installation until you are certain.
More: Moving to new hardware, and why Windows to Linux is a rebuild on the forum.
35.15 How the port is verified
Up to 6.3.3 the jobs below ran in GitHub Actions. Since GitHub suspended the project’s account, GitLab CI builds the Linux server with clang on the project’s own runner and compiles every core file for AArch64 there; its first pipeline ran on 26 September 2026. Since 6.3.6 linux-build-gcc also builds the server and its tester programs with GCC 15 on master and batch* branches; the GCC census is run by hand (bash build/linux-tu-census.sh --cc g++). Nothing is built or run on AArch64 hardware: the project has no ARM machine, so from 6.3.6 the AArch64 packages and AppImage are cross-compiled and tested under emulation, and neither 6.3.4 nor 6.3.5 carries an AArch64 package. 6.3.4’s and 6.3.5’s packages were built in the CI image hmailserver-ci/linux:26.04-dad0ab749505 with SOURCE_DATE_EPOCH set to the tag commit’s time.
A claim that a twenty-four-year-old Windows codebase now runs on Linux is worth little on its own. This is what stands behind it.
Every translation unit compiles. build/linux-tu-census.sh
compiles every core source on its own — one compiler invocation per file, syntax only, no
linking — and prints how many succeeded, how many failed, and the first error of each failure
grouped by shape. It is deliberately not the CMake build: a census must not stop at the first
failure and must not care about linking. The script always exits 0, because its output is the
measurement; the gate is in the workflow, which reads the failure count and fails the
job when it is not zero. Up to 6.3.3 it ran on both native runners and, in a separate job, cross-targeted
at aarch64-linux-gnu; since 6.3.4 GitLab CI runs only the cross half, as the
linux-cross job on the project’s own runner.
The GCC job. Up to 6.3.3 a third matrix entry built and linked the whole core with
GCC on x86-64, which is what stopped the port quietly becoming clang-only. GitLab CI did not port
that job: since 6.3.4 no pipeline builds with GCC on x86-64, and the GCC census
(bash build/linux-tu-census.sh --cc g++) is run by hand.
Reproducibility. CMakeLists.txt passes
-ffile-prefix-map, so no absolute path reaches the binary, and the build job (up to 6.3.3 the Linux workflow on GitHub; since 6.3.4 GitLab CI’s linux-build)
exports SOURCE_DATE_EPOCH from the tagged commit’s own date, so that is what
__DATE__, the archiver, dpkg-deb and rpmbuild stamp
instead of the clock. Measured on 8 September 2026: one commit built twice on the hosted
runners — two attempts of the same run, forty-four minutes apart, on two separate runner
allocations — and every job’s binary hashed the same both times, for x86-64 clang, x86-64 GCC
and AArch64 clang.
The caveat, stated rather than buried. What is not pinned is what the image holds. On GitHub up to 6.3.3
ubuntu-latestmoved; GitLab CI’shmailserver-ci/linuximage is Ubuntu 26.04 at a pinned digest, but its packages are the archive’s of the day it was built. A compiler or a Boost that moved between two builds changes the binary legitimately. The job log records the versions it built with, and the Linux claim is therefore “reproducible on the runner image of the day” — weaker than the Windows claim on the same release, which is two clean builds of the stamped source producing a byte-identicalhMailServer.exe. The package containers are not compared either; the binaries inside them are what the hashes cover.
The version stamp. build/check-linux-version-stamp.sh, which needs
no toolchain (up to 6.3.3 the first step of the Linux workflow; since 6.3.4 run by GitLab CI’s
checkers job and again by linux-build before it packages, so a drifted
stamp makes no package), checks that Version.h agrees with itself, that
CMakeLists.txt derives the version from it rather than carrying a
literal, that the Arch pkgver matches, and that nothing else under the packaging
directory carries a hard-coded version at all.
The Linux regression run
hmailserver/test/LinuxRegressionTests is a .NET 10 project that
compiles 260 of the regression suite’s own files in place — not copies of them
— under a REST-backed fixture layer standing where COM stands on Windows. A fixture edited for
the Windows gate is the same text both gates run. The shim layer presents the COM object model
the fixtures already speak — Domain, Account, Alias,
Settings, Rules, Utilities — and turns each property set
and method call into a request on /api/v1/.... A call no route can answer becomes
a skip that names its reason, never a weakened assertion.
At 6.3.0 it put 1,405 of the suite’s 2,175 tests against a Linux server. Measured on
10 September 2026 against PostgreSQL: 756 passed, 0 failed, 649 skipped, in about 40
minutes. For 6.3.4 it was run by hand on the tag’s packaged binary, on the project’s Linux
machine: 2,013 tests, 1,562 passed, 0 failed, 451 skipped (the Windows suite has 3,811).
For 6.3.5 it ran in the tag’s own pipeline, on the project’s Linux runner: 2,213 tests, 1,892
passed, 0 failed, 321 skipped (the Windows suite has 3,828).
Up to 18 September 2026 CI ran it on every push on GitHub Actions, against the binary the same
workflow had just built, on a postgres:16 service container, with no
continue-on-error. Since 6.3.4 it is the linux-suite job of the
project’s GitLab CI, run whole on the project’s own runner against PostgreSQL 16 for
master, batch* branches and release tags, where one failing test fails
the job; its first run, on 26 September 2026, failed on faults of the tree that 6.3.4 fixes, and
6.3.5’s tag pipeline ran it whole, with the result above.
Why the skips are mostly the API’s gaps, not the server’s. A skipped test
is one whose fixture reaches something the REST API cannot express. At the first measurement
that was 299 call sites across 83 reasons, the heaviest being the message
object, distribution lists, fetch accounts and folder ACLs, and each of those was “and no REST
route carries it” rather than anything COM can do that HTTP cannot; fetch accounts, folder
permissions and the Sieve evaluation have had routes since 6.3.3. What skips in
6.3.4 is what cannot be asked over HTTP or has no route yet: the public folders, because the
/api/v1/me routes reach the signed-in account’s own mailbox by design and a public
folder is nobody’s; a stored message’s MIME as a COM object; the COM logon; and the suite’s fake
DNS zone, which is served to the server’s own resolver from the test host. Every skip names its
reason.
The project does not call this finished, and neither should you. In its own words: the tick waits until the skip list is things that genuinely cannot be asked over HTTP rather than a backlog of routes nobody has written yet. At 6.3.0 it was 649 skips against 756 passes, mostly the latter; 6.3.4’s run skipped 451 of 2,013, and the roadmap still lists the suite as done only in part. A green run that skips more than a fifth of what it was handed is worth having, and is not the same thing as a suite that tests the Linux server.
What the Linux suite found, which is the argument for building it: a
narrow-to-wide conversion that stopped at the first byte above 127, so IMAP
SEARCH CHARSET UTF-8 and MAIL FROM under SMTPUTF8 arrived cut short;
modified UTF-7 folder names handed to the client as stored rather than decoded; a folder
created over REST stored in a form no IMAP client could afterwards name; an IMAP literal
terminated with the platform’s newline, which is CRLF on the Windows bench and a bare LF
anywhere else, so a counted STATUS hung for two minutes; and a family of fixtures
naming ports 25, 110 and 143 literally — which on a machine where WSL mirrors the network
meant a fixture inside Linux quietly connecting to the Windows service and testing a
server that had never heard of the account it had just made.
What CI proves about the packages. Up to 6.3.3 the package-install job on GitHub Actions installed the
.deb on a clean machine, edited the packaged INI in place rather than replacing it,
ran --check-config, --create-database and
--set-admin-password as documented, asserted the INI was still
root:hmailserver 0640 afterwards, and started the server
under the packaged unit with all the hardening in force and the listeners left
on 25, 587, 110 and 143 — so a server that binds them there is a server whose one capability
actually works, which is the part of the unit that would otherwise be silently wrong. It then
asked whether the REST API answered and whether GET / returned the real Control Deck
rather than the stub, compared byte for byte against both the installed file and the file in
the repository. The .rpm was installed in a Fedora container and checked for its
files, its identity, its directories, its %config(noreplace) marking and a clean
removal. Since 6.3.4 these are GitLab CI’s linux-package-deb and
linux-package-rpm. The first installs the .deb in a clean Ubuntu 26.04
container, configures the packaged INI against a PostgreSQL service, runs the server’s own
verbs as the service user, starts the server as its unit would (as hmailserver, with
CAP_NET_BIND_SERVICE alone and no new privileges) and asks whether the REST API
answers and whether GET / is the Control Deck, byte for byte. A container has no
systemd, so the start under the unit’s sandbox is no longer shown. The second installs the
.rpm in Fedora 44 with --nodeps.
35.16 Troubleshooting a Linux installation
Chapter 19 covers the mail problems, which are the same on both platforms. These are the ones specific to this one. Start every investigation with the same three commands:
systemctl status hmailserver
journalctl -u hmailserver -n 100 --no-pager
sudo tail -n 100 /var/log/hmailserver/ERROR_hmailserver_$(date +%F).log
| Symptom | Cause and fix |
|---|---|
The service will not start; the journal says hMailServer could not start: |
Initialisation failed before any listener opened — almost always the database or the directories. Run --check-config and, since 6.3.4, --check-database, which makes one connection to the database and says what it got, and read the error log. Standard error carries the reason as well as the log, because at that point the log directory may be exactly what is wrong. |
Database type: 0 |
The Type key did not take. Check the spelling, check it is under [Database], and check for a trailing comment on the same line — text after a value is the value (§35.5). |
| HM7530 or HM7531, and the database is never reached | Since 6.3.4: [Database] TlsMode is not one of prefer, require, verify-full or disable (HM7530), or verify-full names a TlsCaFile that is not there (HM7531). Neither falls back to less. Correct the key and restart; sudo -u hmailserver hmailserver --check-database says what one connection gets. |
| Critical 6390 | Type names SQL Server or SQL Server Compact. Neither exists in this build. Repoint at PostgreSQL or MySQL (§35.4). |
| Critical 5094, “The MySQL client library could not be loaded” | The MariaDB or MySQL client package is not installed, or the linker cannot find it. Install libmariadb3, mariadb-connector-c or mariadb-libs. The error lists the four sonames it tried and the linker’s own last message. |
| Critical 5011, “run DBUpdater.exe” | The message text is a Windows inheritance. Here the command is sudo -u hmailserver hmailserver --upgrade-database. “Too new” instead means you have downgraded across a schema change (§35.13). After an upgrade to 6.3.4 on MySQL or MariaDB, a database server older than MySQL 5.7.9 or MariaDB 10.2.2 stops the schema upgrade at 6041 with error 1071, having changed nothing: upgrade the database server, then the schema. |
| Critical 5603 for every TLS listener and every outbound delivery | /usr/bin/dh2048.pem is missing, so no TLS context gets the finite-field Diffie-Hellman group. It is read from the executable’s own directory, not from ProgramFolder. Reinstall the package. |
| 4335 (up to 6.3.5) | /usr/bin/tlds.txt is missing — same directory, same fix. From 6.3.6 the file is gone and 4335 retired: the Public Suffix List is compiled in. |
| The REST listener does not come up | Three possibilities and the log says which: RestApiPort=0; AdministratorPassword empty, which disables the API entirely (“Refusing to start – the administrator password is not set.”; sudo hmailserver --set-admin-password sets it); or a bind address that is not one of the three loopback literals with no certificate configured (§35.9). Since 6.3.4 the port is the value stored in the database, which on a new installation is whatever the file held at the first start, the packaged RestApiPort=0 unless the line had been changed before then: a port edited into hMailServer.ini after that is reported as HM5804 and not used, and the reload brings up no listener. Store it with sudo hmailserver --set-setting RestApiPort=8045 (and RestApiBindAddress the same way), then sudo systemctl reload hmailserver (§35.3). |
GET / says “Web administration page not installed” |
/usr/share/hmailserver/WebAdmin/index.html is missing, or ProgramFolder does not point at the directory holding it. --check-config will not tell you. |
| HM5804; or HM5806 when a setting is saved, then HM5803 at every start | Since 6.3.4 the database holds the [Settings] keys and the file is its copy (chapter 21). HM5804: a line edited in the file after the first start; the stored value is used, so set the key over the API (PUT /api/v1/settings/ini/{name}), with hmctl ini set, or with sudo hmailserver --set-setting, which reaches a running server at the next reload. HM5806 then HM5803: the value is stored and in effect, but the service cannot write its copy into the file, which is 0640 root:hmailserver under ProtectSystem=strict. The report stops once the key’s line in the file is changed by hand to the same value, or the value is stored with sudo hmailserver --set-setting, which writes the file as root. |
| A listener below port 1024 will not bind | Under the packaged unit this works — CAP_NET_BIND_SERVICE is ambient. It will not work from an AppImage, from a hand-started binary, or under a drop-in setting PrivateUsers=yes, which moves the capability into a namespace where the host’s ports are out of reach. |
| A backup, archive or ACME issuance fails with a write error | ProtectSystem=strict: the destination is outside ReadWritePaths and read-only to the service. Add it with systemctl edit hmailserver (§35.8). |
| An external virus scanner never sees the file it was handed | PrivateTmp=yes (§35.8). Prefer clamd, which is unaffected; otherwise drop PrivateTmp with a drop-in and understand what you have given up. |
| Name resolution misbehaves under the unit but not from a shell | Check any drop-in that narrowed RestrictAddressFamilies: AF_NETLINK is not optional (§35.8). |
| HM6411 — the secret key file is refused | Its mode lets another account read it. The error names the chmod that fixes it; the mode is re-checked on every read. |
| HM6412 — a secret cannot be opened | The key file is missing, and the error log names it. This is what a database restored without it looks like (§35.12). Since 6.3.4, once a stored-secrets key has been made, its line in hMailServer.ini is sealed under the same file, so the server also reports HM7495 at every start and every second factor sealed under that key refuses its codes: put the file back. If it is gone, import the exported key with sudo hmailserver --import-stored-secrets-key FILE and restart, which mends the second factors and the [Settings] secrets, and re-enter the other stored passwords, which nothing can open without the old file (§35.14). |
| HM6414 — a DPAPI blob was found | A secret written by a Windows installation. Nothing here can open it; the value answers empty and the condition is reported once per process. Re-enter the password (§35.14). |
| HM6420 — the logon was refused as a directory failure | [LDAP] BindMethod=1 (Negotiate) is Windows SSPI. Set BindMethod=0 with Security=2 or Security=1. |
| HM6421 | A directory-linked account exists but [LDAP] Enabled is off. The error names the settings to set. A certificate error on the bind instead means the directory’s certificate does not chain to the system trust store — TLS_CACERT in /etc/ldap/ldap.conf. There is no private-CA setting in hMailServer for it. |
| An event script does nothing | Up to 6.3.3 it never runs: there is no script engine here. Since 6.3.4 a Linux server runs event scripts in JavaScript only (§35.1). Scripting is off as shipped and the language is seeded VBScript on every platform, so set JavaScript before switching it on (hmctl settings set scripting --set-string language=JavaScript). A VBScript or JScript language, or a script that does not compile, is refused when the script is loaded, with HM5016 (HM5710 at a reload) naming the language or the error. The file is /var/lib/hmailserver/events/EventHandlers.js; load it with POST /api/v1/settings/scripting/reload, and POST /api/v1/settings/scripting/check gives the error and its line (§28.2). |
| Logs stop appearing after a rotation | Check that nothing added create to /etc/logrotate.d/hmailserver and that the su hmailserver hmailserver line is still there. Both matter (§35.11). Test with sudo logrotate -d /etc/logrotate.d/hmailserver. |
| A stop takes five minutes and the process is then killed | ShutdownDrainSeconds is larger than TimeoutStopSec, or a session is genuinely stuck. Raise both together (§35.8). |
crash-oracle.log has a new line |
A memory-safety fault. The record is written before the process dies, and Restart=on-failure brings it back after five seconds. During a start-up fault, look in /tmp/hmailserver-crash-oracle.log instead. Report it with the surrounding error log. |
| Something is wrong with the paths and you cannot tell which | Run the Installation paths diagnostic, one of the tests POST /api/v1/diagnostics/run and the Control Deck’s Diagnostics view run (both since 6.3.4): it lists all six configured directories and whether each exists, where --check-config prints three. The Languages line is expected to be absent here and is deliberately not counted as a failure. |
When you report a problem, say which package and architecture (
hmailserver --version, anddpkg -l hmailserverorrpm -q hmailserver), which backend and version, the output of--check-config, and the last hundred lines of the error log and ofjournalctl -u hmailserver. On a platform this new, the packaging details are often the answer.
More: The hmailserver command line on Linux: every verb, who runs it, the files, the unit and Databases on Linux: PostgreSQL or MariaDB, the client, TLS and the upgrade floor on the forum.
36. The Control Deck and the webmail portal
hMailServer has two browser pages: the Control Deck at /, which an
administrator uses to run the server, and the portal at /portal, which a
mailbox owner uses to read and send mail. Both existed before 6.3.0 and both did far less — the Deck
read and did not write, and the portal read mail rather than sending it — and 6.3.0 is what made them
what this chapter describes. Both are served by the REST listener of chapter 24, on the same port, under
the same authentication. Much of this chapter describes the Control Deck and the portal as they were at
6.3.1, which changed nothing in either page, and a sentence that gives a later change names the release. 6.3.3 rebuilt the webmail to the
shape of Gmail and Outlook.com (tabs, mute, pop-out windows, search history, fourteen more search
operators, follow-up dates, quick steps, CardDAV for the address book) and moved the Deck most of the
way to the desktop Control Panel. 6.3.4 added calendars and tasks over CalDAV, OpenPGP,
offline mail and a second account to the webmail, and brought the Deck to 458 of the 466 properties the
Control Panel writes by the project’s census, whose eight missing, an account’s directory fields,
the Deck edits on a route of their own; 6.3.4 also took the Deck from the fifteen views of 6.3.3 to forty. 6.3.5 brought it to 498 of the 508, the
ten missing an account’s directory fields and its external ID, and the server’s country policy. The 6.3.3 and 6.3.4 entries in chapter 25 and the release notes on the
downloads page describe the rest until this chapter is rewritten.
At 6.3.1 the compiled server differed from 6.3.0’s
by a version stamp and one comment line, and everything else in it is the release path.
They matter most because a Linux server has no Control Panel and no COM. On Windows they do not
replace it — chapters 5 and 20 still describe the program that does everything. At 6.3.5 the Deck reaches
498 of the 508 properties that program writes, but a few things are still the Control Panel’s alone, editing
the event scripts and the Active Directory pickers among them, and some views are the Deck’s alone: the
migrations, the reports, the webhooks and the inbound webhooks. On Linux (chapter 35) the Deck is the only
graphical administration front end there is; since 6.3.4 the packages also install hmctl, a
command-line client over the same REST API.
36.1 Two pages, one listener
One listener answers three things and, since 6.3.4, the sign-in, reset, reading and list pages named below. GET / and GET /index.html return the
Deck; GET /portal and GET /portal.js return the portal; everything under
/api/v1/ is the API, and since 6.3.4 so is SCIM provisioning under /scim/v2/, which answers only to an API key holding provision. There is no second port and no second process.
All four page routes — /, /index.html, /portal and
/portal.js — are unauthenticated to fetch, deliberately: what comes back is a static
sign-in card with no data in it, and everything its script then calls is authenticated normally. Serving
the shell without a credential is what stops the browser raising its own native password box over the
page. What the pages load beside them is served without a credential too: the webmail’s language
catalogues at /portal-lang/<code>.json and its S/MIME module at /portal-smime.js
and, since 6.3.4, its OpenPGP module at /portal-openpgp.js and its Markdown renderer at
/portal-markdown.js, the Deck’s catalogues at /deck-lang/<code>.json and the help
documents at /api/v1/help. So, since 6.3.4, are the single sign-on pages (/portal/oidc/,
/portal/saml/, /saml/metadata), the password-reset pages (/portal/reset,
/recovery/verify and /api/v1/password-reset), the reading pages of a message that
expires or is protected by a password (/confidential/, /portal/protected/ and
/api/v1/protected/), the list manager’s pages under /lists/, and the two routes the
sign-in card asks before anyone has signed in, /api/v1/signin-methods and
/api/v1/passkeys/challenge.
| Aspect | Control Deck | Portal / webmail |
|---|---|---|
| Credential | The administrator password; since 6.3.4 also an account that holds an administrator role, which sees only what the role allows, a passkey the administrator enrolled, or the organisation’s OpenID Connect or SAML sign-in for an address on the server’s list of Deck administrators | A mailbox address and its password, or an app password once the account has a second factor; since 6.3.4 also a passkey, single sign-on, or a recovery code beside the password, once |
| Where the bytes live | A file on disk: <ProgramFolder>/WebAdmin/index.html, 66,732 bytes at 6.3.1; since 6.3.4
its language catalogues are files beside it, in WebAdmin/languages |
Compiled into the binary: 21,636 bytes of markup and 61,411 of script at 6.3.1, and since then its catalogues and its S/MIME and OpenPGP modules too |
| If it is missing | A built-in stub page says so | Cannot be missing |
| Inline script | Yes — script-src 'unsafe-inline' |
No — script-src 'self' |
| Addresses, Back and Forward | No | Yes |
| Its script executed in CI | No at 6.3.1; at 6.3.4 yes, by build/check-deck-script.py in the harnesses job |
Yes — 88 checks on every push at 6.3.1; at 6.3.4 by build/check-portal-script.py in the same job |
The Deck is read off the disk as raw bytes, so the characters in its own markup survive any code page.
The Linux packages install it at /usr/share/hmailserver/WebAdmin/index.html and the Windows
installer at {app}\WebAdmin; it is found through ProgramFolder in the
configuration, not through the binary’s directory. If it is not there the server answers a stub reading
“Web administration page not installed. The REST API is available under /api/v1/.” and nothing
else breaks.
The portal is the opposite, and the source says why: a portal is exactly the page that has to work on a machine with nothing else on it, and a page that depends on a file beside the binary is a page that is missing where somebody most needs it. At 6.3.1 it was three C++ string literals; its language catalogues and its S/MIME and OpenPGP modules have since been compiled in the same way. Nothing is read from disk and nothing is fetched from anywhere — no font, no image, no stylesheet, no script from a CDN.
36.2 Turning them on
There is one switch for all three surfaces. RestApiPort turns on the
API, the Deck and the portal together. There is no separate setting for either page; those routes are
unconditional on the listener the API opens. If you want one you have all three, and section 36.13 is
about what to do with that. Since 6.3.4 these keys are kept in the database (chapter 21): set them in
the Control Panel on Windows (Monitoring & troubleshooting → API & monitoring), or with
sudo hmailserver --set-setting on Linux, since a line edited in hMailServer.ini after the
server’s first start is ignored in favour of the stored value, named in the error log as HM5804
and, where the service may write the file, put back. Written into the file, the lines below are taken into the
database at a new installation’s first start. On a server being upgraded, a line edited after the old
server last started, including one edited to prepare the upgrade, is taken for an edit at the first 6.3.4
start (HM5804) and not applied, so set these keys after upgrading. All four wait for a
reinitialise rather than a restart.
[Settings]
RestApiPort=8080 ; 0 leaves everything off; this is the default
RestApiBindAddress=127.0.0.1
;RestApiCertificateFile=/etc/hmailserver/rest.pem
;RestApiPrivateKeyFile=/etc/hmailserver/rest.key
The listener refuses to start rather than come up without a credential or without TLS, and logs
which. Four messages begin RestApi: Refusing to start; these are the two you are likely to
meet:
RestApi: Refusing to start - the administrator password is not set.
RestApi: Refusing to start - TLS certificate is required unless bound to 127.0.0.1 or ::1.
Set RestApiCertificateFile and RestApiPrivateKeyFile.
A TLS context that cannot be prepared accounts for the other two, logged in the same form: one when
the shared TLS configuration could not be applied to the configured certificate — an unreadable
certificate file, a private key that does not match, each of which SslContextInitializer has
already reported in detail as HM5113 — and one when constructing the context threw. An empty administrator
password disables the whole thing. The TLS exemption applies only to a bind address that is exactly
127.0.0.1, localhost or ::1 — exact literals, not
127/8 and not mapped forms, because this is a security gate and widening it
would be a separate decision. With no certificate configured the listener falls back to the ACME
certificate the server already renews, when both fullchain.pem and privkey.pem
exist, and logs that it has. When TLS is used the context is built through the same initialiser the mail
protocols use, so the configured cipher list, option mask and TlsKeyExchangeGroups —
including the hybrid post-quantum key exchanges — apply to it. That matters because the obvious
alternative, a listener that builds its own context, sets a floor and then takes OpenSSL’s defaults for
everything else: configured post-quantum groups would silently not reach it. The one thing deliberately
not taken from the shared configuration is the protocol
floor: TLS 1.2 stays the minimum here, applied after the shared settings so that it can only tighten
them, because an administrator who opened the mail protocols up to TLS 1.0 for an ancient client was not
deciding anything about an HTTP API. §24.1 is the same switch seen from the API’s side, with the
four keys in a table. See chapter 9 for certificates and chapter 22 for where this port
sits.
sudo apt install ./hmailserver_6.3.8_amd64.deb # Ubuntu 26.04; any other release: its own .deb, or with dnf its own .rpm (§35.2)
# set [Database] in /etc/hmailserver/hMailServer.ini first - chapter 35 has the whole sequence
sudo hmailserver --set-admin-password # as root: the file is root-owned, and this writes it
sudo -u hmailserver hmailserver --create-database
sudo systemctl enable --now hmailserver
sudo hmailserver --set-setting RestApiPort=8080 # since 6.3.4 the database holds it, not the file
sudo systemctl reload hmailserver
# open http://127.0.0.1:8080/ and sign in as Administrator
Reloading stops and restarts the listeners inside the running process, so do it when nothing is
mid-delivery. On Windows, run the installer over the existing installation: nothing 6.3.0 added is on by
default, and without RestApiPort a server upgraded to 6.3.0 behaves exactly as 6.2.28 did;
6.3.4 changes some things on the upgrade without a switch (§18.5). Two
things about that installer are worth knowing before you run it. From 6.3.1 it is
Authenticode-signed — the elevation prompt names Progressive Robot Ltd rather than reading
Unknown publisher — but SmartScreen still warns, because a signature is not reputation, and
every release before 6.3.1 is unsigned and will stay that way, a published release being immutable. Only
the Windows installer carries an Authenticode signature; there is none for a .deb, an
.rpm or an AppImage, and up to 6.3.3 the check that covers every asset on either platform is
the Sigstore .cosign.bundle beside it, verified with cosign verify-blob; 6.3.4
carries none (§4.1). A 6.3.4 download is checked instead against the release’s
hmailserver-6.3.4-SHA256SUMS.txt, whose SSH signature,
hmailserver-6.3.4-SHA256SUMS.txt.sig, is made with the key that signs the release tags:
ssh-keygen -Y verify on the sums file, then sha256sum -c on the downloads
(SECURITY.md, Verifying a release).
And if the installation you are upgrading is older than schema 6030, read chapter 18 first: the
6029-to-6030 upgrade step adds seventeen foreign keys and reads every child table once, so plan it like an
index build. The defect 6.3.1 named in that step, on a database that already held orphaned rows, was
fixed in 6.3.2, so an upgrade to 6.3.4 does not meet it.
36.3 Signing in to the Control Deck
Before 6.3.0 the Deck kept the base64 of user:password in the browser’s session storage
and sent Authorization: Basic on every request. That is gone. The password is now presented
once, exchanged for a cookie, and forgotten. §24.2 is the mechanism as the API defines it; this
section is what the two pages do with it.
POST /api/v1/session HTTP/1.1
Authorization: Basic QWRtaW5pc3RyYXRvcjpzZWNyZXQ=
X-Requested-With: hMailServer
HTTP/1.1 201 Created
Set-Cookie: hmailsession=<64 lowercase hex>; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200; Secure
{"administrator":true,"idle_seconds":1800,"lifetime_seconds":43200}
The portal’s sign-in is the same exchange with the mailbox address in place of
Administrator. The token is 32 bytes from the CSPRNG, hex-encoded; only its SHA-256 digest
is kept server-side and the comparison is constant-time, so the session table holds nothing that logs
anybody in. HttpOnly means the page’s own script never reads it;
SameSite=Strict means a request originating on another site does not carry it;
Secure is set whenever the browser came over HTTPS: when the listener speaks TLS, which is
everywhere but loopback, and, since 6.3.4, when a reverse proxy on this machine in front of the loopback
listener says X-Forwarded-Proto: https. A proxy that terminates HTTPS should send that header;
one that does not leaves the cookie without Secure. There are
two ceilings — by default 30 minutes idle and 12 hours absolute, whatever the user is doing, so a
captured cookie stays useful for a bounded time rather than for as long as the victim keeps clicking —
and at most 1,000 live sessions, the least recently used dropped if the table is
full. Since 6.3.4 the two ceilings are session policies an administrator sets server-wide or per domain
(session_idle_seconds and session_lifetime_seconds, at most thirty days each; 0 means
not set, and the defaults apply), beside two switches: one session per device, and a session bound to the
address it began on. They are re-read at every request, and the four server-wide values are in the
Deck’s Settings view, in the server group.
A write must announce itself. Any cookie-authenticated request whose method is not
GET or HEAD must carry X-Requested-With: hMailServer, enforced
centrally before routing. The refusal is a 403:
{"error":"a request that changes something must carry X-Requested-With: hMailServer when it is authenticated by a session cookie"}
Cross-site request forgery is closed twice over: SameSite=Strict means another site’s
request does not carry the cookie at all, and the header is one a browser never adds on its own and
another origin cannot add without a preflight this server never grants. It applies to cookie
authentication only — a script using HTTP Basic or a bearer key is unaffected.
What ends a session. An administrator session row holds a credential stamp:
the SHA-256 of the stored administrator password hash as it stood at sign-in — a hash of a hash, so the
table still holds nothing usable. It is re-checked on every request, and if it is now empty (the API
switched off) or different, every administrator session is dropped. For accounts,
POST /api/v1/me/password signs out every other browser on that account, and deactivating or
deleting an account drops its sessions on their next request. Since 6.3.4 an account’s browser session
is also an entry of its device list, and revoking it, on the webmail’s Security page or the Deck’s
Devices view, ends it at its next request; a password reset through a recovery address ends every session
of the account. And the first administrator sign-in after the upgrade to 6.3.4 hashes an administrator
password stored the old way (the Windows installer’s unsalted MD5 among them) again as PBKDF2, which
changes the stamp, so a Deck session made under the old hash ends once. Sessions live in a process-local table, so
a restart of the services holds none of them: there is no persistence and no “remember me”.
An API key cannot mint a session. Both session routes are allowed only to an account
password or the administrator password and, since 6.3.4, to a passkey’s assertion and to the one-use
ticket a finished OpenID Connect or SAML sign-in carries, each presented to the same
POST /api/v1/session; a bearer key is refused with “a browser session is started
with an account’s password or with the administrator password, not with an api key”. A key is
already the credential a script keeps, and a cookie minted from one would be a second credential with
none of the key’s restrictions written on it. A session cookie cannot mint another session either.
When a second factor is enrolled on the administrator credential, the first
POST /api/v1/session answers 401 with X-hMailServer-OTP: required and
"second_factor":"required" in the body; the Deck then reveals a code field and re-sends with
X-hMailServer-OTP. Since 6.3.4 a code is taken once: somebody asked for one again within the same
half minute (to register a passkey straight after signing in, say) waits for the authenticator’s next code.
After five wrong codes in fifteen minutes every code is refused for fifteen minutes, with 429.
The portal’s sign-in has no field for the authenticator’s code — an account with a second
factor enrolled cannot sign in with its own password at all, and uses an app password; since 6.3.4 a passkey
or single sign-on works too, and a recovery code beside the password opens the webmail once. The
authenticator’s code is asked for where the account’s own password is, the password change among them
(section 36.6).
Two details for debugging by hand. A 401 omits WWW-Authenticate: Basic for a request a
page’s script made, detected by Sec-Fetch-Mode or by X-Requested-With being
present; curl and a browser that navigated straight to a route still get the challenge,
and without this an expired session would put the browser’s native password box over a working page.
And the page keeps no secret: after sign-in it clears the password field and writes one non-secret mark,
sessionStorage["hmsSession"] = "1", so a reload asks the server whether the session is live
instead of drawing the sign-in card over a working one.
More: Signing in: passwords, second factor, passkeys, single sign-on, recovery, devices on the forum.
36.4 What the Control Deck can do
At 6.3.1, ten views: Dashboard, Domains, Delivery queue, DANE / TLSA, Settings, Rules, Routes, Certificates,
Ports, Logs. 6.3.3 had fifteen and 6.3.4 has forty; the twenty-five 6.3.4 added are Checklist, API keys,
Roles, Quarantine, Held for review, Archive, Discovery, Journaling, Audit trail, Changed from stock, Metric
history, Reports, Reputation, Spam traps, Webhooks, Inbound webhooks, Devices, Remote domains, Groups,
Folders, Migrations, Update, DNS records, Diagnostics and Passkeys. The dashboard re-reads GET /api/v1/status every three seconds while it is the
open view.
Settings forms generated from the server’s own document
This is the part that keeps working after this manual is out of date. The Deck fetches
GET /api/v1/openapi.json once per session and draws each settings form from the schema of
that group’s PUT. Nothing in the page lists a settings key by name. Its own
note states the contract: “Every field below, its type and its words are read from the server’s own
OpenAPI document, so a setting added to the API appears here on its own.”
A boolean becomes a checkbox, an integer a number box, an
array a one-per-line textarea, an enum a select of exactly the permitted words,
anything else a text box. readOnly gets a read-only badge and static text rather
than a control; writeOnly, or a key ending in password, gets a
write-only badge and an empty password box placeholdered “left unchanged unless
filled”, sent only if filled in; a key in the schema’s required list gets a
required badge. At 6.3.1 a key whose description matched “takes effect when the server
restarts” got a restart badge — not from a hand-written list, but because the server
appended that sentence to a key’s description when its effect was marked as such. Since 6.3.4 a field kept
in the settings store carries one of the three marks the Control Panel shows, Applies on save,
After reinitialize or After a restart, read from the document’s x-applies, so the
page is still the reader and a key whose effect changes re-badges itself. A bar above the groups lists the
saved settings that still wait, with their stored and running values and Apply now, and a
setting the server’s environment manages is shown as Set by the environment, with no control. The
table gives 6.3.1’s three groups.
| Group | Path | Keys | Read-only | Write-only |
|---|---|---|---|---|
| Server | /api/v1/settings |
63 | 0 | 1 |
| Anti-spam | /api/v1/settings/antispam |
34 | 0 | 0 |
| Logging | /api/v1/settings/logging |
16 | 5 | 0 |
| Total | 113 | 5 | 1 |
At 6.3.1, 113 keys described, five of them read-only facts about where the log is written rather than settings,
which leaves 108 writable. The one write-only key is the SMTP relayer password: written
and never read back, and omitted from its group’s GET by design. Chapter 21 remains the
reference for what each key means. Saving sends only the keys whose control differs from
the value the form was drawn with, then re-reads the group; a save with no differences says “Nothing
changed” and sends nothing. A filter box narrows the fields by key, caption or description. The API’s groups have
grown since, to nine at 6.3.3 and thirteen at 6.3.4, anti-virus, backup, bounces, cache and scripting among
them, each with a GET and all but the read-only directories group with a PUT, so at
6.3.4 the Deck draws twelve forms; the OpenAPI document (chapter 24) lists them.
Rules, routes, certificates, listeners, accounts
| Object | Create | Edit | Delete |
|---|---|---|---|
Global rules — /api/v1/rules |
yes | yes | yes |
SMTP routes — /api/v1/routes |
yes | yes | yes |
Listeners — /api/v1/ports |
yes | yes | yes |
Certificates — /api/v1/certificates |
yes | no | yes |
Accounts — POST /api/v1/domains/<domain>/accounts, PUT and DELETE /api/v1/accounts/<address> |
yes | yes since 6.3.3 (no at 6.3.1) | yes |
| Domains and distribution lists — since 6.3.3 (section 36.5) | yes | yes | yes |
| Aliases — since 6.3.3; the API has no route to change one, so replace it by deleting and adding | yes | no | yes |
| Settings, the groups (three at 6.3.1, twelve at 6.3.4; the API’s thirteenth, the read-only directories group, has no form) | — | yes | — |
| Delivery queue — retry or remove | — | — | yes |
At 6.3.1 the read-only views were GET on /status, /domains,
/domains/<d>/accounts, /queue, /tlsa, /logs and
/logs/<name>?lines=N.
A certificate cannot be edited, because the API has no route to edit one. Replace one by adding the new certificate, pointing the listeners at it and deleting the old. The release notes say “rules, routes, certificates and listeners are created, edited and deleted”; the accurate sentence is that rules, routes and listeners are created, edited and deleted, while a certificate is added and deleted.
The same machinery draws the rule, route, certificate and listener forms from the document, and prints
the document’s own description prose above each editor. One honest exception: the
rule vocabulary — which words a criterion’s field and match take, and which parameters each
action type carries — is read out of the prose of the POST /api/v1/rules description rather
than from an enumeration, with a hard-coded fallback behind it. The document does now carry proper
enum arrays for a criterion’s field, its match and an action’s type — though not for the
parameters each action type carries, which are only schema properties — but the shipped page still parses
the sentence, and its own comment says that if the fallback is ever used the document and the page have
drifted apart. Do not read the rule editor as schema-generated the way the settings forms are.
The in-place restart
The Ports view carries a Restart the services now button. It confirms first — “Restart the server’s services now? Connections in progress are dropped, and this browser session ends with them.” — and then posts:
curl -u Administrator:your-admin-password -X POST \
https://mail.example.com:8080/api/v1/server/reinitialize
It answers 202, and it answers before it happens, because the REST listener
restarts with everything else. This is exactly what the Control Panel’s Apply now does (chapter 20):
every service stopped, the configuration reloaded, the services started again in the same process, so
that a new port, a certificate binding or a setting marked After reinitialize takes effect without
stopping the process; a setting marked After a restart still waits for the service itself to be
restarted, and since 6.3.4 most settings apply when saved and need neither; the Settings view’s
Apply now is the same reinitialise. A reinitialise also ends maintenance mode: on a
server that is draining, the listeners open again and delivery resumes, and the answer then carries
"drained": true. The page then polls GET / once a second, up to sixty times, without
credentials, until it answers 200, then shows the sign-in card saying “The services
restarted, which ended this session. Sign in again.” The credential-free poll is deliberate: a
request the server answers 401 is a request that puts the browser’s own password box on the screen. From
a script, poll GET /api/v1/status instead.
Every refused request answers {"error":"<a sentence>"} and the Deck shows that
sentence beside the control that caused it, unreworded. A 403 says why; a 401 deliberately says nothing
beyond that authentication failed. Every server value rendered into markup is HTML-escaped first, and
buttons carry their arguments as data- attributes rather than inline JavaScript, so no
server value is ever placed where an HTML entity in an account address would be decoded before the
handler is parsed.
36.5 What the Control Deck cannot do yet
At 6.3.4 this section is history. Since 6.3.3 the Deck edits domains, distribution lists and existing accounts, and adds and deletes aliases. The project’s parity census (hmailserver/docs/DeckParity.md) at 6.3.4 counts 466 properties the desktop Control Panel writes, 458 of them writable over REST and every one of those reachable from a Deck view; the eight it lists as missing, an account’s directory fields, are written by PUT /api/v1/accounts/{address}/directory and the directory card of the Deck’s account editor. What follows describes 6.3.1.
Said plainly, because the gap is real. The API writes all four of these; the Deck has no control anywhere for any of them.
| Not on the page | The routes that do it |
|---|---|
| Creating, changing or deleting a domain | POST /api/v1/domains, PUT and DELETE /api/v1/domains/<domain> |
| Aliases | POST /api/v1/domains/<domain>/aliases, DELETE /api/v1/aliases/<address> |
| Distribution lists | /api/v1/domains/<domain>/lists, /api/v1/lists/<address> |
| Changing an account that already exists — password, active flag, size limit, administrator level | PUT /api/v1/accounts/<address> |
The Domains view is read-only: it lists domains and, under each, that domain’s accounts, and it can
create and delete an account. It cannot change one. Until the Deck grows those views, use
curl or any other HTTP client:
curl -u Administrator:your-admin-password -H "Content-Type: application/json" \
-d '{"name":"example.com","active":true,"postmaster":"[email protected]"}' \
http://127.0.0.1:8080/api/v1/domains
One more thing belongs here rather than being discovered later. A domain’s DKIM signing
is written with the rest of the domain — its size limits, its signature and its relay host —
at PUT /api/v1/domains/{domain} or on the Control Deck, since 6.3.3;
/api/v1/domains/{domain}/dkim is a read, and a PUT there answers 404. Up to
6.3.2 none of these could be written over the API, so a domain that had to sign its outbound mail
with DKIM was not one to run on Linux (§24.15). Since 6.3.4 a DKIM key rotation’s schedule is saved with
the domain on the same route, and the rotation’s own actions are under
/api/v1/domains/{domain}/dkim/rotation, with a DKIM key rotation card in the Deck’s
domain editor.
36.6 The portal: what a user gets
/portal was a page that read mail. It is now a webmail client. A mailbox owner signs in
with their own address and password, and everything they do goes through the /api/v1/me/
routes, which answer to an account’s own credentials and to nothing else — the
administrator password and API keys are refused on every one of them, because neither is an account and
there is no mailbox behind them whose quota or vacation message could be meant. The one way an
administrator reaches them is a support session (6.3.3,
POST /api/v1/accounts/<address>/support-session), which acts as the person in the webmail
and leaves the webmail’s note that an administrator opened the mailbox; since 6.3.4 it is refused every
change to the account’s S/MIME and OpenPGP keys and to zero access, and cannot remove or rewrite that note.
| Capability | Route under /api/v1/me |
Notes |
|---|---|---|
| Account and quota | GET /api/v1/me, exactly |
The credential is checked exactly as an IMAP logon is, per-name lockout and auto-ban included. |
| Folder tree | GET /folders |
Also shared: public folders and other users’ folders, flattened into the same sidebar under the rights their owner granted. |
| Read a folder | GET /folders/<id>/messages |
limit 1-200, before_uid to page back, q to search inside. Fields decoded as FETCH ENVELOPE would. |
| Open a message | GET /messages/<id> |
Over 1 MB it is truncated: the attachment list is still there, the text and HTML are not. Over 32 MB nothing is parsed, so the attachment list is empty too and the attachment download route answers 413. Another account’s message is a 404. |
| Search | GET /search?q=... |
Subject, From, To, Cc, text or HTML, case-insensitively, over every folder the account may read, newest first. At most 2,000 messages scanned, and scanned, complete and more say when a search was cut short. Since 6.3.4 a request also stops after about two and a half seconds of reading, the hits are newest first across every folder by message id rather than folder by folder, and an answer that is not the end of the search carries complete: false with next_before_id, which before_id continues from. With the full-text index on, a message the index rules out is passed without its file being opened. |
| Flags | PUT /messages/<id>/flags |
Only the flags named change; the rights STORE asks for. Every IMAP session on the folder is told. |
| Move | POST /messages/<id>/move |
As IMAP MOVE: a copy with a new UID, then the original expunged. |
| Delete | DELETE /messages/<id> |
To the folder designated \Trash when there is one and it is not already there; final otherwise, or with ?permanent=1. Since 6.3.4 a message the person deletes is kept aside for recover_deleted_days (0, off, by default) and can be put back from Recover deleted items. |
| Send | POST /messages |
Every address goes through the checks RCPT TO makes for an authenticated sender, and a refused one is named. Queued through the same pipeline as SMTP submission. A copy marked read is kept in \Sent when there is one and the quota allows. At most 20 attachments, 12 MB together. |
| Save a draft | POST /drafts |
replace_id supersedes the previous draft, which is expunged — new content is a new message with a new UID, as IMAP requires. Sending from a draft deletes it permanently. |
| Change password | POST /password |
current must be the account password, not an app password; policy and reuse history apply as when an administrator sets one. This is where the account second factor applies: without X-hMailServer-OTP it answers 401 and X-hMailServer-OTP: required. Every other browser on the account is signed out. Hidden entirely for a directory-linked account. Since 6.3.4 a recovery code in X-hMailServer-Recovery-Code stands in for the authenticator’s, and five wrong codes in fifteen minutes refuse every code for fifteen minutes, with 429. |
| Vacation reply | PUT /vacation |
The whole state at once, with expires_date as YYYY-MM-DD when it expires. |
| Sieve filter | GET / PUT /filters |
Checked as ManageSieve’s PUTSCRIPT checks it, with the same wording in error (chapter 12). Empty removes it; over 256 KB is refused. |
| Name, forwarding, signature | GET / PUT /settings |
Each object the body names is applied whole; one it does not name is left alone. Enabled forwarding needs an address, and not the account’s own. |
| Held mail | /quarantine and its release / delete routes |
Only entries this address is a recipient of, and without the other recipients. A release delivers to this address only. A server that does not hold suspected spam for review says so, rather than showing an empty list. |
At 6.3.1 bulk actions called the flag, move and delete routes one message at a time, in order, then
reloaded once. Since 6.3.4 an action on a whole folder’s selection, Mark as read, Mark as
unread, Archive, Junk, Delete or Move, is sent to the server through
POST /api/v1/me/folders/{id}/bulk, which works through it a bounded step at a time: at most 1,000
messages a call for a flag and 500 for a move or a delete, and no new message taken on after about two and a
half seconds of work. Each answer says how far it got and the next call goes on from there, up to the newest
message the page had shown, while the bar counts Done so far; with SpamAssassin on, Junk over a whole
folder queues one lesson for each message moved.
A keyboard cursor moves with j/k or the arrows, Enter opens,
x ticks; shortcuts are suppressed inside a text field and when Ctrl, Meta or Alt is held.
Signing out clears the cookie, stops the new-mail probe, and empties the message pane, the list, the
folder tree, the attachment list, the message frame and the draft.
At 6.3.1 the only thing the portal stored in the browser was the light/dark theme. The
webmail of 6.3.4 keeps more there, among it the inbox listing and the last thirty-one opened messages in
localStorage, an outbox of sends that failed for want of a connection, the last ten searches
and, only when the reader chooses it in Settings, the mail of the last days, attachments included, in the
browser’s IndexedDB for reading offline, up to reader_offline_days_max days (30 by default; 0
offers none). Anyone who can open that browser can read the offline mail, and signing out removes it. And
one point that surprised people at 6.3.1: an HTML message was not displayed automatically.
The reader saw the text part, and a Show as sent button revealed the frame of section 36.10. Since
6.3.3 the reading pane shows a message’s HTML as the server’s own document
(GET /api/v1/me/messages/{id}/html) under a policy where nothing runs and nothing is fetched
until the reader allows it.
More: The webmail in depth: search, offline mail, expiring messages, send-as and recovery on the forum.
36.7 Addresses and navigation
The portal has an address space, and it is in the fragment rather than the path. The reasoning
generalises: the server answers GET /portal and no path below it for a folder or a message, so a
path route would be a 404 on the first reload. (Since 6.3.4 some separate pages do live under
/portal/, among them the password reset, the single sign-on steps and a password-protected
message’s reading page.) The fragment needs no server route at all, is never sent to the
server — so no folder name and no search term reaches a log — and gives the history API
everything it needs.
| Address | Shows |
|---|---|
#/f/<id> |
A folder. ?q=text searches inside it; &before=<uid> pages back. |
#/search?q=text |
A search across every folder |
#/m/<id> |
One message |
#/compose |
A new message. ?reply=, ?replyall=, ?forward= and ?draft= are each an address of their own. |
#/held, #/folders |
Quarantine; managing folders |
#/settings, #/filters, #/password |
The three self-service panels |
Because these are real history entries, Back and Forward work: Back out of a reply
returns to the message, Back again returns to the folder listing, which is shown again with the cursor on
the row it was on. And a reload lands where the reader was — on load the script stashes
the incoming fragment before anything else and replays it once GET /api/v1/me has succeeded,
so a bookmark at #/settings with no session shows the sign-in card, and signing in lands on
Settings rather than the inbox. Sign-out deliberately clears the address, replacing it with
#/ through history.replaceState — no history entry, no navigation event —
because otherwise a stale #/m/<id> would repaint the previous account’s message into
the next account’s session without a request.
The half-written reply. The release notes say one survives a reload; be precise,
because the source is narrower and the source is right. Back out of the compose form and in again
within a session and the words are still there, because the form is re-primed only when the
compose address changes. Across a reload the reply is rebuilt from the original — the recipient,
the Re: subject, the quoted original, the Cc list for a reply-all, and for Forward and Edit
draft the original’s attachments — but the characters typed into the body are not
restored; nothing persists them. A draft saved with the Save draft button survives everything, because it
is then a real message in the Drafts folder reachable at #/compose?draft=<id>. So: a
reload rebuilds the reply rather than losing it. Save the draft if you want the words.
36.8 Noticing new mail
A webmail page has to answer “is there anything new” without becoming a load problem. The portal does it with one small GET.
GET /api/v1/me/changes?since=9f2c8a...
HTTP/1.1 200 OK
{"token":"1b77e4...","folders":[{"id":1,"count":214,"unseen":3},
{"id":4,"count":11,"unseen":0}],"changed":true}
The server walks the account’s own folder tree breadth-first, skipping any folder an ACL keeps from the account — the same two rights the folder listing asks for. A ceiling of 10,000 folders per answer is what stops a parent-id cycle in a hand-edited or half-restored database turning a poll into an endless walk. The counts come from the cached per-folder message collection that IMAP, POP3 and delivery all share, so nothing opens a message file or walks the store; that is what makes it cheap enough to poll. The list is sorted by folder id before anything is hashed, so the token stands for the state and not for how the tree happened to load. Then this is built, SHA-256’d unsalted, and the hex digest is the token:
hmailserver/me/changes/2
account=<account id>
<folder id>:<message count>:<unseen>:<current UID>:<folder name>
... one line per folder, sorted by id
Note what is in there. The answer reports each folder’s message and unseen counts; the token additionally covers the folder’s current UID and its name. Both extras are needed: counts alone cannot see a message replaced — a poll spanning a delete and a delivery finds the same two numbers — and cannot see a rename at all. A UID is never reissued, so no addition leaves it alone, and the name is what a rename changes and nothing else does. The account id prefix is there so that two accounts whose mailboxes happen to have the same shape do not share a token.
Because it is a hash, the token carries no folder id, no count and no address a holder could read back;
it is a value to hand in again and nothing else. And ?since= is compared, never
parsed: equal means "changed":false, anything else — including a token this mailbox
never had — means "changed":true, because “something differs” is the answer that cannot lose
a message. Without since there is no changed member at all. It is a
/me route, so it is refused to the administrator password and to API keys.
On the page side:
- Every six seconds. The first probe carries no token; every one after it carries the token the last answer gave. When something changed — or when the answer names a folder the page has never seen — the folder tree and the open listing are both re-read.
- The cursor is restored by message id, not by row. The page records the id under the cursor, the ticked boxes and the scroll offset, re-fetches, then finds that id among the new rows. The case that matters is a message arriving above the cursor: the cursor stays on the message it was on, which has moved down. Ticked boxes for messages no longer in the listing are dropped.
- It stops dead while the tab is hidden, saying “Paused – this tab is in the background”, and probes immediately when the tab comes back. A portal left open all day in a background tab costs nothing.
- A server that does not answer the route is asked once and then never again. A 404 (it does not know the path) or a 403 (it does not know this is one of the account’s own) turns the probe off for the session, saying “This server does not report changes”. Asking an old server every six seconds for the rest of the day would be a defect of the page.
- A transient failure backs off to thirty seconds. A request that never reached the server is reported internally as status 0, specifically so the probe’s own continuation still runs and the next tick is still scheduled — otherwise a dropped network would stop the probe for good while the page went on claiming to watch. Polling stops on sign-out.
36.9 Managing folders
POST, PUT and DELETE on /api/v1/me/folders create,
rename and delete a folder. All three are /me routes, so a browser session must also carry
X-Requested-With: hMailServer.
The point of the implementation is that the judgements are IMAP’s, in IMAP’s own
sentences. Create runs IMAP CREATE’s checks in IMAP’s order: a name at all, then the folder
already existing, then the name being valid. A name carrying the hierarchy delimiter creates the whole
path exactly as CREATE "a.b.c" does, and a first element beginning with # — the
public namespace — is invalid here exactly as it is for a non-public CREATE. A folder is created
unsubscribed, as CREATE creates it. Rename is IMAP RENAME’s classic non-public flow: the new name is a
whole mailbox name, so renaming Projects to Work.Old moves it under
Work, creating Work if it is missing; subfolders are not touched and do not need
to be, because they carry their parent’s id. Depth is checked against the same maximum IMAP checks, and
the right checked is the delete-half right, per RFC 4314.
The refusals are literally the strings IMAP answers with, typos and all. These are the ones you are likely to meet; the document lists a few more, and every 500 means the row could not be written:
| Refusal | HTTP | From |
|---|---|---|
Folder name not specified. |
400 | The route’s own |
Folder already exists. |
409 | CREATE |
CREATE The folder name is invalid. |
400 | CREATE |
Folder could not be found. |
404 | DELETE |
Cannot rename INBOX. |
403 | RENAME |
Target folder already exist. |
409 | RENAME |
To many sub-folders in structure. |
400 | RENAME |
A folder cannot be moved into one of its subfolders. |
400 | RENAME |
ACL DeleteMailbox permission denied (required for RENAME). |
403 | RENAME |
ACL: DeleteMailbox permission denied (required for DELETE). |
403 | DELETE |
You cannot delete the inbox. |
403 | DELETE |
You cannot delete a folder the server has designated for a special use. |
403 | New here — not an IMAP sentence |
The two ACL sentences differ by a colon. That is IMAP’s own inconsistency, carried over verbatim
rather than tidied, so that a refusal from the API and a refusal from IMAP are the same string. A rename
the database refuses answers 500 The folder could not be renamed. and reports HM6520 in the
error log; the in-memory move is deliberately not unwound, because reversing a move between containers
half-correctly leaves a tree no reload would produce.
The one rule stricter than IMAP. DELETE refuses a folder the server has designated
for a special use — the effective designation the listing reports, whether set by
CREATE ... USE or inferred from the name. IMAP itself allows it. The route’s reasoning: a
portal is a different proposition. One mis-click on the folder designated \Sent takes every
sent message with it, the page has no undo, and the designation is the one piece of evidence that says
the folder is not an ordinary one. The whole subtree is checked, not just the folder
named — deleting an ordinary Archive that happens to hold Archive.Sent is
refused, because the delete would take the subfolders and every message in all of them. And such
a folder is not undeletable: it stays deletable over IMAP, where the client that designated it
can undesignate it first, and where the person deleting it asked for that mailbox by name.
The Manage folders view draws one row per folder of the account’s own tree. Public folders and folders shared from elsewhere are excluded — by comparing the owning account rather than testing it for truth, after a bug where a public folder’s account id of 0 read as falsy and drew controls the routes always refuse.
| Folder | Rename | Delete |
|---|---|---|
| INBOX | no | no |
| One with a special use, badged kept for \Sent and so on | yes | no |
| One the listing reports as not writable, badged read-only | no | no |
| Anything else | yes | yes |
The page’s own comment: the server refuses to delete the inbox and a designated folder, and it is
right to, because there is no undo here — and not offering the control is better than a refusal the
reader did not ask for. Three further behaviours, each pinned by CI. Create posts the whole path
in the server’s own delimiter — choosing INBOX as the parent and typing Bills posts
{"name":"INBOX.Bills"}, the delimiter taken from the folder listing rather than hard-coded.
Rename shows the whole path — INBOX.Work, not Work — because a
rename may move the folder, and showing only the leaf would make a move look like a rename and a rename
to the top level look like nothing. Delete takes two presses: the first turns the button
into Really delete?, and nothing goes to the server until the second. When the server does
refuse, the reader is shown its sentence verbatim.
36.10 How inline images work, and why
A sender embeds a logo in an HTML message. The HTML part points at it as
<img src="cid:[email protected]">, and the image itself is a MIME part in the same
message carrying that Content-ID. Displaying it is the most interesting problem on either
page, because the obvious design cannot work, and the reason it cannot work is worth understanding.
1. What the message tells you. GET /api/v1/me/messages/<id> returns
an attachments array in which every part — inline or not — is an entry:
{"attachments":[
{"index":0,"name":"invoice.pdf","size":184213,"content_type":"application/pdf","content_id":""},
{"index":1,"name":"logo.png","size":8422,"content_type":"image/png","content_id":"[email protected]"},
{"index":2,"name":"chart.svg","size":2190,"content_type":"image/svg+xml","content_id":"[email protected]"}
]}
content_type is the media type the part declares, lower-cased and without its parameters,
and empty when the part declares none. content_id is the part’s Content-ID with
the angle brackets stripped — the form a cid: URL uses.
2. The body is rendered in a frame with an origin of its own. The sender’s markup is never merged into the portal’s document. It goes into an iframe:
<iframe id="message-html"
sandbox="allow-popups allow-popups-to-escape-sandbox"
referrerpolicy="no-referrer"
title="The message as it was sent" hidden></iframe>
The two sandbox tokens present exist only so that a link the reader clicks opens. The two that matter
are the ones absent: allow-scripts, so nothing in the message runs, and
allow-same-origin, so the framed document does not get the portal’s origin.
3. And therefore the download route answers 401. Each link in the chain is forced by the one before it:
- The frame is sandboxed without
allow-same-origin, so its document has an opaque origin — not the portal’s origin, and not any origin. - A browser therefore counts every subresource request that document makes as cross-site.
- The session cookie is
SameSite=Strict, so it is not attached to a cross-site request. - So an
<img src="/api/v1/me/messages/42/attachments/1">inside the frame — the obvious design — arrives with no credential, and the route answers 401.
This was measured, not assumed. The source records the experiment: in Chrome on
9 September 2026, through a logging proxy, the same frame without the sandbox attribute sent the cookie
and got 200 image/png; with the sandbox attribute it sent no cookie and got
401. And the only thing that would fix it is the very attribute the frame exists to
withhold — weakening the sandbox to make the URL work would hand the sender’s markup this account’s own
origin, which is the whole thing the frame is there to prevent. There is no configuration that makes the
obvious design safe.
4. So the page fetches the bytes itself. The portal’s own document is on the
portal’s origin, so a request it makes is same-site and does carry the cookie. It fetches the attachment
with its own credentials, reads the response as a blob, converts it to a data: URL, and
rewrites the cid: reference in the HTML to that data URL before handing the markup
to the frame. The frame never makes a request at all; it is given the bytes. The budgets are on the page
side, because an unbounded version of this turns one message into a hundred megabytes of base64: at most
4 MiB per image, 12 MiB across all of them, and at most 12
images per message. That was how 6.3.1 did it. By 6.3.3 the server had taken this step over: the
frame shows the server’s own rendering of the message, GET /api/v1/me/messages/<id>/html,
which puts each cid: image in place as a data: URL, at 6.3.4 up to 64 images, 4 MB each
and 12 MB together, and the page frames the HTML only of a message of up to 1 MB, showing a larger one as
its text.
5. The frame’s own policy names no URL at all. The document handed to the frame begins with its own policy and a base target:
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; img-src data:">
<base target="_blank" rel="noopener noreferrer">
img-src data: and nothing else. Not 'self', not the server’s own host, not
any host. So a remote image — the classic tracking pixel at
https://tracker.example.org/pixel.gif — is left in the markup exactly as the sender wrote it
and is blocked by the policy. Opening a message still tells its sender nothing. At 6.3.1 the outer
page said the same at the outer level: its own policy was also img-src data: (section
36.11 has what changed there). By 6.3.3 the bar over a message that carries remote images offered to show
them, for that message once or always for that sender, so nothing is fetched until the reader asks for
it.
What decides whether something is inlined
This is how the page decided at 6.3.1, when it did the inlining itself (step 4). Not the message’s claim. The server’s Content-Type on the download. Two
gates, in this order:
- A pre-filter on the message’s claim, which only decides what is worth fetching at
all. The part must have a
content_id, must be within the budgets, and its declaredcontent_typemust be one ofimage/png,image/jpeg,image/gif,image/webp,image/bmp,image/x-icon,image/vnd.microsoft.icon— or absent, since a part declaring nothing is left to the second gate like everything else. - The decisive gate, on the bytes that come back. The fetched blob’s type must start
with
image/, and the resulting data URL must start withdata:image/. Anything else is discarded and thecid:reference is left exactly as it was — so it renders as nothing, and the part is still in the attachment list below, named, where the reader can download it deliberately.
The download route is what makes that second gate meaningful. It serves an attachment under the very
media type the listing reported — which is what would let an <img> pointed at it
render, with nosniff set — except for the types a browser would run or
render as a page, which all become application/octet-stream:
text/html application/xhtml+xml image/svg+xml
text/xml application/xml anything ending "+xml"
text/javascript application/javascript a part declaring no usable type at all
The reason is stated in the source: with the session cookie attached, an attachment that rendered as a
page on this origin could act as the page. Every download also carries
Content-Disposition: attachment (the filename in both the plain and the RFC 8187 form),
X-Content-Type-Options: nosniff, Content-Security-Policy: sandbox and
Cache-Control: no-store.
So an SVG is never inlined, even though SVG is an image format and the message says
image/svg+xml — because SVG can carry script, so the download route serves it as
application/octet-stream, so the blob’s type does not start with image/, so the
second gate discards it. The cid:[email protected] reference survives untouched in the
frame’s document, renders as nothing, and chart.svg stays in the attachment list. The same
applies to HTML, XML and script parts. The mechanism needed no special case for any of them: one rule
about what the server is willing to serve as an image produces the right answer for all of them.
36.11 Content-Security-Policy and why the script is a separate response
GET /portal returns text/html and GET /portal.js returns
text/javascript. Both carry identical headers:
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'unsafe-inline';
img-src data:; connect-src 'self'; frame-src 'self'; form-action 'none';
frame-ancestors 'none'; base-uri 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Cache-Control: no-store
The page’s only script element is <script src="/portal.js"></script>, the last
line before </body>. That split is the whole point. Because the script
is a separate response, the policy can be script-src 'self' with no
'unsafe-inline' — which means an inline <script> block would not execute
and an inline onclick= handler would not fire. Had the script stayed inside the page, the
policy would have had to permit inline script, and then it would have permitted any inline
script, including one that got there by accident.
The consequence for the page’s own code is strict: every value from the server is written as
text, never as markup. The script builds DOM nodes and sets their text content; there is no
assignment of server data to innerHTML anywhere. The single exception is the message body,
which is not written into the document at all but handed to the sandboxed frame of section 36.10.
Read the rest of the policy as a list of things the page has decided it will never need.
default-src 'none' — nothing, from anywhere, unless named below. img-src data: —
no image from any host, including this one. That was 6.3.1’s policy; by 6.3.4 the page loads the
directory’s photos by URL from this server, so its policy allows images from this server too, and
still nothing from anywhere else. connect-src 'self' — it talks to the server that
served it and to nothing else. form-action 'none' — no form posts anywhere; every write is a
scripted request. frame-ancestors 'none' — nobody may frame the portal, which is
clickjacking closed. base-uri 'none' — no injected <base> can redirect the
page’s relative calls somewhere else.
The Control Deck does not have this property, and the difference is honest rather than
accidental. The Deck is one self-contained file with its script inline, so its policy permits inline
script: default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self'
data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'. It
compensates in code rather than in policy, escaping every server value and dispatching through
data- attributes. That is a weaker guarantee, and it is why the portal — the page a much
larger number of much less trusted people sign in to — is the one built the harder way.
36.12 What CI guarantees
The portal’s script is not merely linted; it is executed on every push.
build/check-portal-script.py does three things.
It recovers the literals. It parses RestApiPortal.cpp for definitions of
exactly the shape const char *Name = followed by one string literal per line until the one
ending in a semicolon, unescapes them back into bytes, and requires all three of
PortalHtml, PortalScript and PortalHeaders to be there. A C++ raw
string literal is deliberately not understood: the concatenated form is what keeps the page
readable in a diff.
# extract the three literals, check them, run the script
python3 build/check-portal-script.py
# extract only, and leave portal.html, portal.js and portal.headers in DIR
python3 build/check-portal-script.py --extract-only --keep DIR
portal script: 88 checks passed
It asserts cheap structural truths before executing anything. These are the only
substring assertions in the harness, and each one is a property of the page rather than a name in it. The
build fails if the page loads any script that is not /portal.js; if it carries inline script;
if it carries an inline event handler; if any src or href names an
http:, https: or protocol-relative URL; if the headers do not name
script-src 'self' or frame-ancestors 'none'; or if any -src
directive names a source starting http or //, or is *.
Then it runs the script. build/portal-script-test.js builds a small DOM
from the extracted markup, stubs the fetch API with a recorded server, and stubs local storage, the HTML
parser, the file reader, the timers, the location and the history — defining them over node’s own
globals so the script sees only this world — executes the portal’s script in it, and asserts
88 behaviours. Why it exists is stated in the harness itself: until it did, the only
thing standing behind these two literals was a regression test asserting that certain substrings were
present in what the server served, which proves that a name is spelled somewhere in a file and nothing
whatever about what the page does. A script can carry a syntax error, a handler wired to an element that
does not exist, or a sign-in that keeps the password, and pass every one of those assertions.
In order, the 88 cover: the sign-in gate; the password sent exactly once and only to
POST /api/v1/session, the field emptied, nothing secret stored, every write carrying
X-Requested-With; a reload landing where the reader was; the sidebar and its unseen counts;
a folder being an address and its listing rendering; the keyboard cursor, and that a keystroke in a text
box is not a shortcut; opening a message and its being marked read; inline images and the
frame — all of section 36.10, including that the page and not the frame fetched the bytes, that
the frame is pointed at no URL on this server at all, that a remote image is left alone and blocked, and
that an SVG the download route will not serve as an image is neither inlined nor smuggled into the page;
Reply as an address, and Back and Forward returning to the message, the folder and the row the cursor was
on; the change probe, its token, and the cursor restored by id after a message arrived above it; nothing
polled while hidden; a server with no change route asked once and then not again; folder management,
including the whole path in the server’s own delimiter, the two-press delete and the server’s own refusal
shown verbatim; search as an address; the theme remembered; and sign-out, with the message pane, list,
folder tree, attachment list, frame, draft and address bar all emptied.
At 6.3.1 nothing equivalent existed for the Control Deck: its markup was checked into the repository and
CI verified that the Linux packages and the AppImage carried it byte for byte, but its script was not
executed. That is no longer so. The Deck has a harness of its own, build/check-deck-script.py
and build/deck-script-test.js, which runs the page against a recorded server, and in 6.3.4 the
harnesses job of the project’s GitLab pipeline runs the Deck’s and the webmail’s
harnesses, with the S/MIME and OpenPGP checks, in every pipeline but a scheduled one. The job failed in the first two pipelines to run the project’s whole GitLab CI file, the
first of them on 26 September 2026; 6.3.4 fixes the cause (the portal harness depended on the runner’s
time zone), and when 6.3.4 was released the job had not yet run on the fixed tree; it passed in 6.3.5’s tag pipeline. The count of 88 is
6.3.1’s too: by 6.3.4 the webmail is kept as Portal.html and Portal.js, which the
server serves from PortalPageData.cpp, and its harness has grown with each feature (the
importance marker alone has forty-three checks).
36.13 Deploying them safely
Start from the two facts that drive every other decision. There is one switch and one listener, so exposing the portal exposes the Deck. And the listener will not start without TLS unless it is bound to loopback, so there is no accidental plaintext deployment to make.
Posture one: administration only. The simplest, and the one the shipped configuration
documents. Bind to loopback, leave RestApiCertificateFile and RestApiPrivateKeyFile empty, and
reach it through an SSH tunnel. Nothing is reachable from the network, and mailbox users do not get the
portal — which may be exactly what you want. Since 6.3.4 both keys are kept in the database, so a line
commented out in hMailServer.ini does not clear a value the server has stored. And where an ACME
certificate has been issued, the listener takes it even on loopback and answers HTTPS, with a certificate
that does not name 127.0.0.1; open https://127.0.0.1:8080/ in that case.
ssh -N -L 8080:127.0.0.1:8080 [email protected]
# then open http://127.0.0.1:8080/ in a browser on your own machine
Posture two: the portal for users, the Deck for you. If mailbox owners are to reach
/portal from the internet, the administration page at / is on the same port by
definition. This is a deployment decision the server does not make for you. Put a reverse
proxy in front and split the surfaces there:
| Path | Where it belongs |
|---|---|
/portal, /portal.js, /api/v1/me and every path under it |
Public |
Every other path that begins /portal: the webmail’s own files (its language catalogues, the installed app’s manifest and service worker, and its script modules: /portal-lang/, /portal.webmanifest, /portal-sw.js, /portal-smime.js and, since 6.3.4, /portal-openpgp.js, /portal-markdown.js and /portal-protected.js) and, since 6.3.4,
the pages for a password reset, single sign-on, a password-protected message and the return from another
provider’s sign-in for Other accounts (/portal/reset, /portal/oidc/,
/portal/saml/, /portal/protected/, /portal/oauth/callback) |
Public — the webmail loads them, or a person opens them from a link |
/api/v1/session |
Public — both pages sign in through it |
/api/v1/portal/branding, GET only |
Public — after sign-in the webmail reads its domain’s own name, logo and announcement here |
Since 6.3.4: /api/v1/signin-methods, /api/v1/passkeys and the paths under it,
/api/v1/password-reset and /api/v1/password-reset/complete, /recovery/verify,
/api/v1/help and the paths under it, and /saml/metadata |
Public — sign-in by passkey or single sign-on, a forgotten password, a recovery address confirmed, the help button, and the metadata a SAML identity provider imports |
/files/*; since 6.3.4 also /confidential/*, /api/v1/protected/*,
/lists/* and /held/* |
Public — opened from a link by people who may have no account here: a file sent as a link, a message that expires, a password-protected message, a list’s subscribe, unsubscribe and archive pages, and the quarantine digest’s pages that deliver held mail or stop the digest |
/, /index.html; since 6.3.4 also /deck-lang/* |
Internal network or VPN — this is the Control Deck |
Everything else under /api/v1/ |
Internal network or VPN — this is the administration surface |
Since 6.3.4, /scim/v2 and the paths under it |
Wherever the organisation’s identity provider calls from: public for a cloud one such as Microsoft
Entra ID or Okta, internal otherwise. It takes nothing but an API key whose role holds provision |
/api/v1/session has to be public for the portal to sign anybody in, and it is the same
route the Deck signs in through. That is safe in itself — the session it mints for a mailbox address is
an account session, which reaches only the account’s own routes unless, since 6.3.4, the account holds
an administrator role, and then only the administration routes its role allows, which the proxy keeps
internal — but it does mean an attacker can reach the administrator sign-in, which is the argument for
enrolling a second factor on it. A proxy on the same machine that terminates HTTPS in front of the
loopback listener should send X-Forwarded-Proto: https. Since 6.3.4 that header is what marks
the session cookie and the sign-in cookies Secure, and without it they go without. Nothing in the
server reads a forwarded-for header, so behind such a proxy every request comes from loopback, which the
auto-ban leaves alone; an account’s per-name lockout, where AccountLockoutThreshold sets one (it is 0, off, by
default), and the limit on wrong one-time codes (five in fifteen minutes for each owner) still apply.
If you do not run a proxy, terminate TLS on the listener itself, or let it fall back to the ACME
certificate. Either way the port belongs in your firewall policy and in chapter 22’s picture of what this
server listens on.
- Leave it off unless you want it.
RestApiPort=0is the default and turns off the API, the Deck and the portal together. - Set a second factor on the administrator credential. Both pages already handle the
401 that asks for the code. The secret is kept in
hMailServer.ini, so where the service may not write that file — a new 6.3.4 Windows installation, which runs asNT SERVICE\hMailServer, and the Linux package, whose file is root’s — enrolling is refused (409 over REST); on Windows, move the service to LocalSystem for the enrolment and back. - Use scoped API keys for scripts, never the administrator password. A key is read-only unless you say otherwise, always carries an expiry — a bounded default when the request names none, and a date in the past is refused rather than stored — may be confined to named domains and a source address, and is stored only as a digest. No key can mint or revoke keys, or mint a browser session.
- Expect the restart button to sign everyone out, and remember that sessions are in memory: a restart, a crash or the 1,000-session cap ends them.
- On Linux, back up the secret key file with the database. A database restored without
/var/lib/hmailserver/.hmailserver-secret-keyhas lost every stored password (chapters 15 and 35). Since 6.3.4 that file also opens the stored-secrets key line inhMailServer.ini, once there is one (sudo hmailserver --create-stored-secrets-keymakes it), and that key seals every account’s second factor and the[Settings]secrets: keep the key file and the configuration together, or export the key withsudo hmailserver --export-stored-secrets-key FILEand keep the file with the database backups, stored apart from them as the password pepper is kept.
What the server already does, so you need not configure it: the session cookie described in section
36.3; X-Requested-With on every cookie-authenticated write; a rate limit of 200 requests per
10 seconds per credential, budgeted against the credential rather than the source address, with one log
line per credential per window; 64 connections, and a 64 KB / 30-second cap on a request, except the routes
granted more: the two attachment-carrying routes at 16 MB / 300 seconds and, since 6.3.4,
PUT /api/v1/me/settings (a large request like sending a message, so that a formatted signature
is held by the route’s own limit of 64,000 bytes rather than by the listener’s cap) and the SAML
sign-in’s POST /portal/saml/acs at 256 KB within the ordinary 30 seconds; and nosniff,
Referrer-Policy: no-referrer, Cache-Control: no-store,
frame-ancestors 'none' and base-uri 'none' on both pages. Chapter 17 is the
wider hardening picture.
36.14 Troubleshooting
| Symptom | Cause and fix |
|---|---|
| Nothing answers on the port | Look in the application log for RestApi: Refusing to start. Either the administrator password is not set, or the listener is bound to a non-loopback address with no certificate and key. Where the service may write hMailServer.ini, set the password on the Control Panel’s Sign-in & access → Administrative access page. A new 6.3.4 installation runs the service as NT SERVICE\hMailServer, which may not write it, and there the page’s change is refused with a sentence: from an elevated prompt beside hMailServer.exe, put the password in HM_ADMIN_PASSWORD and run hMailServer.exe --set-admin-password (it never takes the password as an argument), then restart the service. On Linux run sudo hmailserver --set-admin-password. Since 6.3.4 RestApiPort, RestApiBindAddress and the certificate keys are kept in the database: set them on the Control Panel’s Monitoring & troubleshooting → API & monitoring page, or with sudo hmailserver --set-setting on Linux, because a line edited into hMailServer.ini is reported as HM5804 and the stored value is used. The listener takes either change only at a reinitialise (for those keys, Apply now on the Control Panel’s pending bar; on Linux sudo systemctl reload hmailserver) or a restart of the service. |
| “Web administration page not installed” | <ProgramFolder>/WebAdmin/index.html is missing and the server has fallen back to its stub — on Linux, /usr/share/hmailserver/WebAdmin/index.html. hmailserver --check-config will not tell you, because the page is not one of the paths it prints. Check the file itself. |
| The portal is missing but the Deck works | Not possible from an absent file: the portal is compiled into the binary. Check you are asking for /portal exactly: the page keeps its own addresses after the #, and the only paths below /portal/ the server answers are the sign-in, password-reset and protected-message pages 6.3.4 added, and /portal/oauth/callback, where another provider’s sign-in for Other accounts returns. Check too that a proxy is not rewriting it or holding back the files the page loads beside it, whose paths begin /portal (/portal.js, /portal-lang/ and the others). |
Every write answers 403 about X-Requested-With |
You are authenticated by a session cookie and did not send the header. Either send it, or authenticate with HTTP Basic or an API key sent as a bearer token, which are exempt. Since 6.3.4 the header is also required on a write made with a webmail second account’s session token, and on POST /api/v1/session when the credential is in the body (a passkey assertion or a single sign-on ticket). |
A bearer key gets 403 from POST /api/v1/session |
Working as intended. A browser session is started with an account’s password or the administrator password (since 6.3.4 also with a passkey or a single sign-on ticket), never with an API key. |
| A correct one-time code is refused | Since 6.3.4 a code the server has accepted is refused if it comes again, for the administrator and for an account alike. Asked for a code twice within half a minute (to sign in to the Deck and then to enrol a passkey, say), wait for the authenticator’s next one. A script that sends the administrator password and a code with every request has only its first request in each half minute accepted; sign it in once with POST /api/v1/session, or give it an API key. Since 6.3.4 too, five wrong codes in fifteen minutes lock every code for that owner, the right one too, for the next fifteen minutes; the REST API answers 429 with second_factor_locked and Retry-After. Wait it out; a restart or a reinitialise of the services also clears it. |
| The browser raises its own password box over the page | Something reached a route as a navigation rather than as a scripted request — usually a link or bookmark pointing straight at /api/v1/.... |
| Everybody signed out at once | The administrator password changed, or the REST API was switched off and on; either drops every administrator session on its next request. A restart or a reinitialise of the services does the same, and since 6.3.4 that includes Apply now in the Control Panel or the Deck. After an upgrade to 6.3.4 the first administrator sign-in can also end the administrator’s browser sessions once, when it writes an older stored password hash again as PBKDF2. One user signed out everywhere means they changed their own password or, since 6.3.4, reset it through a recovery address, or the account was deactivated or deleted. Since 6.3.4 switching a domain off also ends its people’s sessions at their next request. |
| A session ends sooner than expected, or 429 under normal use | By default a session ends after 30 minutes idle or 12 hours in all. Since 6.3.4 these are session policies an administrator can change: server-wide as session_idle_seconds and session_lifetime_seconds (the server group of the Deck’s Settings view, as §36.3 names them; SessionIdleSeconds and SessionLifetimeSeconds over COM), and per domain in the domain’s dialog, and per account, which may shorten the values the account would otherwise have and not lengthen them; the administrator’s own session follows the server-wide values. Two more policies end a session early when switched on: one session per device, and a session bound to the address it began on, which ends when a phone moves from wi-fi to a mobile network. Revoking a client on the Devices view ends its session too. The rate limit is 200 requests per 10 seconds per credential. If a script shares the administrator password with a browser session they share the budget — give the script its own scoped key. |
| “This server does not report changes” | The server answered 404 or 403 to GET /api/v1/me/changes — usually a newer page talking to an older server, or a proxy not passing that path. The probe is off for the rest of the session; reload to try again. “Not watching (0)” means the request never reached the server, and it backs off to 30 seconds. |
| An inline image shows as a broken or empty box | The image was not inlined. In 6.3.4 the frame shows the server’s own rendering of the HTML (GET /api/v1/me/messages/<id>/html), which puts each cid: image in place as a data: URL, up to 64 images, 4 MB each and 12 MB together; an image past those limits is not inlined. The download route serves SVG, HTML, XML and script parts as application/octet-stream on purpose. At 6.3.1, when the page inlined images itself from that route, that kept them out of the frame (section 36.10). The part is still in the attachment list, named, and can be downloaded. |
| An HTML message shows as plain text, or a remote image does not load | A message over 1 MB is shown as its text: the page frames a message’s HTML only up to 1 MB. Remote images are blocked by the frame’s policy until the reader allows them: the bar over the message offers to show them for this message, or always for this sender. There is no server setting for either. In a second account’s messages (6.3.4) remote images are not loaded at all, and the bar says so. |
| Sending a large attachment fails | At most 20 attachments, 12 MB together, inside a 16 MB request with a 300-second deadline. Ordinary requests are capped at 64 KB and 30 seconds. Since 6.3.2 a larger file can be sent as a link that expires instead. |
| A folder cannot be created, renamed or deleted | The sentence shown is the server’s own, and it is the same sentence IMAP answers with; section 36.9 lists the common ones. The only refusal that is not IMAP’s is the one about a folder designated for a special use — delete it over IMAP if you really mean it. A 500 means the database refused the write; HM6520 is in the error log. |
| A setting saved in the Deck has not taken effect | Look for the mark beside the field. Since 6.3.4 a field kept in the settings store says when a save applies it — Applies on save, After reinitialize or After a restart — and most apply on save. A saved value still waiting is listed above the Settings view’s groups with its stored and running values; Apply now there reinitialises the services, which applies every key marked After reinitialize and ends this browser session. A key marked After a restart waits for the service itself to be restarted. A listener changed on the Ports view takes effect at a reinitialise too. It is not a [Settings] key, so it is not listed as waiting; the Ports view has the button that restarts the services in place. A value set with hmailserver --set-setting while the service runs, or by another server on the same database, reaches this one at its next save, reinitialise or restart. |
| The Update view will not install a release | A release that carries no Sigstore bundle the server can verify is not installed: the view says it cannot be verified and downloads nothing. 6.3.4 itself carries none, so no live update installs it and it is installed by hand. On Windows a server running as NT SERVICE\hMailServer, the account a new 6.3.4 installation runs as, cannot run an installer, so the apply is refused and the view says why. Download the installer from the release page, check that its Authenticode signature is valid, and run it as an administrator; chapter 18 describes the upgrade. |
| No control for a domain, an alias, a list, or changing an account | Since 6.3.3 there is: the Deck edits domains, distribution lists and existing accounts and adds and deletes aliases; section 36.5 records what 6.3.1 lacked. In 6.3.5 the project’s census counts 498 of the 508 properties the desktop Control Panel writes as reachable from a Deck view. Signed in with an administrator role rather than the administrator password, you see only the views the role allows, and a role confined to named domains is refused the server-wide ones. |
More: The Control Deck: browser administration at / and The webmail portal at /portal on the forum.
37. Glossary
| Term | Meaning |
|---|---|
| ACME | The protocol Let’s Encrypt uses to issue certificates automatically |
| ARC | Authenticated Received Chain — preserves authentication results across forwarding |
| BATV | Bounce Address Tag Validation — signs your envelope sender to detect backscatter |
| Backscatter | Bounces sent to a forged sender address; you are the innocent third party |
| DANE | Publishing your TLS certificate in DNS (TLSA records), secured by DNSSEC |
| DKIM | A cryptographic signature proving a message came from your domain unaltered |
| DMARC | A policy saying what to do when SPF and DKIM fail, and where to send reports |
| DNSBL | DNS blacklist — a list of IPs known to send spam |
| DNSSEC | Cryptographic signing of DNS, so answers cannot be forged |
| Greylisting | Temporarily rejecting unknown senders; real servers retry, most spambots do not |
| IMAP | Protocol for reading mail that stays on the server |
| MTA | Mail Transfer Agent — a mail server |
| MTA-STS | A policy telling other servers that mail to you must be encrypted |
| MX record | The DNS record saying which server receives a domain’s mail |
| Open relay | A server that forwards mail for anyone. Never be one |
| POP3 | Protocol for downloading mail to one device |
| PTR record | Reverse DNS — maps an IP back to a host name |
| Reinitialise | Stopping and starting the server’s services in place, so they reload their configuration: Apply now in the Control Panel or the Control Deck, or POST /api/v1/server/reinitialize. Since 6.3.4, 28 settings wait for one; most apply when saved |
| SASL | The framework mail protocols use to authenticate |
| SCRAM | A SASL mechanism where the password never crosses the wire |
| Settings store | Since 6.3.4, the database, which holds the [Settings] keys; [Settings] in hMailServer.ini is its copy, and a line edited there is ignored in favour of the stored value, reported as HM5804 and, where the service may write the file, put back (chapter 21) |
| Sieve | The standard language for filtering mail during delivery |
| Smart host | Another server you relay your outbound mail through |
| SMTP | The protocol for transferring mail between servers |
| SPF | A DNS record listing which servers may send mail for your domain |
| SRS | Sender Rewriting Scheme — rewrites the envelope sender when you forward, so SPF still passes |
| STARTTLS | Upgrading a plaintext connection to an encrypted one |
| SURBL | A blocklist of domains appearing in spam message bodies |
| TLS-RPT | Reports other servers send you when TLS to your server fails, asked for by your TLS-RPT DNS record; hMailServer sends the same reports about other domains once TlsRptFromAddress is set |
hMailServer 6.3.8 — maintained by Christopher Holloway / Progressive Robot Ltd. Licensed under AGPL-3.0-or-later. Source: https://gitlab.com/hmailserver/hmailserver, mirrored on GitHub. Bug reports: GitLab issues, or contact-project+hmailserver-hmailserver-86730559-issue-@incoming.gitlab.com without a GitLab account; security reports only by the routes in SECURITY.md (§19.8).