Skip to content

Upgrading Guide

chrisholloway5 edited this page Sep 8, 2026 · 3 revisions

Upgrading

How to move an existing hMailServer installation to this fork, or from one release of it to the next, and what the database upgrade actually does. Written because the upgrade is the one operation where an unpleasant surprise costs you mail rather than time.

This is the mechanism. For the step-by-step procedure, the per-step cost table and the decision tree, see Upgrading hMailServer.

The short version

Run the new installer over the existing installation. It preserves your configuration and your mail, stops the service, upgrades the database schema, and starts the service again. There is no separate migration step and no export and re-import.

Take a backup first anyway. Not because the upgrade is unreliable - the chain below has run continuously since hMailServer 4 - but because the one thing an upgrade cannot do is undo itself. See Rolling back.

Where you can upgrade from

The upgrade chain is continuous, from schema version 0 through to whatever version this build requires, applied one step at a time. A database at any intermediate version is brought forward step by step, so there is no "you must first upgrade to 5.x" hop to plan around.

The step count and the target version are deliberately not written here. They have gone stale on this page four times now, and a version number is exactly the sort of fact a reader trusts without checking. Read them from the tree instead: REQUIRED_DB_VERSION in hmailserver/source/Server/Common/Application/Constants.h is the version this build demands, and build\check-schema-versions.ps1 prints both numbers and proves the chain reaches one in exactly the other.

That includes databases created by the original upstream project. This fork did not branch the schema; it extended it. The current version is a superset, reached by the same mechanism upstream used.

How far back the chain reaches depends on your backend, and this is the one qualification worth knowing before you start. The steps are registered once, for every backend, but the SQL scripts they run are per-dialect and they do not all exist:

Backend Steps that ship Reaches back to
MySQL / MariaDB every step schema 0 (hMailServer 1.0)
Microsoft SQL Server every step schema 0 (hMailServer 1.0)
PostgreSQL every step from 5001 onwards schema 5001
SQL Server Compact (internal) every step from 5001 onwards schema 5001

In practice that is not a gap, because neither PostgreSQL nor the internal SQL CE database was a supported backend before hMailServer 5, so no database older than 5001 exists on either. But it is why the answer is "any version of your backend" rather than "any version". If a step's script is missing, DBUpdater says so by name and stops before touching anything - it checks that every file on the path exists before it runs the first one.

What one run looks like

flowchart TD
    A["Read hm_dbversion"] --> B{"Newer than this build requires?"}
    B -- yes --> C["Refuse by name: a database from a newer<br/>version, and downgrading is not supported"]
    B -- no --> D{"Equal?"}
    D -- yes --> E["Nothing to do, exit code 0"]
    D -- no --> F["Walk the registered steps from the current<br/>version, bounded by the number registered,<br/>and check every script file exists"]
    F -- "no step is registered for this version" --> G["Refuse by name"]
    F -- "a script file is missing" --> H["Refuse, naming the file"]
    F --> I["BEGIN TRANSACTION"]
    I --> J["Prerequisites script<br/>MS SQL and PostgreSQL only"]
    J --> K["For each step in order:<br/>EnsurePrerequisites, then that step's<br/>script for this dialect"]
    K -- "a statement throws" --> L["ROLLBACK where the backend can,<br/>show the script and the error"]
    K --> M["Run the schema probes on the SAME<br/>connection, so they see uncommitted DDL"]
    M -- "a probe is not satisfied" --> N["ROLLBACK where the backend can,<br/>naming the object that is missing"]
    M --> O["COMMIT"]
    O --> P["Read hm_dbversion back"]
    P -- "not the required version" --> Q["Report: the scripts ran without error,<br/>but the database is at the wrong version.<br/>Nothing can be rolled back from here"]
    P --> R["Delete the stale ERROR log, exit code 0"]
Loading

Three parts of that are worth understanding rather than skimming.

The whole path is validated before the first statement runs. Building the path also proves each file exists; a missing dialect script therefore stops the upgrade while the database is still untouched, not halfway through.

