Skip to content

Command Line

Christopher Holloway edited this page Oct 26, 2026 · 16 revisions

Command line

Since 6.3.4: both clients are in the repository, and the Linux packages install hmctl as /usr/bin/hmctl. The Windows installer carries neither client. Since 21 October 2026 both are published, at 6.3.7: hmailserver-cli on PyPI (pipx install hmailserver-cli) and the HmailServer module on the PowerShell Gallery (Install-PSResource HmailServer on PowerShell 7.4 and later, or Install-Module HmailServer on 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, diff and apply leave out, the module's manifest, and hmctl as the PyPI package hmailserver-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 as master has it from batch50, 0c0b6bae7 (CommandLine.md at 0c0b6bae7), which changes it in two places: the queue examples, one for hmctl and one for the PowerShell module, filter on tries, the field a queue item has, where they filtered on retry_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 as master has it from batch52, 72be22b4e, on master since 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-cli on PyPI and the HmailServer module on the PowerShell Gallery - and how each is installed, as hmailserver/docs/CommandLinePackages.md says with batch52, where it said to take them from the repository on Windows. Re-copied on 26 October 2026 from the document as master has it from batch55, 36bd59faa, on master since 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, RestApiCertificateFile and RestApiPrivateKeyFile (HM_REST_CERTIFICATE_FILE and HM_REST_PRIVATE_KEY_FILE in the container image, rest.tlsSecretName in the Helm chart), how a container applies the new installation's defaults at its first start with /etc/hmailserver in 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.

Administering the server from a command line

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.

Credentials

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 list
Connect-HmServer -Url https://mail.example.com:8045 -ApiKey (Get-Content .\api.key)
Get-HmDomain

hmctl 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\Root

Then 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

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.

Reports

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.csv

The 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.

DKIM keys that rotate

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 check

Spam 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 stay
hmctl 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 went

Journaling 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 3

status 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.

Bounces and the suppression list

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 expire

bounces 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.

OpenPGP keys

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 866ac3a0785f51afd5eada4635efb5d13db90a11
Set-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 $false

store 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.

Outbound webhooks

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-lettered

webhooks 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.

Complaints and the feedback loops

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 expire
Get-HmComplaint -Domain example.com | Format-Table address, feedback_type, sender, arrival
Get-HmComplaintRate | Format-Table domain, sent, complaints, rate_ppm, judged
Invoke-HmComplaintMaintenance

complaints 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.

The reports a domain receives: DMARC and TLS-RPT

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 0
Set-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-HmDomainReportMaintenance

dmarc 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.

Renaming a mailbox, and moving one to another domain

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-alias

The 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.

Accounts from a spreadsheet

hmctl accounts import new-people.csv --dry-run
hmctl accounts import new-people.csv
hmctl accounts export example.com accounts.csv
Import-HmAccount -Path .\new-people.csv -WhatIf
Import-HmAccount -Path .\new-people.csv
Export-HmAccount -Domain example.com -Path .\accounts.csv

The 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,,domain

A 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.

The configuration as one file

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, applied

The 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 password field for the accounts you mean to create.
  • The secrets among the ini keys. GET /api/v1/settings/ini lists a key whose name says its value is a secret - OAuth2HmacSecret, BATVSecret, ServiceAccountPassword, MetricsServerAuthToken, PasswordPepper and 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 that apply would 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 journalrule edits 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 apply sends while the rotation is on, in the document or on the server; the schedule itself (dkim_rotation_enabled and 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 when diff or apply finds 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, UpdateTrustRootsFile and UpdateLogPublicKeyFile. From 6.3.6 a server reads those five from its own hMailServer.ini alone 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, by hmctl config export and Export-HmConfiguration alike. A document an earlier version of either exported can still carry them: diff and apply (Compare-HmConfiguration and Import-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 config does not read. A domain's external-sender tag, first-contact note and disclaimer settings are carried, because the domain listing the domains section is read from includes them.

What this server has changed from stock

$ 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.

A repository that is the configuration

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 --force

Give 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.

The PowerShell module

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 -WhatIf

Every 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.

What neither client does

  • 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 start asks the server to run its own backup, hmctl backup restore asks it to put one back, and hmctl backup passphrase sets 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-password on the server does that - on Windows hMailServer.exe --set-admin-password, with the password in HM_ADMIN_PASSWORD or 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, or sudo 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=8080 stores the setting - the database is the settings store since schema 6042, so editing hMailServer.ini does not - and sudo systemctl restart hmailserver starts 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 and RestApiBindAddress an IP address or localhost - the checks --apply-environment makes, 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 every hm_settings row this build reads can come from the environment - HM_SETTING_<key>, HM_PROPERTY_<name>, and _FILE after either for a Secret - stored by hmailserver --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 changed prints (set by the environment) beside it, and hmctl config export and Export-HmConfiguration leave every such setting out of the document - a group's key and its <key>_environment mark, and a [Settings] key the ini list's environment map 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 profiles lists the four setting profiles (Profiles.md) with how many of each one's settings would change here; hmctl settings profile office is the diff, every setting beside the value the server holds, and prints the diff's token; hmctl settings apply-profile office applies it, with --confirm TOKEN where 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-profile puts back what the last one changed, discard-profile keeps its settings and forgets the way back, and decline-profile records that the shipped settings are kept. With the server stopped, sudo -u hmailserver hmailserver --apply-profile office (and hMailServer.exe --apply-profile office on Windows, which refuses while the service runs, exit 4) applies it from the command line, --confirm as above, --once to do nothing on a server something was recorded for before, --if-new to leave a server of ten accounts or more as it is (exit 5), and none for the name to record that no profile is wanted - which is what the container's HM_PROFILE and the installer run; --revert-profile puts the last one back. It exits 3 when a --confirm is 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.py fails if they stop doing so.

Home — full index

Start here

1. Install and run

2. Secure it

3. Operate it

4. Extend it

5. Contribute to it

Look it up — from any journey


About this wiki

Clone this wiki locally