Repository navigation
Command Line
Since 6.3.4: both clients are in the repository, and the Linux packages install
hmctlas/usr/bin/hmctl. The Windows installer carries neither client. Since 21 October 2026 both are published, at 6.3.7:hmailserver-clion PyPI (pipx install hmailserver-cli) and theHmailServermodule on the PowerShell Gallery (Install-PSResource HmailServeron PowerShell 7.4 and later, orInstall-Module HmailServeron Windows PowerShell 5.1) (CommandLinePackages.md). Both can also be taken from the repository (hmailserver/source/Tools/Cli/).
Source of truth: hmailserver/docs/CommandLine.md, kept in the repository and copied here. Re-copied from master 92b8f9f63 on 26 September 2026, the same at the tag v6.3.4 (51e2dbda0), and re-copied on 5 October 2026 from the document as the tag v6.3.6 (e3c448aa6) has it (CommandLine.md at v6.3.6): the live update's five trust settings, which
config export,diffandapplyleave out, the module's manifest, andhmctlas the PyPI packagehmailserver-cli. The document is the same at the tag v6.3.7 (31d656c0c), the release, tagged and published on 17 October 2026, read again on 18 October 2026 (CommandLine.md at v6.3.7). The note above this one and one note under What it does not carry are this wiki's own additions. Re-copied on 20 October 2026 from the document asmasterhas it from batch50, 0c0b6bae7 (CommandLine.md at 0c0b6bae7), which changes it in two places: the queue examples, one forhmctland one for the PowerShell module, filter ontries, the field a queue item has, where they filtered onretry_count, which no item carries. Its links point at the documents as 0c0b6bae7 has them, or at this wiki's copy where there is one. The document is the same asmasterhas it from batch52, 72be22b4e, onmastersince 21 October 2026 (CommandLine.md at 72be22b4e). Since 22 October 2026 the note above this one says that both clients have been published since 21 October 2026, at 6.3.7 -hmailserver-clion PyPI and theHmailServermodule on the PowerShell Gallery - and how each is installed, ashmailserver/docs/CommandLinePackages.mdsays with batch52, where it said to take them from the repository on Windows. Re-copied on 26 October 2026 from the document asmasterhas it from batch55, 36bd59faa, onmastersince 26 October 2026 (CommandLine.md at 36bd59faa), which changes it in one place: under Credentials, a new passage, A server's self-signed certificate, trusted on the machine you administer from, says how a REST listener another machine connects to is given the self-signed pair a new installation makes,RestApiCertificateFileandRestApiPrivateKeyFile(HM_REST_CERTIFICATE_FILEandHM_REST_PRIVATE_KEY_FILEin the container image,rest.tlsSecretNamein the Helm chart), how a container applies the new installation's defaults at its first start with/etc/hmailserverin a named volume, and how the certificate is trusted on the administering machine, on Debian and Ubuntu and on Windows, and reached by the name it carries; and that both clients were installed from PyPI and the PowerShell Gallery and run this way on 23 October 2026. The document was the same at batch54, 9336fdd59, as at 0c0b6bae7, from which this copy was last made. Its links point at the documents as 36bd59faa has them, or at this wiki's copy where there is one.
Two clients, one vocabulary, one API: hmctl for any shell, and the
HmailServer PowerShell module for PowerShell. Both speak the REST API, so both
work against a Windows server, a Linux server, or a server on another machine,
and neither needs COM or the Control Panel.
This is not a replacement for either administration program. The Windows Control Panel is the place to configure a server, and the browser Control Deck is that place on Linux. A command line is for the things a command line is better at: making fifty accounts from a spreadsheet, reading one setting in a script, watching the delivery queue, asking what a rule criterion would decide, or doing the same thing to twenty servers.
hmailserver/source/Tools/Cli/hmctl |
One Python 3 file, no dependencies beyond the standard library. The Linux packages install it as /usr/bin/hmctl. |
hmailserver/source/Tools/Cli/HmailServer.psm1 |
The PowerShell module: Get-HmDomain, New-HmAccount, Set-HmSetting and the rest, answering objects rather than text. |
build/check-cli.py |
Runs both against a recorded server and asserts what each one sent, so the two cannot drift apart in what they ask for. In CI: hmctl's half in the checkers job of every pipeline, the module's in cli-powershell when the clients change. |
hmailserver/source/Tools/Cli/HmailServer.psd1 |
The module's manifest: its version (the server's) and the functions it exports. |
hmailserver/source/Tools/Cli/python/ |
hmctl as the PyPI package hmailserver-cli, built from the same file (CommandLinePackages.md). |
build/hmconfig.ps1 is a different tool and stays: it exports the whole
configuration as one reviewable JSON document and applies it to another machine,
over COM, on Windows. These two are per-object verbs over HTTP.
Either the administrator password, sent as HTTP Basic with the user name
Administrator, or an API key, sent as a bearer token. An API key is the better
answer for anything unattended: it can be read-only, it can be restricted to one
domain, and it can be revoked without changing the administrator password.
export HMAIL_URL=https://mail.example.com:8045
export HMAIL_API_KEY="$(cat /etc/hmailserver/api.key)"
hmctl domain listConnect-HmServer -Url https://mail.example.com:8045 -ApiKey (Get-Content .\api.key)
Get-HmDomainhmctl reads HMAIL_URL, HMAIL_API_KEY, HMAIL_PASSWORD and
HMAIL_PASSWORD_FILE, and takes --url, --api-key, --password and
--password-file wherever you type them - before the verb or after it. With no
credential at all it asks for one, but only when there is a console to answer:
in a scheduled task or a pipeline it says what is missing and stops, rather than
waiting at a prompt nobody can see.
--insecure is refused for anything but the loopback. A self-signed
certificate on localhost is the ordinary case and nothing is on the wire; an
administrator password sent to a remote host over a connection nobody verified
is not a convenience, and this will not make it one. Give the server a
certificate the client trusts.
A server's self-signed certificate, trusted on the machine you administer
from. A REST listener another machine connects to needs a certificate and its
key in RestApiCertificateFile and RestApiPrivateKeyFile
(HM_REST_CERTIFICATE_FILE and HM_REST_PRIVATE_KEY_FILE in the container
image, rest.tlsSecretName in the Helm chart): bound to any address but the
loopback, the server does not start it without one, and with the two empty it
takes the ACME client's certificate when there is one. A new installation sets
neither - the certificate it makes (Secure Defaults, "The
certificate") is its mail listeners' - but until an ACME certificate or one of
your own is there, that pair can be named in the two settings, as the container
image's start test names it (ContainerImage.md, "The start
test"): self-signed-certificate.pem and self-signed-key.pem in the data
directory's Certificates folder, /var/lib/hmailserver/Certificates from the
Linux packages and in the container image. A container is not given the pair by
itself, and the verb that gives it must run with the server's own
/etc/hmailserver: the entrypoint's --create-database writes the
installation's stored-secrets key there (Secrets at Rest,
"Who makes it"), and a container that keeps it in an anonymous volume of its own -
a one-off docker run --rm given the data volume alone - takes the key away with
it, after which the server seals nothing and says so at every start (HM7495).
Keep /etc/hmailserver in a named volume, as hm-etc is in the image's
docker-compose.yml, and give the server's container the command
bash -c 'hmailserver --config /etc/hmailserver/hMailServer.ini --apply-new-install-defaults --if-marked; exec hmailserver --config /etc/hmailserver/hMailServer.ini --foreground'which applies the defaults at its first start, when --create-database has just
left its .new-installation file, and does nothing at every later one; the ;
starts the server whatever the verb answered, as the packaged unit's
ExecStartPre=- does. Or run hmailserver --apply-new-install-defaults once by
hand (Secure Defaults, "By hand") before the server's
first start, in a one-off container with all the server has - docker run --rm --network <its network> --env-file <its environment> -v <its data volume>:/var/lib/hmailserver -v <its configuration volume>:/etc/hmailserver --hostname mail.example.com <the image> hmailserver --apply-new-install-defaults, which exits 0 - and then start the server on the
image's own command with the same volumes: without the network the one-off
cannot reach a database container by its name, and without the host name it
makes the certificate for its own. The start test runs the verb before the
server, joined by &&, only because it starts each container once: the verb
decides a database once and exits 5 at every later start, so a container started
that way would not start again after a restart. The two settings are set as
RestApiPort is set ("What neither client does", below) and taken when the
listener next starts; empty them again once the ACME client has a certificate,
which the listener takes only while they are empty. The certificate names the
server's host name setting, or else the machine's DNS name, and neither client
takes it from another machine until that machine trusts it: hmctl says "TLS
refused the server's certificate: ... self-signed certificate", and the module
gives the system's reason - under Windows PowerShell 5.1 from the release after
6.3.7, since 6.3.7's module says "GET /api/v1/domains failed." there and nothing
more. Copy the certificate, never its key, to the machine you administer from
(docker cp out of a container) and trust it there:
# Debian and Ubuntu: every program that reads the system's store, hmctl and pwsh among them
sudo cp self-signed-certificate.pem /usr/local/share/ca-certificates/mail.example.com.crt
sudo update-ca-certificates
# or for the programs started from this shell only (hmctl and pwsh)
export SSL_CERT_FILE="$PWD/self-signed-certificate.pem"# Windows, from an elevated prompt: hmctl, Windows PowerShell and PowerShell 7
Import-Certificate -FilePath .\self-signed-certificate.pem -CertStoreLocation Cert:\LocalMachine\RootThen reach the server by the name the certificate carries, with a line in the
hosts file where the name is not in DNS: the certificate does not name the
server's address, and hmctl refuses it there - "IP address mismatch" once it is
trusted, "self-signed certificate" until then.
A certificate from a public authority, which the ACME
client gets (Secure Defaults), needs none of this. Both
clients were installed from PyPI and the PowerShell Gallery and run this way on
23 October 2026 (CommandLinePackages.md, "Installed from
PyPI and the Gallery").
hmctl [--url URL] [--api-key KEY | --password PASSWORD | --password-file FILE]
[--insecure] [--json] <resource> <verb> [arguments]
| Resource | Verbs | Notes |
|---|---|---|
domain |
list, get, create, update, delete | |
account |
list, get, create, update, delete | Listed and created under --domain, read and written at the address: which is how the API is shaped. |
alias |
list, create, delete | Under --domain. |
list |
list, create, update, delete | Distribution lists, under --domain. |
group |
list, get, create, update, delete | Account groups; member handles the accounts in one. |
rule, route, port, iprange, certificate
|
list, create, update, delete | Server-wide. |
fetchaccount |
list, get, create, update, delete | Under --address. |
apikey |
list, create, delete | Administrator password only, never an API key. |
suppression |
list, create, delete | A domain's suppression list, under --domain; list takes --search, and an entry is deleted by its address. |
bounce |
list | The bounce counts behind a domain's suppression list, under --domain, with --search. |
bounces |
classify, expire | How the server would read a status or a reply, and the list's hourly sweep run now. |
complaint |
list | The abuse reports a domain's mail has drawn, under --domain, with --search over the complained-of address, the sender, the subject and the provider. |
complaints |
rates, expire | Every domain's complaint rate over the last seven days, and the feedback loops' hourly sweep run now. |
dmarc |
summary, reports, report, tls, expire | The reports a domain receives, read (DmarcReports.md): who sends as the domain and with what result over --days, the aggregate reports a page at a time with what carried each (message_from, message_dmarc), one report's records by id, the TLS-RPT reports, and the retention sweep run now (--older-than-days 0 removes every report; it says when a hundred passes of 5,000 left rows for another run). |
openpgp |
list, show, store, remove, publish | The OpenPGP PUBLIC keys this server publishes (OpenPGP.md): a domain's whole listing, one account's document, a key stored from a file or from standard input, one removed by fingerprint, and an account's own publishing switched on or off. |
queue |
list, retry, delete | |
spamtrap |
list, create, delete | A domain's spam traps: addresses of it that were never real. Made under the domain, taken back at the address itself. |
traphits |
list, clear | What has addressed a spam trap - the trap, the connecting address, the sender and what was done - and the clearing of that record. |
journalrule |
list, create, update, delete | Journaling and dual delivery (Journaling.md): a copy of a domain's mail, or of one account's, sent to an address - as the Exchange envelope report with the original attached, or as the message itself. Server-wide; nothing is copied until a rule exists. |
reputation |
status, check | This server's own outbound addresses against the DNS block lists (ReputationWatch.md). Exits 1 when an address is listed, and when a list could not be asked - an unanswered check is not a clean one - so a monitoring job reads the exit code rather than the table. A pair no check has covered yet also exits 1 while the watch is on; with the watch off it does not, because then nothing is being checked and the output says so. |
webhook |
list, get, create, update, delete | The outbound webhook subscriptions: where this server's mail events go, signed with what, and confined to which domains and addresses. The secret is write-only and never read back (Webhooks.md). |
webhooks |
deliveries, run, test | The delivery queue with --webhook, --state and --limit; one delivery pass now; and one event raised through the path a real one takes, to see whether it arrives. |
quarantine |
list, release, delete | |
settings |
get, set, breach-test | A group whole, or one setting: settings, antispam, antivirus, logging, backup, cache, indexing, scripting, bounces, complaints, directories, openpgp. breach-test [--endpoint URL] asks the password breach check's endpoint for a range and exits 1 when it could not be used (PasswordBreachCheck.md). |
ini |
list, get, set, delete | The server settings that were the [Settings] section of hMailServer.ini - stored in the database since schema 6042, under the names they have always had. list shows a secret as (set); get reads it. |
sample-domain |
status, create, remove | A throwaway example.test with three mailboxes, a distribution list, a rule and eight delivered messages, so that every page has something in it while somebody is learning - and its removal, which says how many mailboxes, lists, rules and messages went. Never creates over, and never removes, an example.test this server did not make. Exit 2 for a refusal, which is an answer rather than a failure. The one password is minted by the server and printed once by create; status cannot answer it, because nothing stores it. Remove the sample before the server takes real mail (SampleDomain.md). |
log |
list, tail | |
backup |
status, start, restore, passphrase | The server's own backup, and putting one back. restore FILE --domains --messages replaces what it restores and is refused before anything is touched when the archive cannot be opened; passphrase set and passphrase clear are the passphrase its archives are encrypted with. A passphrase is read from --passphrase-file FILE (or - for standard input) or asked for with --ask, never taken as an argument - the process list is public. See Backup.md. |
dkim |
status, rotate, check, switch, retire, cancel, run | A domain's DKIM key rotation and the records it needs published. The schedule is five keys of the domain: domain update. |
mailbox |
status, move | Renaming a mailbox, or moving it to another domain on this server. status says whether anything is holding it and what it has been through; move takes the address it is to have. MailboxMove.md. |
app-password |
list, create, delete | Under an account's address. |
device |
list, revoke | The clients that have signed in to an account - protocol, address, program, which credential, first and last seen - under its address. revoke ID ends what one holds now (its browser session, its live IMAP, POP3 and SMTP connections); --credential also turns off the app password it signed in with, --forget removes the entry (DevicesAndSessions.md). |
match |
(a criterion and a value) | What a rule criterion would decide, without a rule. |
accounts |
import, export | A CSV file, with a dry run. |
report |
(a section) | What happened on this server, per domain and per day. sections first: it says what can be reported and what cannot. |
api |
(method and path) | The escape hatch, for a route newer than this file. |
A few whole commands:
hmctl domain create --set name=example.com --set active=true
hmctl account create --domain example.com --set address=ann@example.com --set password=...
hmctl account list --domain example.com
hmctl settings get antispam use_spf
hmctl settings set antispam --set use_spf=true --set spam_mark_threshold=5
hmctl settings set --set breach_check_enabled=true
hmctl settings breach-test --endpoint https://mirror.example/range/
hmctl settings set --set session_idle_seconds=900 --set session_bind_to_address=true
hmctl domain update example.com --set session_lifetime_seconds=28800 --set session_one_per_device=1
hmctl device ann@example.com list
hmctl device ann@example.com revoke 0123456789abcdef0123456789abcdef --credential
hmctl queue list --json | jq '.[] | select(.tries > 3)'
hmctl match contains viagra "cheap viagra here"
hmctl dkim status example.com
hmctl report sections
hmctl report traffic --from 2026-09-01 --to 2026-09-15 --domain example.com
hmctl report mailboxes --top 25 --csv mailboxes.csv
hmctl api POST /api/v1/server/reinitialize--set name=value reads true, false, null, numbers and anything starting
with { or [ as JSON and everything else as a string; --set-string keeps a
value that looks like one of those a string. --from-json FILE takes a whole
object, and - reads it from standard input.
Output is a table by default and the server's own JSON with --json. The exit
code is 0 for success, 1 for an error, 2 for a usage mistake, 3 when the server
refused the credential and 4 when what you named does not exist - so a script
can tell "no such account" from "wrong password" without reading the text.
hmctl report sections
hmctl report summary --from 2026-09-01 --to 2026-09-15
hmctl report senders --domain example.com --top 25
hmctl report traffic --csv traffic.csvThe sections are traffic, failures, senders, recipients, mailboxes,
volume, storage and summary. The window is --from and --to as
YYYY-MM-DD, both inclusive, defaulting to the last thirty days ending today on
the server; --domain narrows it to one domain the server hosts; --top is how
many rows the sender, recipient and mailbox sections return.
--csv FILE writes the table, and --csv - writes it to standard output. It is
the server's CSV, fetched with format=csv, rather than one built here out of
the JSON: two writers of the same table drift, and the file that ends up in a
spreadsheet should be the one the route promises.
Without --csv, the section's own note is printed above the table. Read it. The
four sections counted from the message trace are empty when the trace is switched
off - which is the default - and "the message trace is switched off" is not the
same statement as "nothing happened". hmctl report sections says which sources
are recording, how long each is kept, and the two questions this server cannot
answer at all: spam and virus counts per domain, and storage growth per domain.
Reports.md is why.
hmctl domain update example.com --set dkim_rotation_enabled=true # the schedule, with its defaults
hmctl dkim rotate example.com # or start one now; --selector names it
hmctl dkim status example.com # what to publish, keep and remove
hmctl dkim status example.com | grep '^ publish' # just the records to add
hmctl dkim check example.com # look them up from the server's resolver now
hmctl dkim switch example.com --force # before the propagation period is over
hmctl dkim retire example.com # before the retention period is over
hmctl dkim cancel example.com # drop the next key; signing is unchanged
hmctl settings set --set reputation_watch_addresses=203.0.113.9 # what this server is seen as
hmctl reputation status # what the last check found; exit 1 if listed
hmctl reputation check # check now rather than at the daily checkSpam traps, and what has walked into them:
hmctl spamtrap list --domain example.com
hmctl spamtrap create --domain example.com --set address=never-used@example.com
hmctl spamtrap delete never-used@example.com # taken back at its own address
hmctl traphits list --limit 20 # every domain's, newest first
hmctl traphits list --domain example.com # one domain's
hmctl traphits clear # the record only: bans and blocks stayhmctl sample-domain status # is it there, and what is in it
hmctl sample-domain create # example.test, three mailboxes, a list, a rule, eight messages
hmctl sample-domain remove # and away again, with a count of what wentJournaling and dual delivery:
hmctl journalrule list
hmctl journalrule create --set target=example.com --set journal_address=archive@compliance.example
hmctl journalrule create --set scope=account --set target=ann@example.com \
--set journal_address=copy@second-system.example --set direction=outbound --set mode=copy
hmctl journalrule update 3 --set enabled=false # stops copying; what was sent stays sent
hmctl journalrule delete 3status prints the phase, then one block per key - current, next, retiring and
retired - with its times and its last check, and under each the records as
ACTION HOST TXT VALUE lines: publish for the next key, keep for the current
and retiring ones, remove for a retired selector. Then what is waiting, and until
when, in the server's words. --json is the status document itself.
The server does the work on its own schedule; these verbs only make it happen now.
switch is refused - exit 1, with the status and the reason word - until the next
key's record resolves with that key in a check made for the request, and without
--force until it has resolved for the whole propagation period. The other
refusals are the same shape: a second rotate while a next key is staged, a
retire with nothing retiring. run makes one pass over every domain, and
--seconds-ahead moves the clock for that pass, which is how a test waits out three
days; it needs the administrator password or a server-wide key. It is a test's
tool, not for a production server: a switch or retire the pass makes happens at
once, early, and the times it records are the moved clock's and stay recorded, so
the server's own passes afterwards wait that much longer.
DkimKeyRotation.md is the whole walk.
In the PowerShell module the same verbs are Get-HmDkimRotation (with
-Records, one object per record), Start-HmDkimRotation, Test-HmDkimRotation,
Complete-HmDkimRotation, Remove-HmDkimRetiringKey, Stop-HmDkimRotation and
Invoke-HmDkimRotation. The reputation watch is Get-HmReputation (with
-Results, one object per address and list, where Listed is $true, $false
or $null - and the third is not the second) and Invoke-HmReputationCheck.
hmctl settings set bounces --set processing_enabled=true --set suppression_action=observe
hmctl suppression list --domain example.com --search elsewhere.test
hmctl suppression create --domain example.com --set address=gone@elsewhere.test --set-string note="old export" --set expires_days=90
hmctl suppression delete gone@elsewhere.test --domain example.com
hmctl bounce list --domain example.com
hmctl bounces classify "550 5.1.1 <gone@elsewhere.test>: User unknown"
hmctl bounces expirebounces classify asks the server rather than a copy of its table, because the
rule that matters is the server's: a code a remote did not send itself - a bare
550 - is never read as a dead mailbox, and the answer says so when it is not
trusted. Deleting an entry sends the next message to the address and starts its
run of hard bounces again. BounceSuppression.md is the
whole of it.
hmctl settings set openpgp --set web_key_directory_enabled=true
gpg --armor --export ann@example.com | hmctl openpgp store ann@example.com - --publish
hmctl openpgp list example.com
hmctl openpgp show ann@example.com
hmctl openpgp publish ann@example.com off
hmctl openpgp remove ann@example.com 866ac3a0785f51afd5eada4635efb5d13db90a11Set-HmOpenPgpKey -Account ann@example.com -Path .\ann.asc -Publish
Get-HmOpenPgpKey -Domain example.com | Format-Table address, fingerprint, algorithm_name, published
Set-HmOpenPgpPublish -Account ann@example.com -Enabled $falsestore reads the key from a file, or from standard input with -, because a key
is thousands of characters and a command line is not where it belongs. Only the
PUBLIC half is ever accepted: a secret key is refused with a sentence saying so,
here and at every other door. A domain listing that is not being served says
which of the three switches is in the way before it prints the table.
OpenPGP.md is the whole of it.
hmctl webhook create --set name=ticketing --set url=https://tickets.example/hook \
--set-string secret="$SECRET" --set events=message.bounced --set domains=example.com
hmctl webhook list
hmctl webhook update ticketing --set enabled=false
hmctl webhooks test --event message.bounced --domain example.com
hmctl webhooks run
hmctl webhooks deliveries --state dead-letteredwebhooks test raises a real event through exactly the path a real one takes -
the same matching, the same per-domain switches, the same rate ceiling - and
says how many subscriptions took it rather than pretending one did, so a 0 is
an answer rather than a failure. webhooks run makes the delivery pass happen
now rather than within the minute, which is what an administrator wants
immediately after configuring an endpoint. A subscription's shared secret is
never returned by any read, so it is not in webhook list and not in a
configuration export; keep it wherever you keep the rest of your secrets.
Webhooks.md is the whole of it.
hmctl settings set complaints --set processing_enabled=true --set-string mailbox=fbl@example.com
hmctl complaint list --domain example.com
hmctl complaint list --domain example.com --search anna@example.com
hmctl complaints rates
hmctl complaints expireGet-HmComplaint -Domain example.com | Format-Table address, feedback_type, sender, arrival
Get-HmComplaintRate | Format-Table domain, sent, complaints, rate_ppm, judged
Invoke-HmComplaintMaintenancecomplaints rates asks the server rather than working the rate out from the
listing, because the window, the volume below which a rate is not judged and the
denominator are the server's, and a second implementation of them would drift
from the one the complaints.rate.high alert fires on. A domain that has sent too
little is said to be so rather than given a rate.
Complaints.md is the whole of it.
hmctl domain update example.com --set-string report_mailbox=dmarc@example.com
hmctl dmarc summary example.com --days 30
hmctl dmarc reports example.com
hmctl dmarc report example.com 31
hmctl dmarc tls example.com --days 90
hmctl dmarc expire
hmctl dmarc expire --older-than-days 0Set-HmDomain -Name example.com -Property @{ report_mailbox = 'dmarc@example.com' }
Get-HmDmarcSummary -Domain example.com | Select-Object -ExpandProperty sources | Format-Table ip, header_from, messages, passed, failed
Get-HmDmarcReport -Domain example.com | Format-Table id, org, begin, end, messages, passed
(Get-HmDmarcReport -Domain example.com -Id 31).records | Format-Table source_ip, count, disposition, dkim, spf
Get-HmTlsReport -Domain example.com | Format-Table org, policy_type, successful, failed
Invoke-HmDomainReportMaintenancedmarc summary prints the server's sums and never its own: a DMARC pass is an
aligned DKIM or an aligned SPF, judged once in DomainReports::SummaryJson, and
the sources are the addresses that sent as the domain, most messages first. With
no report mailbox named on the domain the command says so, because an empty
summary and one nobody is filling look the same otherwise.
DmarcReports.md is the whole of it.
hmctl mailbox status ann@example.com
hmctl mailbox move ann@example.com anne@example.com
hmctl mailbox move ann@example.com ann@example.net
hmctl mailbox move ann@example.com anne@example.com --drop-aliasThe new address carries the domain, so a rename and a move to another domain on this server are the same command. The account keeps its identity: its messages, folders, rules, filters, application passwords, second factor, contacts, calendars, preferences, password history, sender lists, group memberships and role all follow it, which is what a second account and a copy could never do.
The old address is left behind as an alias unless --drop-alias says otherwise,
because a correspondent's address book, a list somebody signed up to and a
printer configured three years ago all still hold it. status first: a mailbox a
POP3 or IMAP session is holding cannot be moved, and it says which.
In PowerShell, Get-HmMailboxMove and Move-HmMailbox. Move-HmMailbox asks
before it moves anything and -WhatIf sends nothing at all.
MailboxMove.md says what moves, what deliberately does not, and what an interrupted move leaves behind.
hmctl accounts import new-people.csv --dry-run
hmctl accounts import new-people.csv
hmctl accounts export example.com accounts.csvImport-HmAccount -Path .\new-people.csv -WhatIf
Import-HmAccount -Path .\new-people.csv
Export-HmAccount -Domain example.com -Path .\accounts.csvThe file is UTF-8 - in Excel, CSV UTF-8 - and its header row names the columns. These are the columns, and there are no others:
| Column | Required | Sent as | Notes |
|---|---|---|---|
address |
yes | text | Created under the address's own domain. |
password |
yes | text | Digits alone are still a password: 20260915 is sent as the text it is. Never trimmed: white space at either end is refused. Judged by the server's password policy and, when it is switched on, the breach check (PasswordBreachCheck.md), then stored under its configured hash. |
first_name |
text | At most 60 characters, counted as UTF-16 code units, so a character outside the Basic Multilingual Plane (an emoji) counts two. | |
last_name |
text | As first_name. |
|
active |
true or false |
true, yes, on or 1; false, no, off or 0. Default true. |
|
max_size_mb |
a whole number | Megabytes; 0 is no limit, and is the default. |
|
admin_level |
user, domain or server
|
Default user. |
address,password,first_name,last_name,active,max_size_mb,admin_level
ann@example.com,Sup3rSecret!,Ann,Adams,true,500,user
bob@example.com,An0therOne!,Bob,"Brown, Jr",true,,domainA person's name is two columns, not one: a name split at a space is a guess
("Mary Ann Smith"), and the file has to come back out exactly as it went in. The
columns may come in any order, their names in any case, and every cell but the
password is trimmed. An empty cell is not sent, so the server's default applies.
A value with a comma in it goes in quotes, as "Brown, Jr" does; a row with more
cells than the header has columns is refused rather than shifted along.
Each row becomes one POST /api/v1/domains/<domain>/accounts, and that route
refuses a field it does not know by name rather than ignoring it - so a column
that is not in the table is refused before anything is sent, a name column
included. The clients also refuse, before anything is sent and in the words the
server would use, a row with no password, an active that is not one of the
eight words, a max_size_mb that is not a whole number, an admin_level that is
not one of the three and a name past its 60 characters - which is why --dry-run
and -WhatIf refuse the rows the create would refuse for their cells. They
refuse one row the server would take: a password with a space or a tab at its
start or end, which a spreadsheet cell rarely means and which trimming would
silently turn into a different password. What only the server can judge - the
password policy, the domain's account limit, an address it does not accept - is
refused per row when the import runs, and listed with its address and the
server's reason (hmctl adds the line number); a dry run cannot foresee those.
An address that already exists is left alone and counted, never overwritten:
an import that silently reset a password would lock a hundred people out with
one command. --dry-run makes no request that changes anything and prints what
would happen, line by line; the exit code is 1 when any row was refused.
admin_level is decided by who is asking, as it is everywhere else: the
administrator password, or a key issued for every domain, may make a server
administrator; a key restricted to named domains may make users and domain
administrators, and a row asking it for server is refused with a 403.
The export writes the same columns less password, from the domain's account
listing, because the server does not have the passwords to give. Add a password
column and change the addresses, and the exported file imports into another
domain or another server as the same accounts: names, sizes, levels and whether
each is active. build/check-cli.py holds both clients to exactly that round
trip, and RegressionTests/API/RestApiAccountImport.cs holds the server to it.
hmctl config changed # every setting that is not the shipped default
hmctl config export server.json # the whole configuration, as a document
hmctl config diff server.json # what would change; exit 1 if anything would
hmctl config apply server.json # the plan, and nothing else
hmctl config apply server.json --force # the plan, appliedThe document holds the settings groups, the hMailServer.ini keys the server
exposes, the domains and their accounts, aliases and distribution lists, the
groups, the rules, the routes, the listeners and the IP ranges. It is sorted
throughout and carries nothing the server allocated - no identifier, no count,
no time - so two exports of an unchanged server are the same bytes. That is
what makes it usable in a repository: a diff means a change, and nothing churns.
apply is a plan unless you add --force, and a deletion needs --allow-delete
on top of that. Without it, an entry the server has and the document does not
is left alone and counted, because the common case is a document written from
one server and applied to another that has things of its own.
What it does not carry, and why:
-
Passwords. The server does not give them out, so they are not in the
document. An account in the document that does not exist on the server is
refused with that reason rather than created with a password nobody chose;
add a
passwordfield for the accounts you mean to create. -
The secrets among the ini keys.
GET /api/v1/settings/inilists a key whose name says its value is a secret -OAuth2HmacSecret,BATVSecret,ServiceAccountPassword,MetricsServerAuthToken,PasswordPepperand the like - as(set)or empty, never as its value, and both clients leave such a key out of the document rather than carry a placeholder thatapplywould write over the real secret.hmctl ini get <name>reads one by name when it is wanted. A key file is a path, not a secret, and stays in. - Certificates and the directories. A certificate is files on the server's own disk, and the directories are that machine's paths; copying either between machines would describe a server that does not exist.
-
The journaling rules. A journal rule copies somebody's mail to an address
(Journaling.md), and an address chosen for one server is
almost never the right one for another. A document exported here and applied
to a second server would quietly start sending that server's mail somewhere the
person applying it had not thought about.
hmctl journalruleedits them, one server at a time and on purpose. - What lives under an account - application passwords, fetch accounts, folder permissions - which are the account holder's rather than the configuration's.
-
The selector and key files of a domain whose DKIM key rotation is on. The
rotation moves them on its own schedule, so an export taken before a switch
would differ from the server after it, and applying it would ask for the old
key back - which the server refuses. They are left out of the document, out of
the comparison and out of what
applysends while the rotation is on, in the document or on the server; the schedule itself (dkim_rotation_enabledand the four periods) is configuration and travels. A rotation started by hand holds them for a while with the schedule off - while its next key is staged or its old selector is still being kept - and the server refuses a change to them then too. The document keeps them for such a domain, because they are its configuration once that rotation is over, but whendifforapplyfinds them different it asks the server's rotation route, and while the server says it holds them they are left out of the comparison and of what is sent. -
The live update's trust -
UpdateSigningIdentity,UpdateSigningIssuer,UpdateSourceRepository,UpdateTrustRootsFileandUpdateLogPublicKeyFile. From 6.3.6 a server reads those five from its ownhMailServer.inialone and refuses a change to one over the REST API: they say what that machine's updates are checked against, which is the machine's and not the configuration's. They are left out of the document as the keys the server's environment sets are, byhmctl config exportandExport-HmConfigurationalike. A document an earlier version of either exported can still carry them:diffandapply(Compare-HmConfigurationandImport-HmConfiguration) leave them out of the plan and say so in one line naming them,Skipped UpdateSourceRepository: the live update's trust ...- on the standard error under--json, and as a warning from the module - so they neither count as a difference nor fail the apply. Set them in each server's file (The Update Feed, "Where the trust is read from"). -
Wiki addition. Remote domain policies and alert rules, which 6.3.4 added, are
not sections of the document: they have routes of their own (APIs Reference §4) that
configdoes not read. A domain's external-sender tag, first-contact note and disclaimer settings are carried, because the domain listing thedomainssection is read from includes them.
$ hmctl config changed
STORE NAME VALUE DEFAULT
ini LogLevel 5 9
ini SMTPDMaxTimeout 600 1800
ini OAuth2HmacSecret (set)
ini DnsServer 10.0.0.1 (forced from [SettingsOverride])
database smtpnoofretries 6 4
5 of 391 settings are not what this version ships with.
config changed is the other half of config export: the export is the whole
configuration, and this is only the part of it that is not what the server ships
with - which is the first thing to look at when a server behaves oddly and the
first thing to put in a support message. The shipped values are the server's own,
read from the source it was built from, so this file holds no copy of them to go
stale; hmailserver/docs/ChangedFromStock.md says what default means for a key
the installer writes, and why a secret is named here and never shown.
A difference is not an exit code here, which is the one place this parts
company with config diff. Every real server has changed something, so a
non-zero exit would be the normal answer and every script would learn to ignore
it. --json prints the server's own document, counts and all.
The route answers to the administrator password alone, as ini list does: an API
key is refused and so is an account holding a role, because the settings include
the server's secrets and the commands it runs. A value the file's
[SettingsOverride] section is forcing over what is stored says so beside the
value - nothing else the server offers shows one.
There is no daemon; the loop is two commands and whatever runs them.
# On a pull request: does the repository still match the server?
- run: hmctl config diff server.json # exit 1 fails the job
# On merge: make the server match the repository.
- run: hmctl config apply server.json --forceGive the job an API key rather than the administrator password, keep the key in the runner's secret store, and let the diff run on a schedule as well: a server that has drifted from its repository is worth knowing about before somebody needs the repository to be true.
Import-Module .\hmailserver\source\Tools\Cli\HmailServer.psm1
Connect-HmServer -Url https://localhost:8045 -Insecure
Get-HmDomain | Where-Object active -eq $false
Get-HmAccount -Domain example.com | Sort-Object max_size_mb -Descending | Select-Object -First 10
New-HmAccount -Domain example.com -Address ann@example.com -Password (Read-Host -AsSecureString)
Set-HmAccount -Address ann@example.com -Property @{ max_size_mb = 500 }
Set-HmSetting -Group antispam -Property @{ use_spf = $true }
Test-HmBreachCheckEndpoint -Url https://mirror.example/range/
Get-HmQueue | Where-Object tries -gt 3 | Start-HmQueueDelivery
Get-HmDkimRotation -Domain example.com -Records | Where-Object Action -eq publish
Get-HmChangedSetting | Format-Table store, name, value, default
Get-HmSuppression -Domain example.com | Where-Object category -eq 'hard' | Format-Table address, status, created
Add-HmSuppression -Domain example.com -Address gone@elsewhere.test -Note 'old export' -ExpiresDays 90
Get-HmBounceCount -Domain example.com | Sort-Object consecutive_hard -Descending | Select-Object -First 10
Test-HmBounceStatus -Status '550 5.2.2 Mailbox full'
Get-HmWebhook | Format-Table name, url, events, enabled, pending, dead_lettered
New-HmWebhook -Name ticketing -Url https://tickets.example/hook -Secret $s -Events message.bounced
Get-HmWebhookDelivery -State dead-lettered | Format-Table id, webhook, event, attempts, error
Test-HmWebhook -Event message.bounced -Domain example.com
Get-HmOpenPgpKey -Domain example.com | Where-Object published -eq $false
Import-HmAccount -Path .\new-people.csv -WhatIfEvery function answers objects, so Where-Object, Sort-Object, Export-Csv
and the rest work on them; nothing in the module formats anything. Everything
that changes the server supports -WhatIf and -Confirm, and deleting a domain
or an account asks before it acts unless told not to. A domain name or an
address completes from the server itself once Connect-HmServer has run.
Invoke-HmApi -Method GET -Path /api/v1/... is the escape hatch, and is what
every other function is built on.
- It does not read or write the message store. Mail is IMAP's business.
-
It is not a backup tool of its own.
hmctl backup startasks the server to run its own backup,hmctl backup restoreasks it to put one back, andhmctl backup passphrasesets the passphrase its archives are encrypted with. The archive is written, read and restored by the server; nothing here touches it. -
It sets no administrator password.
hmailserver --set-admin-passwordon the server does that - on WindowshMailServer.exe --set-admin-password, with the password inHM_ADMIN_PASSWORDor on standard input and never as an argument (Secrets at Rest) - and the Control Panel does it on Windows. -
It does not make or carry the stored-secrets key. The key the second factors
and the
[Settings]secrets are sealed under in the database is made with--create-stored-secrets-key, exported with--export-stored-secrets-key FILE- a copy kept with the database backups, and for the other nodes that share it, which take it with--import-stored-secrets-key FILE- and, lost for good, what it sealed is cleared with--forget-sealed-secrets KEYID: all run on the server itself (hMailServer.exe, elevated, orsudo hmailserver), never over the API (Secrets at Rest). -
It cannot turn the REST listener on, since both clients need it. On a
Linux server
sudo hmailserver --set-setting RestApiPort=8080stores the setting - the database is the settings store since schema 6042, so editinghMailServer.inidoes not - andsudo systemctl restart hmailserverstarts the listener. The same verb stores any other key the server's settings loader reads (build/ini-settings-baseline.txt), for the ones needed before the API can be reached; the webmail's branding and file-link keys, read by name where they are used, are stored over the API. A key whose value is a secret is refused with its value on the command line, where the shell's history, the process list and sudo's log would keep it: give the name alone,sudo hmailserver --set-setting SRSSecret, and the value on standard input, typed at a prompt that does not echo it or piped in. A key the server reads as a whole number must be one, a port a number from 0 to 65535 andRestApiBindAddressan IP address orlocalhost- the checks--apply-environmentmakes, in its words, which never repeat the value. On Windows the Control Panel does this over COM. -
It does not set what the environment sets. In a container every
[Settings]key and everyhm_settingsrow this build reads can come from the environment -HM_SETTING_<key>,HM_PROPERTY_<name>, and_FILEafter either for a Secret - stored byhmailserver --apply-environment, which the image's entrypoint runs whenever there is anything to apply (EnvironmentConfiguration.md). The server refuses a change to such a setting (409) until a start without the variable;hmctl config changedprints(set by the environment)beside it, andhmctl config exportandExport-HmConfigurationleave every such setting out of the document - a group's key and its<key>_environmentmark, and a[Settings]key the ini list'senvironmentmap names - since the value is the deployment's and not the configuration's. -
A profile is applied from either, and from the server's own verb.
hmctl settings profileslists the four setting profiles (Profiles.md) with how many of each one's settings would change here;hmctl settings profile officeis the diff, every setting beside the value the server holds, and prints the diff's token;hmctl settings apply-profile officeapplies it, with--confirm TOKENwhere an entry closes a listener, trusts an address range, turns a filter or a check off or weakens it, or shortens a window of days, which the server refuses without it - the token ties the confirmation to the diff that was read, and the server refuses it when the settings moved since;revert-profileputs back what the last one changed,discard-profilekeeps its settings and forgets the way back, anddecline-profilerecords that the shipped settings are kept. With the server stopped,sudo -u hmailserver hmailserver --apply-profile office(andhMailServer.exe --apply-profile officeon Windows, which refuses while the service runs, exit 4) applies it from the command line,--confirmas above,--onceto do nothing on a server something was recorded for before,--if-newto leave a server of ten accounts or more as it is (exit 5), andnonefor the name to record that no profile is wanted - which is what the container'sHM_PROFILEand the installer run;--revert-profileputs the last one back. It exits 3 when a--confirmis needed and 2 for a name that is no profile. -
Neither invents a field. A field you did not give is one the server
decides; the two clients were made to send the same request for the same
command, and
build/check-cli.pyfails if they stop doing so.
hMailServer 6.3.7, the latest release (17 October 2026, schema 6108) · AGPL-3.0-or-later · Repository · Mirror on GitHub · Report a documentation error (a GitLab issue naming the page)
Home — full index
Start here
1. Install and run
- Before You Install
- Installing hMailServer
- Installing on Linux
- Running in a Container
- Profiles and Environment
- The Control Panel
- The Control Deck
- Your First Domain and Mailbox
- Connecting a Mail Client
- The Webmail
- DNS for Your Domain
- Migration
2. Secure it
- Encryption and Certificates
- Database TLS
- Encryption for People
- Stopping Spam
- Stopping Viruses
- Security Hardening
- Secure Defaults
- The Service Account
- Secrets at Rest
- Sign-in and Identity
- Security Policy
3. Operate it
- Monitoring and Health
- The SNMP Agent
- Backup and Restore
- Message Storage
- Compliance and eDiscovery
- Troubleshooting
- Diagnosing Stalled Mail
- Relocating an Installation
- Upgrading hMailServer
- Upgrading Guide
- The Update Feed
- Migrating the Database Backend
- High Availability Runbook
- Warm Standby
- Runbooks Digest
4. Extend it
- Rules and Sieve
- Aliases Lists and Public Folders
- Rooms and Equipment
- Mailing Lists
- Routes and Relays
- Mail In to a Webhook
- Outbound Shaping
- The COM API and Scripting
- Event Scripts
- The REST API
- Command Line
- APIs Reference
5. Contribute to it
- Project Handbook
- Architecture
- Contributing
- Release Process
- Governance
- Assurance Case
- Regression Test Environment
- Fuzzing
- Regulatory Scope
- Third-Party Binaries
Look it up — from any journey
- Settings Reference
- Settings Overview
- Capabilities and Configuration
- Control Panel Reference
- Control Panel Pages
- Ports Reference
- Changes-Since-6210
- Version-History-to-6210
- Glossary
About this wiki
- hMailServer 6.3.7 is the latest release (17 October 2026, schema 6108)
- Pages say since 6.3.4 where a feature is new in it
- AGPL-3.0-or-later
- Repository, the one place the project is worked on
- Its mirror on GitHub, since 18 October 2026, with no Issues
- Questions: a GitLab issue with the Question template, GitHub Discussions Q&A, or the forum
- Report a documentation error: a GitLab issue naming the page