The probes exist because of a specific trap. Three of the four dialects mark their ALTER TABLE statements [IGNORE-ERRORS] so that re-running a step is harmless. The data-access layer matches that marker anywhere in the statement and then discards every error - while the update hm_dbversion statement in the same script carries no marker and always runs. A marked ALTER that failed for a real reason would therefore stamp the new version onto a database that does not have the column. Each such step now carries a probe that must be satisfied before the commit, and the probes run on the transaction's own connection so they can still see uncommitted DDL. build\check-schema-versions.ps1 is what makes sure a probe exists to run.

The version is read back rather than assumed. hm_dbversion is written by a statement of its own, in the same script as the schema change but independent of whether it worked, so the last thing DBUpdater does is re-query it. Nothing can be rolled back at that point, so a mismatch is reported and the ERROR log is deliberately left in place rather than cleaned up.

Its exit codes, which is what a scripted install actually reads:

Code Meaning
0 Success, or the database was already at the required version
1 The upgrade failed
2 A configuration error: unknown database type, no script directory, or no registered path to the required version
3 Authentication was cancelled - or, under /silent, could not be satisfied

What the upgrade touches

  • The schema only. Messages live on disk, not in the database - the database holds accounts, domains, settings and message metadata. No upgrade step rewrites message files.
  • Every supported backend. Separate script sets exist for MySQL/MariaDB, MS SQL Server, PostgreSQL and the embedded SQL CE. The installer picks the set matching your configured backend.
  • Nothing is deleted that carries live data. One table has been dropped in the chain's history and it is worth knowing about because a schema comparison will show it: hm_adsynchronization, created by step 3402 -> 4000 (which ships for MS SQL and MySQL only) and dropped by step 5004 -> 5005 on all four dialects. No CreateTables script creates it on any backend, so a database that came through the chain and a freshly created one agree about it afterwards. If you do find a table a fresh database does not have, it came from somewhere other than this chain.

How a backend's own behaviour changes the failure mode

The chain is the same everywhere; what a failed step leaves behind is not.

Backend DDL in a transaction So a failed upgrade leaves
PostgreSQL Transactional The database exactly as it was. This is the best-behaved of the four
Microsoft SQL Server Transactional for most DDL; the server also rolls back on its own for some errors Normally as it was
MySQL / MariaDB Commits DDL as it executes The steps that ran, applied. Re-running resumes from whatever hm_dbversion now says
SQL Server Compact Commits DDL as it executes The same

This is why DBUpdater's messages say "rolled back where the backend allows it" rather than promising a rollback it cannot deliver. On the two that commit as they go, the recovery is to re-run: the chain resumes from the version the database actually reports, and [IGNORE-ERRORS] makes a re-applied ALTER harmless.

Doing it

  1. Back up. Both halves, and they are separate things:
    • the database - with your backend's own tools (mysqldump, pg_dump, SQL Server backup). The built-in backup covers this too, but a native dump is what you want if you need to restore into a different server.
    • the data directory - the message files. This is the part that cannot be reconstructed from anything else.
  2. Note your current versions. The server version is in the Control Panel. The schema version is in a table of its own - select value from hm_dbversion, a single-column, single-row table, not a row in the settings table. (The Control Panel shows it on the Server status page and DBUpdater reads it too; the SQL is here for when the server will not start and neither of them will connect.) Write both down - if you need support, they are the first two questions.
  3. Run the new installer. It stops the service, installs, upgrades the schema, and restarts.
  4. Check it came up. The service should be running and listening on your configured ports. Then check the ERROR log for the window around the upgrade - an empty ERROR log is the signal you want.
  5. Send a message to yourself, end to end, and read it back over IMAP or POP3. A service that starts is not the same as a server that delivers.

If the upgrade fails

The installer checks both that the database tool launched and its exit code, and a non-zero exit code now fails the installation: the specific dialog naming the exit code is shown, the service start is still attempted, and then setup raises and finishes with a non-zero exit code of its own. A scripted deployment can therefore tell, which it could not in earlier releases - the dialog used to be the only signal and the wizard still reported success.

Two other things the installer refuses to do, both of which produce a failed install rather than a broken one:

  • It will not proceed if the service did not stop. The wait is bounded by ShutdownDrainSeconds plus thirty seconds, derived from your own setting because that setting deliberately holds the stop open while sessions finish. If the service is still running when the file copy starts, Inno offers Retry/Ignore, and Ignore leaves the old binary in place while the schema moves forward - after which the server refuses to start at all. Refusing to move past the Ready page is the only point at which that outcome can still be prevented.
  • It will not silently skip the .NET runtime. The bundled .NET 10 Desktop Runtime is installed /quiet /norestart when it is missing, and afterwards the probe is re-run rather than the exit code being interpreted - the only question that matters is whether the runtime is present now. A failure here is reported and named, and it matters because the database tools are themselves .NET applications.

If it does fail, the service may be installed but the schema only partly upgraded. The server does not paper over that: on startup it compares the database's version against the version it requires and refuses to run if they differ, in either direction, reporting HM5011 with both numbers appended. The two messages are worth knowing because they are what you will search for:

  • The database is too old for this version of hMailServer. Please run hMailServer Database updater (DBUpdater.exe) to upgrade it.
  • The database is too new for this version of hMailServer. Please upgrade hMailServer.

Both are followed by a space and then Database version: <n>, Required database version: <m>. A third, Database version could not be detected. (HM5010), means hm_dbversion could not be read at all - a connection or permission problem rather than a version one.

For the first, run DBUpdater.exe from the installation's Bin folder - that is the standalone updater, and it is what the error message names. (The installer drives DBSetupQuick.exe for the same job during an install; either will bring the schema forward.) It picks up from whichever version the database actually reports, so a retry after a partial upgrade resumes rather than restarting. If it fails again, the log names the step it stopped on, and that step number is the useful thing to put in an issue.

The second message means the database has already been upgraded past this binary - you are running an older server against a newer schema. Install the matching or newer server rather than trying to downgrade the database.

Rolling back

There is no downgrade path, and this is the one genuine sharp edge in the process. The schema upgrade is one-way: an older server will refuse to run against a newer dbversion rather than misinterpret it, which is the correct behaviour but means "just reinstall the old version" does not work on its own.

To roll back you need the pre-upgrade database backup. Restore it, then reinstall the older server. The data directory does not normally need restoring - message files are not rewritten by an upgrade - but if you restore an older database against a newer data directory, any mail that arrived after the backup will exist on disk with no metadata row, and will be invisible.

Which is the real argument for the backup: not fear of the upgrade, but the fact that rollback is only as good as the snapshot you took first.

Special cases

Moving to a different database backend is not an upgrade and the chain does not do it. There is no supported in-place conversion between backends. The route is a backup, a repoint and a restore, which is written up in Migrating the Database Backend.

Moving to a new machine: install the same version on the new machine, restore the database and the data directory, and confirm the data-directory path in the settings matches where you actually put it. Upgrade afterwards, as a separate step, so that if something goes wrong you know which of the two changes caused it.

The database password does not travel with the file. With ProtectStoredSecretsWithDPAPI=1, which is the default, the [Database] Password value in hMailServer.INI is protected with machine-scoped DPAPI (CRYPTPROTECT_LOCAL_MACHINE) and PasswordEncryption records that. Copied to a different machine it cannot be decrypted, and the server will not connect. Re-enter it there - through DBSetup.exe, or by writing the plain value and letting the server re-protect it - rather than expecting a copied ini to work. The same is true of every other secret the server stores in that envelope.

Upgrading silently - see the unattended install notes. Until 6.2.23 a silent upgrade of an installation with an administrator password set hung: DBSetupQuick.exe forwarded only /SilentIfOk and /silent to DBUpdater.exe, not the password, and DBUpdater then opened a modal password dialog nobody was there to answer. That is fixed. DBSetupQuick forwards the password on the upgrade path as well as the create path, the installer accepts it as /adminpassword=<password> and passes it through, and under /silent the shared authenticator fails with an exit code instead of prompting. So:

  • administrator password empty -> a silent upgrade works;
  • administrator password set -> pass /adminpassword=<password>; without it the database upgrade fails and the installer reports the non-zero exit code rather than waiting.

(New in 6.2.28: the live update's helper passes a single-use /upgradetoken=<hex> in place of the password, so an unattended update never handles the administrator password at all.)

Coming from before hMailServer 5, hMailServer.ini lived in the Windows directory. The installer copies it to Bin\hMailServer.ini, leaves a .old copy where it was and deletes the original; the server reads only the one in Bin.

The .NET 10 runtime. From the first release after 6.2.18, the Control Panel and the setup tools require the .NET 10 Desktop Runtime, which the installer bundles and installs when missing; 6.2.18 and earlier used .NET 8. Nothing about the server itself changed - it is native code with no .NET dependency, so an upgrade that fails to install the runtime still leaves you with a working mail server, just without the Control Panel. The runtime is bundled as windowsdesktop-runtime-10.0-win-x64.exe and installed /quiet /norestart, only when it is missing, and the setup tools (DBSetup, DBSetupQuick, DBUpdater) need it as well as the Control Panel does.

If you are changing an upgrade script

For contributors, because two of these have shipped as defects and both were invisible locally.

  • Statements are split on a blank line. SQLScriptParser breaks a script into commands at a blank line (; then a blank line, for PostgreSQL). Two statements written on consecutive lines are sent as one command, which SQL Server Compact rejects outright - and a create script that fails leaves a fresh install with no database, whose symptom is not an error anybody sees: the service starts and simply does not listen. Leave a blank line between statements.
  • Five hand-maintained lists describe the chain and nothing but build\check-schema-versions.ps1 was checking that they agree: REQUIRED_DB_VERSION in Constants.h; the step table in DBUpdater's formMain.LoadScripts; the version names in GetDatabaseVersionName; the four Upgrade<from>to<to><dialect>.sql files per step; and the probes in SchemaVerification.cs. Run that script after adding a step and before stamping a release; -SelfTest checks the checks.
  • build\check-db-scripts.ps1 executes them. It builds a throwaway SQL CE database from the create script and applies every upgrade script to a second one, using exactly the parser's splitting rules, and runs every probe against a freshly created database with a negative control. This is what would have caught the 6.2.22-pre4 create-script defect, and what now catches a probe a backend cannot run - the SQL CE case when exists (...) crash behind issue #114.

Verified against the code

Re-checked 8 September 2026 against the 6.2.28 tree, and two statements on this page were wrong and have been corrected:

  • The page said a failed database upgrade showed a dialog and then let the installer finish, so "the signal is the dialog and the log, not a failed installation". CurStepChanged now raises on a false result from RunPostInstallTasks, so setup finishes with a non-zero exit code.
  • The page said step 5004 -> 5005 drops hm_adsynchronization on MS SQL, MySQL and SQL CE "but not on PostgreSQL", and offered that as an example of an upgraded database differing from a fresh one. Upgrade5004to5005PGSQL.sql drops it too, via hm_drop_table, so the example was not real. What is true is recorded above.

The earlier note stands and is worth keeping: the step count and target version had gone stale a fourth time - the page said 73 steps to 6022 while the tree had moved on - after a previous revision had already written down that deriving them beats transcribing them, and then transcribed them anyway. They are gone from the prose; the sources below are the answer.

Against: formMain.LoadScripts, CreateUpgradePath, DoUpgrade and VerifyUpgradedSchema in DBUpdater (the registered steps, the path walk, the transaction, the probes and the read-back), DBUpdater/Program.cs (the exit codes), Constants.h's REQUIRED_DB_VERSION, hmailserver/source/DBScripts (which dialects ship which steps, and the hm_adsynchronization history), DatabaseConnectionManager::GetCurrentDatabaseVersion (select ... from hm_dbversion), Application::OnDatabaseConnected (the three refusal messages and their error codes, verbatim), hMailServerInnoExtension.iss (StopHMailServerService, InstallDotNetRuntime, MoveIni, the exit-code check and the RaiseException that follows it), DBSetupQuick's UpgradeDatabase (which arguments are forwarded), Authenticator.AuthenticateUser (the silent-upgrade password dialog), IniFileSettings::SetPassword and DataProtector (the machine-scoped DPAPI envelope), and build\check-schema-versions.ps1 and build\check-db-scripts.ps1.

Clone this wiki locally