Skip to content
hMailServer 6.3.8 is the current release, signed 2 November 2026. Windows and Linux, and an in-place upgrade from 5.3 or later, but for the original hMailServer's 5.7.Download 6.3.8 Documentation FAQ

REST API & integrations

5 Topics 5 Posts

The REST API that RestApiPort turns on — tokens and OAuth2, provisioning scripts, and control panels, billing systems and directory syncs that call it.

  • 0 Votes
    1 Posts
    139 Views
    ProgressiverobotP
    Written for 6.3.4. hMailServer 6.3.7 (17 October 2026) changes how the listener judges some requests. A state-changing request that carries a credential, HTTP Basic included, is refused when the browser reports it as cross-site (Sec-Fetch-Site, or an Origin that is not this server's); scripts (the curl examples below among them), hmctl and the PowerShell module send no such header and are unaffected. A credential confined to named domains (a domain administrator, a key issued for named domains, or serveradmin given with a list of domains) is refused a domain's rename, a domain alias, a custom role's creation, change or delete, the bind_to_address, send_using_route and script_function actions on a domain's or an account's rule, a relay host changed to an address that is not public, and GET /api/v1/metrics/history; and a role is given or taken away only by a credential that holds manage_roles in the account's domain and takes in what the account holds now and afterwards, so such a credential cannot take a role away from an account whose admin level is the server administrator's. The OpenAPI document describes each path once, with every method it answers. In the Control Panel, API & monitoring is Monitoring & alerts and REST API keys is under Sign-in & access; Ctrl+K takes every old title to its page. See the 6.3.7 announcement. hMailServer 6.3.8 (2 November 2026) reads a JSON body in time and memory that follow its bytes (work item 110), and answers 400, saying why, before a route reads a member, to a body that is not one JSON object, names a member twice, holds an escaped U+0000 or passes 100,000 values or 32 levels, and to a member whose absence is the wider setting written as another type than its route reads, such as domains, allowed_from, role or expires on POST /api/v1/apikeys as anything but a string or null: correct a client of your own that sends such a body. An account's failed sign-in from loopback, as through a reverse proxy on the same machine, is counted against the name alone, AccountLockoutThreshold (off by default) being the limit, and never towards a ban of the address (work item 107); the listener's own accounting spares all of 127.0.0.0/8. See the 6.3.8 announcement. The REST API is the alternative to COM for automating hMailServer, and the only administration interface that works from a machine that is not running Windows. This guide covers how to turn the listener on, the three credentials it takes and the roles that narrow them, how one-time codes behave since 6.3.4, the OpenAPI document the server emits, and a set of curl examples that use only routes the project's own documents name. The one thing to know first: the listener is off as shipped (RestApiPort=0), and since 6.3.4 RestApiPort is a setting stored in the database. Editing the [Settings] section of hMailServer.ini does not turn the API on once the server has stored the key. Turning the listener on Four settings shape the listener. Setting Shipped value What it is RestApiPort 0 The port. 0 means there is no listener. RestApiBindAddress 127.0.0.1 The address it binds. TLS is required unless this is 127.0.0.1, localhost or ::1. RestApiCertificateFile empty The certificate, in PEM. Left empty, the listener falls back to the ACME certificate. RestApiPrivateKeyFile empty Its private key, in PEM. Since 6.3.4 the database is the settings store (schema 6042) and the [Settings] section of hMailServer.ini is its copy. A key the server has never stored is taken in from the file at the next start, so on a fresh installation an edit made before the first start still works; once the key is stored, the database's value is the one used. Settings: what is not stock, when a save takes effect covers the store itself, why an edited line is put back (HM5804) and [SettingsOverride]. Where the stored value is changed depends on the platform. Platform How RestApiPort is set Windows The Control Panel page Monitoring & troubleshooting → API & monitoring, or Settings.SetIniSetting over COM. The Control Panel and the COM API are Windows-only. Linux hmailserver --set-setting on the server itself, then a restart of the service. Either, once the API answers PUT /api/v1/settings/ini/{name} with the administrator password. sudo hmailserver --set-setting RestApiPort=8443 sudo systemctl restart hmailserver --set-setting checks what it is given: a port must be a number from 0 to 65535 and RestApiBindAddress an IP address or localhost. On Linux this matters most, because there the REST API is the administration interface. The packaged /etc/hmailserver/hMailServer.ini ships RestApiPort=0 and RestApiBindAddress=127.0.0.1 under [Settings], so the first start stores both, and changing those lines afterwards turns nothing on. The one part of the file that still decides a stored key is a [SettingsOverride] section, applied over the store at every start and reported as HM5807 each time; the wiki gives it as the route where neither the Control Panel nor COM is available. The new value waits for a reinitialise. Since 6.3.4 most settings apply when they are saved, but RestApiPort, RestApiBindAddress, RestApiCertificateFile and RestApiPrivateKeyFile are among the 28 keys marked reinitialize, because the listener binds and its TLS context is built when the servers start. They take effect at Apply now in the Control Panel (Windows) or the Control Deck, at Application.Reinitialize over COM (Windows), at POST /api/v1/server/reinitialize, or at a restart of the service. The listener can refuse to start, and each refusal is written once to the application log. What is wrong Log line Fix The administrator password is empty RestApi: Refusing to start - the administrator password is not set. Set an administrator password The bind address is not loopback and there is no certificate RestApi: Refusing to start - TLS certificate is required unless bound to 127.0.0.1 or ::1. Set RestApiCertificateFile and RestApiPrivateKeyFile. Set both files, enable ACME, or bind loopback The certificate cannot be loaded, or its key does not match RestApi: Refusing to start - the shared TLS configuration could not be applied to the configured certificate., preceded by HM5113 naming the certificate or key file Correct the PEM paths and permissions Nothing RestApi: Listening on 127.0.0.1:8443 (http, loopback only). or ... (https). None needed The Listening on line is the only proof that the listener is up. The loopback exemption is a string comparison against 127.0.0.1, localhost and ::1; 127.0.0.2 and ::ffff:127.0.0.1 do not qualify. The TLS configuration is the one the mail protocols use (cipher list, protocol switches, TlsKeyExchangeGroups), with a TLS 1.2 minimum applied on top, so opening an older protocol for a mail client does not open it for the management API. The three credentials Credential Header Reaches Administrator password Authorization: Basic with the user name Administrator (case-insensitive) Every administrative route. It is never read-only and never confined to domains. API key Authorization: Bearer hmapi_<64 hex> What its scope, role and domains allow, less the routes listed below. An account's own Authorization: Basic with the address and the mailbox password or an app password, or the hmailsession cookie /api/v1/me and /api/v1/session; since 6.3.4 also the administrative routes a role it holds allows. The two Basic forms are told apart before either password is tried: any user name but administrator is treated as a mailbox address and goes down the path an IMAP logon takes, with the same lockout and auto-ban. An account is never tested against the administrator password. API keys are the credential for scripts. A key is read-only unless it is made with "scope":"full", expires after 90 days unless the request names an expires (there is no "never expires"), and may be narrowed to named domains and to a source address, range or CIDR. Only the SHA-256 of a key is stored, in hMailServerApiKeys.ini beside hMailServer.ini, and that file is read again at every authentication attempt, so removing a key's section revokes it on the next request. # Administrator password only: a key can never make or revoke keys curl -u Administrator:your-admin-password \ -H "Content-Type: application/json" \ -d '{"label":"backup monitor","scope":"readonly","expires":"2027-01-01 00:00:00"}' \ http://127.0.0.1:8443/api/v1/apikeys {"id":"3f9a1c4b5d6e7f80","label":"backup monitor","scope":"readonly","domains":"", "expires":"2027-01-01 00:00:00","allowed_from":"","key":"hmapi_7c1f...c92a"} key is the clear text. It is returned once and cannot be recovered, so store it then. GET /api/v1/apikeys lists the keys with an expired flag and never a hash; DELETE /api/v1/apikeys/<id> revokes one. The create request takes label (required, at most 64 characters), scope, domains, expires and allowed_from, and since 6.3.4 role and the send API's send_from, send_per_minute and send_per_day. What no API key reaches. Whatever its scope or role, a key is answered 401 (not 403, so that it learns nothing) on the key store /api/v1/apikeys and on /api/v1/settings/ini, and since 6.3.4 also on /api/v1/audit, /api/v1/alerts, /api/v1/webhooks, the compromised-account review, /api/v1/settings/changed, /api/v1/server/settings/pending, an account's export, the address erasure and the second factor's enrolment. In the other direction, since 6.3.4 a key is the only credential POST /api/v1/send takes (a key naming a sender) and the only one /scim/v2 takes (a key whose role holds provision). Roles, since 6.3.4 A role is a named set of permissions, and an assignment gives an account a role in named domains (schema 6047). Nothing changes on upgrade: an account with no assignment holds what its admin level has always given it, and a key without a role is what it always was: Scope=full is serveradmin and Scope=readonly is auditor. A key is given a role with role on POST /api/v1/apikeys; an account is given one at /api/v1/role-assignments by a credential holding manage_roles. What a refusal says. A route a role may not call answers 403 naming the role and the permission, for example the role 'helpdesk' does not hold the permission 'manage_settings'. An address in a domain the role does not reach answers the role 'helpdesk' does not reach that domain whether or not the account exists. A request authenticated with an app password never carries the account's role. Administrator roles end to end has the built-in roles and their permissions, giving a role on each surface, custom roles, and the two stores, /api/v1/apikeys and /api/v1/settings/ini, that answer to the administrator password alone, not even to an account holding serveradmin. One-time codes When a second factor is enrolled on the administrator credential, the code travels in an extra header: curl -u Administrator:your-admin-password \ -H "X-hMailServer-OTP: 123456" \ http://127.0.0.1:8443/api/v1/status Without it the answer is the one 401 on this listener that says what is missing: HTTP/1.1 401 Unauthorized WWW-Authenticate: Basic realm="hMailServer" X-hMailServer-OTP: required Cache-Control: no-store {"error":"authentication failed","second_factor":"required"} A missing code is not counted towards the auto-ban; a wrong code is. Since 6.3.4 a one-time code is taken once, over COM and over REST, for the administrator and for an account alike. A code the server has accepted is refused if it comes again. The consequence for automation is direct: a script that sent the administrator's password and a code with every request now has only its first request in each half minute accepted. The others are refused with 401 and are not counted as wrong codes, so such a script does not lock the administrator out. The changelog names the two remedies: sign in once with POST /api/v1/session and use the hmailsession cookie from there, or use an API key. # Sign in once; the password and the code travel on this request alone curl -c cookies.txt -u Administrator:your-admin-password \ -H "X-hMailServer-OTP: 123456" \ -X POST http://127.0.0.1:8443/api/v1/session # Reads carry the cookie curl -b cookies.txt http://127.0.0.1:8443/api/v1/status # Anything that changes something also carries the CSRF header curl -b cookies.txt -X POST -H "X-Requested-With: hMailServer" \ http://127.0.0.1:8443/api/v1/queue/81234/retry # Sign out curl -b cookies.txt -X DELETE -H "X-Requested-With: hMailServer" \ http://127.0.0.1:8443/api/v1/session The session rules are enforced by the server: 30 minutes idle and 12 hours from sign-in until a session policy sets others (since 6.3.4, session_idle_seconds and session_lifetime_seconds), at most 1000 sessions, and every session dropped when the listener stops. A cookie-authenticated request that is not GET or HEAD without X-Requested-With: hMailServer is refused with 403. Somebody who is asked for a code twice within half a minute, to sign in to the Control Deck and then to register a passkey for example, waits for the authenticator's next code. Two further changes in 6.3.4 belong here. A wrong code is counted against a budget of five in fifteen minutes, after which no code is taken for fifteen minutes; on the administrator's Basic sign-in the answer is then 429 with code second_factor_locked and Retry-After. And for an account, one of its one-time recovery codes, in X-hMailServer-Recovery-Code, is taken wherever the authenticator's code is asked for. The administrator signs in at POST /api/v1/session with HTTP Basic, and with the code in X-hMailServer-OTP when a second factor is enrolled: AccountRecovery.md names the header for this route, the 6.3.4 changelog gives the session as the remedy above, and the Control Deck signs in that way. The wiki's credentials table, which lists /api/v1/session among the routes the administrator password does not reach, is out of date on this. How a request is judged The server tries Bearer, then Basic, then the session cookie. It then applies the CSRF check for cookie-authenticated writes (and, since 6.3.7, refuses any state-changing request that carries a credential when the browser reports it as cross-site, by Sec-Fetch-Site or an Origin that is not this server's) and the rate budget of 200 requests in a fixed 10-second window per credential, turns the method and path into a route kind, and decides on that kind in one place before any handler runs. Read-only is decided by route kind, not by HTTP method. A read-only key on a mutating route is answered 403 with this api key is read-only. A key with a domain list is refused server-wide resources outright: the queue, the quarantine, IP ranges, certificates, rules, logs, the backup and the settings among them. On a route that names a domain or an address outside its list the answer is 403 with this api key is not permitted for that domain. Routing is exact. Method and path must match after the query string is removed, a trailing slash is a different path, and an id in a queue, quarantine, IP-range, archive or /api/v1/me/... path must be 1 to 18 decimal digits and greater than zero, or the answer is 404. The OpenAPI document GET /api/v1/openapi.json answers the API's description to the administrator password or to any key, a read-only one included. The wiki's advice is that this document is the count to trust: in 6.3.4 it names 441 paths and 621 path-and-method operations, 413 and 585 of them under /api/, against 146 and 219 in 6.3.3. The rest are /scim/v2, the sign-in, reset and reading pages, the list manager's pages and the static catalogues and scripts. The Control Deck draws its settings groups from this document, and since 6.3.4 the document gives the ini-backed rows of the settings groups an x-applies mark saying when a save takes effect. The APIs Reference describes it as a static OpenAPI 3.0.3 document and records where its text is not accurate: the summary of /api/v1/status mentions uptime, and the handler returns no uptime field; the text for /api/v1/settings/ini still describes the file as the store, where since 6.3.4 the value goes to the database first. Settings saved and not yet running Since 6.3.4 a key marked reinitialize or restart whose stored value is not the value the server is running on is pending. The list is recomputed at every request. Route Answer GET /api/v1/server/settings/pending [{name, stored, running, applies, secret}]; a secret's two values are (set) or empty GET /api/v1/settings/ini The keys as {name, value, secret}, an applies map and a pending array of names GET, PUT, DELETE /api/v1/settings/ini/{name} One key, with applies and pending POST /api/v1/server/reinitialize 202 before it happens; {"reinitializing": true, "drained": true} when it ended a drain All but the last answer to the administrator password alone. A DELETE is now the only way to return a stored key to its default, because deleting a line from the file no longer does. A reinitialise restarts the REST listener with the other services, so it ends a Control Deck session; poll /api/v1/status until it answers again. Worked examples These use the loopback, plain-HTTP shape that RestApiPort=8443 and RestApiBindAddress=127.0.0.1 give; put the port that was stored in place of 8443. With a certificate configured, change http:// to https://. They present the administrator password on every request, which works while no second factor is enrolled; with one enrolled, use the session above or an API key. # Status: state is 0 Unknown, 1 Stopped, 2 Starting, 3 Running, 4 Stopping curl -u Administrator:pw http://127.0.0.1:8443/api/v1/status # Domains and accounts curl -u Administrator:pw http://127.0.0.1:8443/api/v1/domains curl -u Administrator:pw http://127.0.0.1:8443/api/v1/domains/example.com/accounts curl -u Administrator:pw -H "Content-Type: application/json" \ -d '{"address":"[email protected]","password":"S0me-Long-Passphrase"}' \ http://127.0.0.1:8443/api/v1/domains/example.com/accounts curl -u Administrator:pw -X DELETE \ http://127.0.0.1:8443/api/v1/accounts/[email protected] # The delivery queue curl -u Administrator:pw http://127.0.0.1:8443/api/v1/queue curl -u Administrator:pw -X POST http://127.0.0.1:8443/api/v1/queue/81234/retry curl -u Administrator:pw -X DELETE http://127.0.0.1:8443/api/v1/queue/81234 # Logs and the backup curl -u Administrator:pw "http://127.0.0.1:8443/api/v1/logs/hmailserver_2026-09-08.log?lines=50" curl -u Administrator:pw -X POST http://127.0.0.1:8443/api/v1/backup curl -u Administrator:pw http://127.0.0.1:8443/api/v1/backup # With a key curl -H "Authorization: Bearer hmapi_7c1f...c92a" http://127.0.0.1:8443/api/v1/status # Store another port: it waits for a reinitialise, and the pending list shows both values curl -u Administrator:pw -X PUT -H "Content-Type: application/json" \ -d '{"value":"8444"}' http://127.0.0.1:8443/api/v1/settings/ini/RestApiPort curl -u Administrator:pw http://127.0.0.1:8443/api/v1/server/settings/pending # Put the running port back; a value put back before anything applied it is no longer pending curl -u Administrator:pw -X PUT -H "Content-Type: application/json" \ -d '{"value":"8443"}' http://127.0.0.1:8443/api/v1/settings/ini/RestApiPort Creating an account answers {"address":"[email protected]","created":true}, or 400 for a missing field, 404 {"error":"domain not found"} or 409 {"error":"account already exists"}. In 6.3.4 the create route takes address and password and the other columns of the CSV import as well (first_name, last_name, active, max_size_mb and admin_level), and refuses a field it does not know by name (CommandLine.md). The older statement in the wiki and the APIs Reference, that only address and password are read, no longer holds. For a log, lines defaults to 200 and is capped at 2000. Limits and status codes Limit Value What the client sees Request size 64 KB, head and body 413, connection closed Large requests 16 MB and 300 s on POST /api/v1/me/messages and POST /api/v1/me/drafts; a few other routes are granted more than 64 KB as well, among them PUT /api/v1/me/settings and the SAML ACS (256 KB) 413 above that Request deadline 30 s, the TLS handshake included Connection closed with no response Connection lifetime 300 s, and 1000 requests Connection closed Concurrent connections 64 Connection closed Rate 200 requests per 10 s per credential 429 with Retry-After: 10 Auto-ban Shared with SMTP, IMAP and POP3; loopback exempt The address is refused at accept for 5 minutes A 401 is deliberately the same for no credential, a wrong password and an unknown, expired or out-of-network key; the application log says which. A request with Transfer-Encoding is answered 411, because the server does not de-chunk. An API response is application/json with Cache-Control: no-store, with exceptions: the static pages and the attachment download serve their own media types, a report asked for with format=csv is text/csv, and every answer under /scim/ is application/scim+json. Where to read more The wiki: The REST API and, for a row per route, APIs Reference section 4. The operator documents: Roles.md and SettingsApply.md. The public documentation: chapter 24, The REST API, with API keys and the OpenAPI document. On this forum: The Control Deck, which is the browser front end for this API; the Linux guide; the 6.3.4 announcement; hmctl and the PowerShell module; the send API and webhooks; and SCIM provisioning and single sign-on.
  • 0 Votes
    1 Posts
    134 Views
    ProgressiverobotP
    An organisation that keeps its people in an identity provider wants two things of its mail server: mailboxes that follow the directory, and a sign-in that goes through it. Since 6.3.4 hMailServer has both. SCIM 2.0 at /scim/v2 lets Microsoft Entra ID or Okta make, rename, suspend and remove accounts, with their aliases and distribution lists. Single sign-on, by OpenID Connect or SAML 2.0, opens the webmail at /portal and the Control Deck at /. The one thing to know first: they answer different questions and take different credentials. SCIM answers to an API key whose role holds provision and to nothing else. Single sign-on opens the two pages only: IMAP, POP3 and SMTP still take the account's password and its application passwords. Passkeys and account recovery are covered in the topic on signing in. How the pieces fit Piece Direction What it does LDAP directory authentication This server asks the directory Checks a password when somebody signs in SCIM provisioning The identity provider calls this server Says what changed, as it changes Single sign-on The browser goes to the identity provider and back Signs a person in to the webmail or the Control Deck oidc_auto_provision, saml_auto_provision In passing, at sign-in Makes a mailbox for whoever arrives with a verified address, and never touches it again SCIM is the deliberate version of auto-provisioning. The two work together: SCIM keeps the mailboxes, and single sign-on is how the people it made sign in to them. Where the settings are. The single sign-on settings are the REST API's and the Control Deck's. There is no COM property and no Control Panel page for them, on Windows or on Linux. SCIM: the key and the permission A SCIM request is a request of the REST listener. RestApiPort has to be on and the listener reachable from the identity provider's service over TLS, with a certificate it trusts (RestApiCertificateFile, or the ACME certificate). A body is at most 64 KB, and the key's requests count against the listener's budget of 200 in ten seconds per credential, beyond which the answer is 429 with Retry-After. The key's role must hold provision. The built-in serveradmin role holds it, so every key issued with "scope":"full" provisions; no other built-in role does, so a read-only key is answered 403. The way to issue a key that provisions and does nothing else is a custom role: curl -u Administrator:your-admin-password -H "Content-Type: application/json" \ -d '{"name":"entra-provisioning","description":"Microsoft Entra ID","permissions":"provision"}' \ https://mail.example.com:8443/api/v1/roles curl -u Administrator:your-admin-password -H "Content-Type: application/json" \ -d '{"label":"Entra provisioning","role":"entra-provisioning"}' \ https://mail.example.com:8443/api/v1/apikeys The second request, POST /api/v1/apikeys, answers the hmapi_... token once, and that token is the identity provider's secret. provision is needed for SCIM's reads as well as its writes. Both requests present the administrator password, which works while no second factor is enrolled on it. With one enrolled, sign in once at POST /api/v1/session with the code in X-hMailServer-OTP, and send both requests with the hmailsession cookie and X-Requested-With: hMailServer: since 6.3.4 a one-time code is taken once, so a second request carrying the same code within the same half minute is refused with 401. Every other credential is refused. The administrator password, an account's password and a browser session are answered 401 on /scim/, in SCIM's shape, with WWW-Authenticate: Bearer. A key confined to named domains ("domains":"example.com") provisions those domains and learns nothing about the rest: listings are filtered, an account elsewhere is the same 404 as one that is not there, and an address elsewhere is refused 400 invalidValue. A key may not change or remove an account whose authority it does not take in, and is answered 403. A key whose role holds provision alone takes in no administrator at all: not a domain administrator, not a help desk, not an auditor. A full key confined to named domains takes in an administrator of those domains and nobody else. A full key issued for every domain takes in every account. SCIM: the routes and the mapping Route What it does GET /scim/v2/ServiceProviderConfig What this server supports: patch, filter (200 at most), changePassword and etag; not bulk and not sort GET /scim/v2/Schemas, GET /scim/v2/ResourceTypes The User and Group schemas, listing only what this server keeps; the two resource types GET, POST /scim/v2/Users The accounts: listed and filtered, or one made GET, PUT, PATCH, DELETE /scim/v2/Users/{id} One account: read, replaced, changed, removed GET, POST /scim/v2/Groups The distribution lists: listed and filtered, or one made GET, PUT, PATCH, DELETE /scim/v2/Groups/{id} One list: read, replaced, changed, removed Every answer under /scim/ is application/scim+json, the refusals included. A User is an account. SCIM This server id, userName The account's address. The id is the address, so a rename changes the id. name.givenName, name.familyName The first and last names, sixty characters each displayName The two names composed. Written alone to an account that already has a name, it is accepted and not kept. active The account's switch. false suspends it and deletes nothing. emails The address, type work and primary, and every alias whose value is the address, type other password Write-only. Judged by the password policy, the reuse history and, when it is on, the breach check. externalId The identity provider's own id for the person, at most 255 characters (schema 6096) meta.created and meta.lastModified are not answered, because the tables keep no such times. A Group is a distribution list: id and displayName are the list's address, and members are its members. The sharing groups at /api/v1/groups are folder-permission principals and are not SCIM groups. Attributes the schemas have and this server does not keep, such as title, phoneNumbers and the enterprise extension's department and manager, are accepted and not kept. A misspelt attribute or path is refused by name. SCIM: what each operation does Making a user. POST /scim/v2/Users needs a userName at a domain this server hosts and the key reaches. A body with no password, which is what Entra sends, makes the account with thirty-two random bytes for one, which nobody is ever told. An address something already has is 409 uniqueness. Filters. These and no others: userName eq, userName co, userName sw, externalId eq, emails.value eq and active eq. Any other filter is 400 invalidFilter. count is at most 200, and sortBy and sortOrder are ignored. Groups filter on displayName eq only. Changing. PUT takes the body a POST takes, and an attribute it leaves out is left as it is. PATCH takes a PatchOp, with op in any case, as Entra capitalises it. If-Match is honoured on PUT, PATCH and DELETE; a version other than the current one is 412. A change is judged whole before anything is written. Suspending. active false refuses every sign-in from the next attempt, refuses mail to the account, ends its browser sessions and drops its live IMAP, POP3 and SMTP connections. The mailbox, the aliases and the lists are untouched. Renaming. A new userName renames the account through the mailbox move. It is refused 409 while the mailbox is in use. The id changes, the old id is 404 from then on, and the old address is kept as an alias until a later request that sends emails leaves it out. Every alias of the account is part of emails, including one an administrator made. An identity provider that replaces emails removes the aliases it does not list. Where aliases are managed on this server and not in the directory, do not map emails beyond the work address. Removing. DELETE /scim/v2/Users/{id} removes the account and its mailbox, every alias emails answered for it, and its row in every distribution list. It answers 204. Nothing of it can be put back but from a backup. Entra sends active false straight away and the DELETE thirty days after a user is soft-deleted, so the suspension is the safe first step. Five diagnostics in the error log, HM7220 to HM7224, name what a SCIM write left behind when it failed part way. Every SCIM write is on the audit trail, each row naming key:<id>. Setting up Microsoft Entra ID for SCIM Issue the key as above and note the token. In the Entra admin centre, Enterprise applications > New application > Create your own application > Integrate any other application you don't find in the gallery (Non-gallery). Provisioning > Automatic. Tenant URL: https://<host>:<port>/scim/v2, where the port is RestApiPort. Secret token: the hmapi_... token. Test connection asks for a user that does not exist and expects an empty list, which is what this server answers. Mappings > Provision Microsoft Entra ID Users: map userName from userPrincipalName where that is the mailbox address, or from mail where it is not. emails[type eq "work"].value must be the same address as userName. Keep active, name.givenName, name.familyName, displayName and externalId, and delete the mappings this server does not keep. Mappings > Provision Microsoft Entra ID Groups, for mail-enabled groups that should be distribution lists: map displayName from mail, keep members, and delete the mapping of externalId. Assign the users and groups, and switch Provisioning Status on. Setting up Okta for SCIM Issue the key as above. Applications > Create App Integration > SAML 2.0 or SWA, then on General enable SCIM provisioning. Provisioning > Integration: SCIM connector base URL https://<host>:<port>/scim/v2; Unique identifier field for users userName; Supported provisioning actions: Import New Users and Profile Updates, Push New Users, Push Profile Updates, Push Groups; Authentication Mode HTTP Header, and the token in Authorization. Provisioning > To App: enable Create Users, Update User Attributes and Deactivate Users. Push Groups sends a group under its Okta name as displayName, and here that is the list's address, so a push of Engineering is 400 invalidValue. Name each pushed group with the list's address, or leave Push Groups off. Okta sends emails with the primary address alone, so an alias made on this server is removed by Okta's next update. Scim.md gives no steps for Google Workspace: at the time it was written, a custom application there could not be pointed at a SCIM endpoint. Single sign-on with OpenID Connect Register this server at the issuer as an authorization-code client with PKCE, with the redirect URI <oidc_redirect_base_url>/portal/oidc/callback. Setting What it is oidc_issuer For example https://login.example.com. The discovery document is read from <issuer>/.well-known/openid-configuration and cached for an hour. Empty offers no single sign-on at all. oidc_client_id The client id this server is registered under oidc_client_secret Its secret, stored protected and never emitted. Empty for a public client. oidc_scopes openid email by default oidc_redirect_base_url The address the webmail is reached at from outside. Empty falls back to import_oauth_redirect_base_url. oidc_auto_provision Whether a verified address with no mailbox here gets one. Off. oidc_administrators The addresses that open the Control Deck as the administrator. Empty means single sign-on reaches the webmail only. The flow is GET /portal/oidc/start[?deck=1] to the issuer and GET /portal/oidc/callback back. The callback verifies the id_token: its signature against the issuer's published keys (RS256 and ES256 only), iss, aud, exp, iat and nbf with a minute of skew, and the nonce. It then takes email, and only when email_verified says the issuer verified it. With oidc_issuer set, both sign-in pages draw a Sign in with button naming the issuer's host. What they draw is decided by GET /api/v1/signin-methods, which needs no credential and says nothing about who exists here. With SAML set up it answers saml: true and saml_label, and both pages draw a second button. The callback does not make the session. It mints a one-use ticket, good for two minutes, and the page hands it to POST /api/v1/session, which is where every browser session is made. The ticket is bound to the browser by an hmsso cookie, and POST /api/v1/session refuses a ticket that arrives without it. Mapping. The verified address is looked up as a mailbox. With auto-provisioning off, the default, a sign-in with no mailbox is refused. With it on, a mailbox is made, but only at a domain this server already has, and with a random password nobody is told. For the Control Deck, the address must be in oidc_administrators. An issuer that says somebody is an administrator is not obeyed. Single sign-on with SAML 2.0 For identity providers that speak only SAML 2.0: ADFS above all, and an Entra ID or Okta application set up the SAML way. It ends in the same ticket, the same POST /api/v1/session and the same mapping rules. Setting What it is saml_idp_entity_id The identity provider's entity ID. Empty, the default, offers no SAML sign-in at all. saml_idp_sso_url Its sign-in URL for the HTTP-Redirect binding saml_idp_certificate Its signing certificate in PEM, or two while it rolls its key over. Write-only. saml_sp_entity_id This server's entity ID. Empty means <base URL>/saml. saml_email_attribute The attribute the address is read from when the NameID is not an email address saml_require_signed_assertion Whether the Assertion must carry its own signature. On by default. saml_auto_provision, saml_administrators As the OpenID Connect settings of the same names saml_button_label The words on the button: Sign in with SAML by default The base URL is OpenID Connect's: oidc_redirect_base_url, falling back to import_oauth_redirect_base_url. From it follow the entity ID <base URL>/saml, the ACS URL <base URL>/portal/saml/acs and the metadata at <base URL>/saml/metadata. Identity provider What to enter there What to copy back Microsoft Entra ID Identifier (Entity ID) <base URL>/saml; Reply URL <base URL>/portal/saml/acs; token encryption off Login URL to saml_idp_sso_url; Microsoft Entra Identifier to saml_idp_entity_id; Certificate (Base64) to saml_idp_certificate Okta Single sign-on URL <base URL>/portal/saml/acs; Audience URI (SP Entity ID) <base URL>/saml; Name ID format EmailAddress; Assertion Encryption Unencrypted Identity Provider Single Sign-On URL to saml_idp_sso_url; Identity Provider Issuer to saml_idp_entity_id; X.509 Certificate to saml_idp_certificate ADFS A claims-aware relying party trust, imported from <base URL>/saml/metadata or entered by hand; E-Mail-Addresses sent as E-Mail Address https://<adfs host>/adfs/ls/ as saml_idp_sso_url; the Federation Service Identifier as saml_idp_entity_id; the Token-signing certificate as saml_idp_certificate The flow. GET /portal/saml/start[?deck=1] to the identity provider, with an AuthnRequest, and the hmsaml cookie POST /portal/saml/acs back from it, with the Response GET /portal/saml/finish on from the ACS, where the cookie is asked for GET /saml/metadata this service provider, for the identity provider The ACS takes a form of at most 256 KB; a larger body is answered 413. The signature is verified against the certificates an administrator stored and never a key the message carries. The signature method must be rsa-sha256 or ecdsa-sha256, and SHA-1 is refused. A Response is taken only when it answers a request this server issued, not yet answered, with the same RelayState. The checks refuse signature wrapping, a replayed Response or assertion, a Response nobody asked for and an encrypted assertion; comment injection in the NameID gains nothing, because the address read is the whole of what was signed. A saml_idp_certificate that cannot be read stops every SAML sign-in. The settings refuse one, so it comes only from a database edited by hand; HM7320 in the error log then names the setting to paste again. A SAML identity provider says nothing like email_verified. The attribute the address is read from must be one the directory controls, such as mail or the user principal name, and never one a person can set themselves. This server cannot check that. What it does not do SCIM: no bulk, no sorting and no POST .search (/Bulk, /Me and POST .search answer 501); no enterprise user extension; no groups but distribution lists; not a SCIM client. externalId is shown by nothing but SCIM and COM's Account.ExternalId, which is Windows-only. Single sign-on, both kinds: no logout in either direction, so an account disabled at the identity provider keeps a session it already started until that session's own ceiling ends it. One OpenID Connect issuer and one SAML identity provider. Nothing is enforced at IMAP, POP3 and SMTP. OpenID Connect: no claims mapping. The email claim and nothing else. SAML: no encrypted assertions, no signed requests, no metadata fetch, no artifact binding and no sign-in started by the identity provider. Where to read more The operator documents: Scim.md, PasskeysAndSingleSignOn.md and Roles.md. The wiki: The REST API and APIs Reference. The public documentation: chapter 24, The REST API and chapter 36, The Control Deck and the webmail portal. On this forum: passkeys, recovery and the rest of sign-in are in Signing in: passwords, second factor, passkeys, single sign-on; LDAP sign-in and directory synchronisation are in Active Directory and LDAP sign-in; The Control Deck; the 6.3.4 announcement; and the REST API guide.
  • 0 Votes
    1 Posts
    126 Views
    ProgressiverobotP
    Written for 6.3.4. hMailServer 6.3.7 (17 October 2026) refuses a state-changing REST request that carries a credential when the browser making it reports it as cross-site (Sec-Fetch-Site, or an Origin that is not this server's), so a page on another site cannot post to these routes with a credential, whether a key or the administrator password the browser holds; a program or script that sends no such header, as the curl examples here do not, is unaffected. See the 6.3.7 announcement. hMailServer 6.3.8 (2 November 2026) answers 400, saying why, before a route reads a member, to a body that is not one JSON object, names a member twice, holds an escaped U+0000 or passes 100,000 values or 32 levels, so correct a program that sends such a body to these routes; and it raises account.locked only for a locked name that is an account's own. See the 6.3.8 announcement. Since 6.3.4 hMailServer has three ways for another program to work with the server's mail: a send API, through which an application sends as one sender and asks afterwards what became of each recipient; mail in to a webhook, where the mail for an address or a whole domain is posted to a URL; and outbound webhooks, which tell a program what the server's mail did. Nothing is on until a key, a route or a subscription is made. The one thing to know first: each of the three takes a different credential, and the difference is deliberate. Feature Routes Who may use it The send API /api/v1/send An API key that names a sender and whose role holds send_through_api. Not the administrator password, not an account. Mail in to a webhook /api/v1/inbound-webhooks view_configuration to read, manage_inbound_webhooks to change. Meant for an API key's role, confined to the application's domain. Outbound webhooks /api/v1/webhooks The administrator password, or an account whose role holds the permission. No API key reaches these routes; the refusal is 401. All three are made and managed over the REST listener, so RestApiPort has to be on; outbound webhook subscriptions can also be managed over COM, on Windows. The send API: the key POST /api/v1/me/messages is the webmail's send and stays the webmail's. The send API is for programs. Route What it does POST /api/v1/send Sends a message as the key's sender. 202 {queue_id, message_id, recipients, recorded} GET /api/v1/send The key's own sends, newest first, paged GET /api/v1/send/{queue_id} One of them, with the state of each address its body named A key sends when two things are true of it: its role holds send_through_api, and it names a sender. Either alone sends nothing. Three fields are set when the key is made, on POST /api/v1/apikeys with the administrator password or on the Control Deck's API keys page. Field What it is send_from The one sender the key may use: an address of a domain this server hosts, or *@domain for any address of that domain. Empty, the default, means the key cannot send. send_per_minute The key's own ceiling on messages a minute. 0, the default, sets none. send_per_day The key's own ceiling on messages in any 24 hours. 0 sets none, which only a key whose sender is an account's address may have. The built-in serveradmin role holds the permission, so a key made with "scope": "full" holds it. No other built-in role does. A custom role is how a key that sends and does nothing else is made: POST /api/v1/roles {"name": "mailer", "description": "sends mail", "permissions": "send_through_api"} POST /api/v1/apikeys {"label": "shop confirmations", "role": "mailer", "send_from": "[email protected]", "send_per_day": 5000} The route refuses with 400, among other things, a send_from at a domain this server does not host (a domain alias does not count), at a domain that is switched off, on a read-only key, or outside the domains a key is confined to. A send_from that is *@domain, or an address that is no account's, needs a send_per_day above 0, because such a sender has no account's sending quota to hold it. A key's sender cannot be changed afterwards: a new sender is a new key. What *@domain grants. To the server, such a key is every mailbox of that domain sending as itself. It can post to a members-only list as any member and send as the domain administrator's mailbox. The display name and reply_to are the application's own and are not checked against anything; what the key vouches for is the address. On Windows, the desktop Control Panel's key page does not show these three fields and cannot set them, so a key made there cannot send. The Control Deck and the route do. The send API: sending and asking afterwards curl https://mail.example.com:8443/api/v1/send \ -H "Authorization: Bearer hmapi_0123…" \ -H "Content-Type: application/json" \ -d '{"to":["[email protected]"],"subject":"Your order","text":"Thank you."}' Member Rule from Optional when the key's sender is an address, required when it is *@domain. "address" or "Name <address>". to, cc, bcc Arrays of mailboxes; at least one recipient between them. Bcc goes to the envelope and nowhere else. reply_to Optional. subject Required; it may be empty. text, html At least one. Both makes a multipart/alternative. headers Optional: X- fields other than the server's own X-hMailServer- fields, List-Unsubscribe, List-Unsubscribe-Post, In-Reply-To and References. Any other field is 400. attachments Optional: [{filename, content_type, data}], data in base64, at most 20 and twelve megabytes together. A line break is refused with 400 in every value that becomes a header, and a member the route does not take (body for text, for example) is 400 and named, not dropped. What the server does to the message. It is written and checked by the same code as the webmail's send. Every recipient goes through what RCPT TO asks of an authenticated sender, with the server's recipient ceiling (MaxSMTPRecipientsInBatch) and the suppression list. The domain's signature and disclaimer are added, the rules that block are asked (403, policy_rejected), and the message is held to the maximum message size (413). It is DKIM-signed at delivery by the domain's key. The envelope sender is the From address, so a bounce comes back to it. A Received field names the server, the send API and the key's label, never the key's id. No Sent copy is kept anywhere. When the sender is an account's address, that account's sending quota is consumed as an SMTP submission would consume it. Ceiling Answer send_per_minute 429, Retry-After: 60 send_per_day 429, Retry-After: 3600 The sender account's sending quota 429, Retry-After one twenty-fourth of the account's period The REST request budget, 200 requests in 10 seconds 429, Retry-After: 10 The recipient ceiling, the size 400, 413 A message is counted only once every other check has passed, and when a later ceiling refuses, every earlier one is given back. A refused message is never a counted one. The answer to a message that is taken is 202: {"queue_id": 4711, "message_id": "<…@mail.example.com>", "recipients": 2, "recorded": true} queue_id is the one to ask about afterwards with GET /api/v1/send/4711. The answer names only the addresses the body named, each with a state. State Meaning queued In the queue and not yet tried deferred An attempt failed and will be retried delivered The trace's delivered event bounced Refused for good, or the retries ran out refused The suppression list stopped it at delivery unknown Nothing names the address; the answer says why It needs the message trace, and the trace is off by default (MessageTraceEnabled), because it records who corresponds with whom. With it off, a send reads queued or deferred while it is in the queue and unknown once the queue has let it go; trace in the answer is false and a trace_note says so. Turn the trace on, and choose its retention, before relying on the answer. The record of a send is kept for MessageTraceRetentionDays, thirty by default. Only a send this key made is answered; any other id is the same 404. The backup does not carry the records. Mail in to a webhook: a route SendGrid and Mailgun call this inbound parse. A route is made over REST or on the Control Deck's Inbound webhooks view. There is no COM member and no Control Panel page, on either platform. POST /api/v1/inbound-webhooks { "address": "[email protected]", "url": "https://helpdesk.example/hooks/mail", "secret": "a-long-random-string" } Field What it is Default address [email protected], or example.com for every address of the domain. The domain must be one this server hosts. required url https:// to the endpoint; plain http:// only to this machine. Printable ASCII only. required secret Write-only. Every request is signed under it and it is never returned. required deliver false: the route alone receives the mail. true: what the address reaches without the route receives it as well. false on_failure keep or bounce keep format form (multipart/form-data in SendGrid's field names) or json form attachments include, names or none include include_raw Also send the message as it arrived false max_size_kb A larger message is not posted. At most 81920. 10240 enabled A route switched off is as though it were not there for new mail true A route decides that the address exists. RCPT TO is accepted for a routed address whether or not an account, an alias or a list has it, and a route is asked before any of them. With deliver off, the route takes the mail instead of whatever has the address, so a list whose address a route takes never sees the message. Route a list's domain with care. An address route wins over its domain's, and a recipient with a plus tag ([email protected]) is found by the address as written, then by the address before the tag, then by the domain, with the tag kept in what is posted. What arrives is a signed POST: POST /hooks/mail HTTP/1.0 Content-Type: multipart/form-data; boundary=hMailServer-4193-... X-hMailServer-Timestamp: 1789000042 X-hMailServer-Signature: sha256=<hex> X-hMailServer-Event: message.inbound X-hMailServer-Delivery: 4193 X-hMailServer-Attempt: 1 X-hMailServer-Delivery is the same on every attempt. The form format carries SendGrid's fields (headers, from, to, cc, subject, text, html, envelope, sender_ip, SPF, dkim, charsets, attachments, attachment-info, attachment1 onward) with a few of this server's own beside them (spam, dmarc, route, queue_id, attachments_left_out). Mailgun's field names are not sent. The json format is this server's own shape. The SPF, dkim and dmarc results are the ones this server wrote into its own Authentication-Results field, and only while AuthenticationResultsEnabled is on. Retries and what is kept. The message is taken after the global rules, the virus scan and the sandbox have had it, and nothing is posted from the delivery thread. Any 2xx delivers. Anything else, a redirect included, is a failure. The retries are the outbound webhooks': WebhookMaxAttempts attempts (5 by default), with waits of 0, 1, 5, 15 and then 60 minutes. When they run out: keep: the message is kept and never swept. It can be read with GET /api/v1/inbound-webhooks/deliveries/{id}/message, tried again with POST /api/v1/inbound-webhooks/deliveries/{id}/retry or removed with DELETE /api/v1/inbound-webhooks/deliveries/{id}. The error log says so once, as HM7401. bounce: the message is returned to its sender with a delivery-status report (4.4.7), without repeating what the endpoint answered. HM7402 records the return. Where the sender cannot be told, the message is kept instead. A message larger than the route's ceiling is not posted at all: it is returned at once (5.3.4) or kept at once (HM7403). Delivered and returned rows are removed after WebhookRetentionDays (7 by default), and the rows survive a restart. Where an endpoint may be depends on who made the route. The server's administrator may name an address only this server can reach, but never a link-local or cloud metadata address (169.254.0.0/16, fe80::/10 and the clouds' own metadata addresses). Every other credential, an API key of any scope or role included, may name a public address only. Both are judged when the route is saved and again at every connection. No redirect is followed and HttpProxy is never used. Who may. Only serveradmin holds manage_inbound_webhooks as shipped, since routing a domain's mail to a URL is as much as reading all of it. A reader without it is answered a route's URL as its scheme and host alone, so put a token in the secret, not in the URL. Reading a kept message needs view_mailbox as well. POST /api/v1/inbound-webhooks/run makes one pass now and needs a credential that reaches every domain. Outbound webhooks An outbound webhook does not carry the message. No body, no attachments, no header block. It is a notification. The one piece of content it can carry is the subject, and only where the domain's webhook_subjects and the subscription's include_subject are both on; both are off by default, and even then a subject travels on message.received and message.quarantined only. Event Raised when message.received The message has been saved and the server has taken responsibility for it, once per local recipient message.delivered A recipient, local or remote, has been delivered to message.bounced A non-delivery report has been written and queued for the sender, once per failed recipient message.quarantined The held copy and its row exist account.locked Repeated authentication failures have locked a name A delivery attempt that failed and will be retried is not a bounce, and no event says deferred. POST /api/v1/webhooks { "name": "ticketing", "url": "https://tickets.example/hooks/mail", "secret": "a-long-random-string", "events": "message.bounced,account.locked", "domains": "example.com", "max_per_minute": 120 } An empty events, domains or addresses list means every one. A subscription confined to a send API key's sender ("addresses": "[email protected]") hears message.delivered and message.bounced for the recipients outside this server, with the message trace on or off. Plain http off this machine is refused when the subscription is made. The secret is write-only, protected at rest, and an empty secret in a change keeps the one that is set. Permissions. Reading needs view_configuration; deleting needs manage_settings; creating, changing, testing and running a pass need manage_server, because whoever can do those can make the server connect to a host of their choosing. A role confined to named domains is refused all eight routes. The queue. The delivery pass runs once a minute, attempts at most 200 rows and runs for at most 60 seconds. A delivery succeeds on any 2xx; a request that has taken more than ten seconds is a failure. After WebhookMaxAttempts the row is dead-lettered and the error log says so once as HM6971. Past a subscription's ceiling per minute, events are dropped and counted as dropped, not queued. Setting REST key Default WebhooksEnabled webhooks_enabled on WebhookMaxAttempts webhook_max_attempts 5 WebhookMaxPerMinute webhook_max_per_minute 60 WebhookRetentionDays webhook_retention_days 7 WebhooksEnabled silences the outbound webhooks and not the inbound routes. From a shell: 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 webhooks test --event message.bounced --domain example.com hmctl webhooks run hmctl webhooks deliveries --state dead-lettered webhooks test raises a real event through the path a real one takes and says how many subscriptions took it, so 0 is an answer. In PowerShell the functions are Get-HmWebhook, New-HmWebhook, Set-HmWebhook, Remove-HmWebhook, Get-HmWebhookDelivery, Invoke-HmWebhookDelivery and Test-HmWebhook. Over COM, on Windows, Utilities.GetWebhookSubscriptions(name) and Utilities.SaveWebhookSubscription(json) take and answer the same JSON documents. Verifying a signature One recipe signs all of them: the outbound webhooks, the inbound routes and the webhook of an alert rule. The signature is HMAC-SHA256 over the timestamp, a full stop, and the raw request body, under the shared secret, as lower-case hex, in X-hMailServer-Signature. Verify it against the bytes received, before parsing them. import hmac, hashlib, time def verify(secret, headers, raw_body): timestamp = headers["X-hMailServer-Timestamp"] signature = headers["X-hMailServer-Signature"] # Refuse anything older than five minutes, or the signature can be replayed. if abs(time.time() - int(timestamp)) > 300: return False expected = hmac.new(secret.encode(), (timestamp + ".").encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest("sha256=" + expected, signature) Compare in constant time. Check the timestamp: a signature with no freshness check can be replayed. De-duplicate on X-hMailServer-Delivery together with X-hMailServer-Event. A retry is the same body with a fresh timestamp and so a fresh signature, and an inbound and an outbound delivery can carry the same number. To rotate a secret, PUT the new one and accept either signature at the receiver while the change is made. There is no overlap window in the server. The documents also give the recipe in JavaScript. An alert's webhook carries three of the headers: X-hMailServer-Timestamp, X-hMailServer-Signature and X-hMailServer-Event. What they do not do The send API has no templates, no substitutions, no scheduled send and no per-recipient ceiling of the key's own, and gives no state without the trace once a message has left the queue. An inbound route makes one post per route per message, not one per recipient. It has no setting of its own and no per-route retry ladder. An outbound webhook does not carry the message and has no per-subscription retry policy. Neither kind of webhook is in the configuration backup. Export the routes with GET /api/v1/inbound-webhooks and the subscriptions with hmctl webhook list; the secrets are in no read. One pass at a time is per process, not per database, for both kinds of webhook. Where to read more The operator documents: SendApi.md, InboundWebhooks.md, Webhooks.md and, for the alerts' webhook, AlertsAndAuditTrail.md. The wiki: Mail In to a Webhook and The REST API. The public documentation: chapter 24, The REST API. On this forum: The Control Deck, the 6.3.4 announcement, the REST API guide and hmctl and the PowerShell module; turning the message trace on is in Troubleshooting by symptom, and the alert rules whose webhook is signed the same way are in Alerts, the audit trail, syslog and the journal.
  • 0 Votes
    1 Posts
    115 Views
    ProgressiverobotP
    Written for 6.3.4. hMailServer 6.3.6 (6 October 2026) carries the PowerShell module, HmailServer.psd1 and HmailServer.psm1, Authenticode-signed, as files of the release, and Linux packages for x86-64 and AArch64; hmctl as a PyPI package and the module on the PowerShell Gallery are built but not yet published. See the 6.3.6 announcement. hMailServer 6.3.7 (17 October 2026) carries the module the same way, and since 18 October 2026 the repository and its releases are also mirrored on GitHub, issues and merge requests staying on GitLab. 6.3.7's REST API refuses a state-changing request that carries a credential when a browser reports it as cross-site, which hmctl and the module, sending no such header, never meet, and it refuses a key restricted to named domains a domain's rename, a domain alias and a custom role's creation, change or delete. See the 6.3.7 announcement. Since 21 October 2026 hmctl is on PyPI as hmailserver-cli and the module on the PowerShell Gallery as HmailServer, each at 6.3.7. hMailServer 6.3.8 (2 November 2026) carries the module as files of the release, as before, and both are sent 6.3.8 by a job started by hand once the release is published. 6.3.8's server answers 400 to a body that is not one JSON object or names a member twice, so a body given to hmctl api or --from-json must be one JSON object that names each member once, and 6.3.8's hmctl refuses a body holding NaN or Infinity before sending it. See the 6.3.8 announcement. hMailServer 6.3.4 has two command-line clients: hmctl for any shell, and the HmailServer module for PowerShell. They share one vocabulary and one API, so both administer a Windows server, a Linux server or a server on another machine, and neither needs COM or the Control Panel. This guide covers where each comes from, the credentials they take, the verbs, the import of accounts from a CSV file with its dry run, the configuration as one document, and what neither client does. The one thing to know first: both speak the REST API, so the REST listener has to be on before either can do anything, and neither client can turn it on. Neither is a replacement for the administration programs. The Windows Control Panel is the place to configure a server on Windows, and the browser Control Deck is that place on Linux. A command line is for what a command line is better at: making fifty accounts from a spreadsheet, reading one setting in a script, watching the delivery queue, or doing the same thing to twenty servers. Where the clients come from File What it is hmailserver/source/Tools/Cli/hmctl One Python 3 file with no dependencies beyond the standard library. 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 in CI and asserts what each one sent, so the two cannot drift apart. On Linux, the packages install hmctl as /usr/bin/hmctl. The 6.3.4 packages are x86-64 only, and the changelog records Python 3 as one of the .deb's two Recommends, the other being 7-Zip. On Windows, the wiki says the installer carries neither client: take them from the repository directory hmailserver/source/Tools/Cli/. Both clients are in the repository since 6.3.4. build/hmconfig.ps1 is a different tool and stays. It exports the whole configuration as one JSON document and applies it to another machine over COM, on Windows. The two clients here are per-object verbs over HTTP. Before anything else: the listener On Linux, store the port on the server itself and restart the service. Since 6.3.4 the database is the settings store, so editing the [Settings] section of hMailServer.ini does not turn the listener on once the server has stored the key. sudo hmailserver --set-setting RestApiPort=8080 sudo systemctl restart hmailserver On Windows, the Control Panel does this over COM. On either platform, TLS is required unless the listener is bound to the loopback, so give it a certificate the client trusts if it is to be reached from another machine. The REST API guide has the listener's settings, the reinitialise they wait for and the three ways it refuses to start, and the hmailserver command line on Linux has the checks --set-setting makes and how it takes a secret. 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 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. --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 what the refusal prevents, and the remedy is a certificate the client trusts. Some commands take the administrator password and never an API key, because no key reaches the routes behind them: hmctl apikey, hmctl ini, hmctl config changed, hmctl webhook and hmctl webhooks. The command synopsis names no option for a one-time code. Since 6.3.4 a one-time code is taken once, and the changelog's advice to a script that sent the administrator's password and a code with every request is a session or an API key; the REST API guide has the detail. hmctl hmctl [--url URL] [--api-key KEY | --password PASSWORD | --password-file FILE] [--insecure] [--json] <resource> <verb> [arguments] The resources, grouped. CommandLine.md has a row for each. Area Resources Notes Domains and what is in them domain, account, alias, list, group, fetchaccount, app-password, device, mailbox An account is listed and created under --domain, and read and written at its address, which is how the API is shaped. mailbox renames or moves one. Server-wide objects rule, route, port, iprange, certificate, journalrule list, create, update, delete Mail in motion queue, quarantine queue has list, retry, delete; quarantine has list, release, delete Settings settings, ini, config A settings group whole or one setting; the keys that were the [Settings] section; the configuration as one document Deliverability dkim, dmarc, suppression, bounce, bounces, complaint, complaints, spamtrap, traphits, reputation, openpgp reputation exits 1 when an address is listed and when a list could not be asked Integrations apikey, webhook, webhooks All three with the administrator password only, never an API key Operations log, backup, report, sample-domain, match match says what a rule criterion would decide, without a rule Bulk accounts import and export, a CSV file, with a dry run Anything else api A method and a path: the escape hatch, for a route newer than the client A few whole commands: hmctl domain create --set name=example.com --set active=true hmctl account create --domain example.com --set [email protected] --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 device [email protected] list hmctl queue list --json | jq '.[] | select(.retry_count > 3)' hmctl match contains viagra "cheap viagra here" hmctl report traffic --from 2026-09-01 --to 2026-09-15 --domain example.com hmctl api POST /api/v1/server/reinitialize How values are read. --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 and exit codes. Output is a table by default and the server's own JSON with --json. Exit code Meaning 0 Success 1 An error 2 A usage mistake 3 The server refused the credential 4 What was named does not exist A script can therefore tell "no such account" from "wrong password" without reading the text. 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 [email protected] -Password (Read-Host -AsSecureString) Set-HmAccount -Address [email protected] -Property @{ max_size_mb = 500 } Set-HmSetting -Group antispam -Property @{ use_spf = $true } Get-HmQueue | Where-Object retry_count -gt 3 | Start-HmQueueDelivery Get-HmChangedSetting | Format-Table store, name, value, default Every function answers objects, so Where-Object, Sort-Object and Export-Csv work on them, and 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 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. Accounts from a CSV file 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 Notes address yes Created under the address's own domain. password yes Sent as text, so digits alone are still a password. 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. first_name At most 60 characters, counted as UTF-16 code units. last_name As first_name. active true, yes, on or 1; false, no, off or 0. Default true. max_size_mb A whole number of 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 [email protected],S0me-Long-Passphrase,Ann,Adams,true,500,user [email protected],An0ther-Long-Passphrase,Bob,"Brown, Jr",true,,domain The columns may come in any order and their names in any case. Every cell but the password is trimmed, and an empty cell is not sent, so the server's default applies. A value with a comma in it goes in quotes. A row with more cells than the header has columns is refused rather than shifted along. What is refused before anything is sent. Each row becomes one POST /api/v1/domains/<domain>/accounts, and that route refuses a field it does not know by name. So a column that is not in the table is refused before anything is sent, a name column included. The clients also refuse, 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; a name past its 60 characters; a password with a space or a tab at its start or end, which is the one row the clients refuse and the server would take. What a dry run cannot foresee. The password policy, the domain's account limit and an address the server does not accept are judged by the server alone. They are refused per row when the import runs, and listed with the address and the server's reason; hmctl adds the line number. An address that already exists is left alone and counted, never overwritten. An import that reset a password without saying so would lock 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. Who may make an administrator. admin_level is decided by who is asking. 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, 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. The configuration as one document 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 with 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, so two exports of an unchanged server are the same bytes. apply is a plan unless --force is added, 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. Not carried Why Passwords The server does not give them out. An account in the document that does not exist on the server is refused rather than created with a password nobody chose; add a password field for the accounts to be created. The secrets among the ini keys Listed as (set) or empty, and left out of the document. hmctl ini get <name> reads one by name. Certificates and the directories They are files and paths on one machine's disk. The journaling rules An address chosen for one server is almost never the right one for another. hmctl journalrule edits them. What lives under an account Application passwords, fetch accounts and folder permissions are the account holder's. The selector and key files of a domain whose DKIM key rotation is on The rotation moves them on its own schedule. Settings the environment manages In a container the value is the deployment's. The server refuses a change to such a setting with 409. Remote domain policies and alert rules The wiki's own addition: they have routes of their own that config does not read. What this server has changed from stock. hmctl config changed, or Get-HmChangedSetting in PowerShell, lists every setting whose value is not the one this version ships with, with the shipped value beside it. Settings: what is not stock has the route behind it, the other places to read the same list, what "default" means and who may see it. $ 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. A secret is named and never shown. A value that the file's [SettingsOverride] section is forcing says (forced from [SettingsOverride]), a value the environment sets says (set by the environment), and a name the server holds a value for and ships no default for says (none: this server does not read it). A difference is not an exit code here, unlike config diff, because every real server has changed something. Reports and country blocking from a shell hmctl report sections hmctl report summary --from 2026-09-01 --to 2026-09-15 hmctl report senders --domain example.com --top 25 hmctl report mailboxes --csv mailboxes.csv hmctl report traffic --csv - --csv fetches the server's own CSV with format=csv instead of building one from the JSON. Per-domain reports has the sections, their sources and parameters, and who may read them. Run hmctl report sections first. It says which sources are recording and what this server cannot answer. The four sections counted from the message trace are empty when the trace is switched off, which is the default, and that is not the same statement as "nothing happened". Without --csv, the section's own note is printed above the table. CountryBlocking.md shows the country policy, which the topic on allow and block lists and country blocking explains, set from both clients: hmctl settings get settings geoip_database_status hmctl settings set --set geoip_database_file=/var/lib/GeoIP/GeoLite2-Country.mmdb hmctl settings set --set country_policy_smtp=block:CN,RU hmctl domain update example.com --set active=true --set country_policy=allow:GB,IE Set-HmSetting -Property @{ geoip_database_file = '/var/lib/GeoIP/GeoLite2-Country.mmdb' country_policy_smtp = 'block:CN,RU' } (Get-HmSetting).geoip_database_status Set-HmDomain -Name example.com -Property @{ active = $true; country_policy = 'allow:GB,IE' } geoip_database_status is read-only. A settings change is all or nothing, so a policy the grammar refuses leaves every other key in the same request unapplied. What neither client does Neither reads or writes the message store. Mail is IMAP's business. Neither is a backup tool of its own. hmctl backup start, hmctl backup restore and hmctl backup passphrase ask the server; the archive is written, read and restored by the server. Neither sets the 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. On Windows the Control Panel does it too, but only where the service may write hMailServer.ini, as under LocalSystem: under NT SERVICE\hMailServer, a new 6.3.4 installation's account, its Administrator password card is refused with a sentence. Neither makes or carries the stored-secrets key. That is done on the server itself, never over the API. Neither can turn the REST listener on, since both need it. Neither sets what the environment sets in a container. No container image of 6.3.4 is published. Neither invents a field. A field that was not given is one the server decides. hmctl has no pending verb. SettingsApply.md notes that GET /api/v1/server/settings/pending is one request; hmctl api takes a method and a path. Where to read more The operator document: CommandLine.md, with a row for every resource and sections on DKIM rotation, bounces, complaints, DMARC reports, OpenPGP keys, the mailbox move and setting profiles. The wiki page: Command Line. For the examples above: ChangedFromStock.md, Reports.md and CountryBlocking.md. The public documentation: chapter 24, The REST API, which both clients speak, and 35.10, Administering a Linux server. On this forum: the Linux guide, The Control Deck, the 6.3.4 announcement, the REST API guide and, for what hmctl webhook manages, the send API and webhooks.
  • 0 Votes
    1 Posts
    124 Views
    ProgressiverobotP
    Written for 6.3.4. hMailServer 6.3.7 (17 October 2026) changes three things this guide relies on. A Control Panel signed in to a server on another computer no longer reads or writes its own computer's hMailServer.ini for the [LDAP] values, which it cannot read from the server, where it showed that file or the defaults as the server's; the sidebar is regrouped, and Ctrl+K takes every old page title to its page. With SMTP logging on, 6.3.6 and every earlier version that offered them wrote the bearer token of AUTH XOAUTH2 and AUTH OAUTHBEARER and the lines of SCRAM-SHA-256, OAUTHBEARER and GSSAPI into the SMTP log in the clear; 6.3.7 hides every line of a SASL exchange but the user name of AUTH LOGIN and the authentication identity of AUTH PLAIN. An account given the serveradmin role with a list of domains is, over COM as over REST, confined to those domains and no longer the server's administrator, so a script that runs the directory synchronisation's preview and apply as such an account needs the role given with no list. See the 6.3.7 announcement. hMailServer 6.3.8 (2 November 2026) signs a name in to an account only when it is the account's stored address, compared in lower case after a domain alias and the default domain, for the name an OAuth2 bearer token carries as for a password (work item 104), so a name that differs from the stored address in more than the case of the letters A to Z, or has white space at either end, may no longer sign in. On Windows an OAuth2PublicKeyFile whose path holds characters outside ASCII may be refused with HM7536 (one outside the code page, which worked up to 6.3.7, is refused), and then the key verifies no token: give the file, and the folders above it, an ASCII name before upgrading. See the 6.3.8 announcement. This topic covers three ways an organisation's directory or identity provider vouches for somebody on hMailServer 6.3.4's mail protocols: checking a password against Active Directory or another LDAP directory, creating mailboxes from that directory, and accepting OAuth2 bearer tokens. Know this first: 6.3.4 keeps settings in the database, but the [LDAP] section stays in hMailServer.ini on each server, with no REST route and no environment name; the server re-reads it within two seconds of a change, with no restart. The OAuth2 keys are ordinary [Settings] keys, stored like any other. Three mechanisms, three questions Mechanism The question it answers Control Panel page (Windows) LDAP directory authentication Is this the password of the directory user the account is linked to? Access & abuse protection → Directory authentication (LDAP) Directory synchronisation Which mailboxes should exist, according to the directory? Access & abuse protection → Directory synchronisation OAuth2 bearer tokens Did the identity provider sign this token, and which mailbox does it name? Access & abuse protection → Authentication All three are off by default, and an upgrade switches none of them on: the [LDAP] section is absent until you add it, and OAuth2Enabled defaults to 0. Where the [LDAP] section lives Windows. The Control Panel's page writes the hMailServer.INI of its own machine. Connected to another machine, its Save is disabled, although "the connection test still works with the values typed here". Work on the server, over Remote Desktop. Linux. No Control Panel and no COM. The file is /etc/hmailserver/hMailServer.ini, 0640 root:hmailserver: sudoedit /etc/hmailserver/hMailServer.ini. Not REST or hmctl. The REST ini routes and hmctl ini set handle [Settings] keys only. Per server. On a warm standby each node has its own [LDAP]; keep the two alike by hand. ServicePassword is a line of that file. It is not among the [Settings] secrets 6.3.4 seals under the stored-secrets key; the Control Panel's field for it is write-only. The sign-in keys Key Default What it does Enabled 0 The master switch Server empty The directory host; required Port 0 0 means 636 for LDAPS, 389 otherwise Security 2 2 LDAPS, 1 StartTLS, 0 plain; anything else is LDAPS VerifyCertificate 1 Check the directory's certificate AllowUnprotectedPassword 0 Permit a simple bind over a plain connection BindMethod 0 0 simple; 1 Negotiate (Kerberos or NTLM through Windows SSPI) SearchBase empty Required unless UserDnTemplate is set or the bind is Negotiate UserSearchFilter (&(objectCategory=person)(objectClass=user)(sAMAccountName=%u)) How the user's entry is found UserDnTemplate empty Bind directly, without searching ServiceUsername, ServiceDomain, ServicePassword empty What the search binds with TimeoutSeconds 10 Bounds every directory operation; 1 to 90 FallbackToWindowsLogon 0 Also try Windows LogonUser; Windows only LDAPS with VerifyCertificate=1 is the default and the recommended configuration, proven end to end against a Windows Server 2025 domain controller. Server must be the name in the directory's certificate: the same setup pointed at the controller's IP address, with the right password, is refused, and HM5920 reports a failure "unrelated to the credentials supplied". On Windows, put the issuing CA in LocalMachine\Root; when this was proven the service ran as LocalSystem, and a CurrentUser\Root import served it nothing. The bind needs no extra privilege under NT SERVICE\hMailServer, which a new 6.3.4 installation runs as. On Linux the certificate must chain to the system trust store. StartTLS (Security=1) connects on 389 and upgrades before anything is sent. Plain LDAP is labelled Unprotected LDAP - cleartext (lab networks only): there a simple bind is refused (HM5921) rather than sent, unless AllowUnprotectedPassword is 1. Simple or Negotiate. A simple bind sends the password to the directory, so it belongs on LDAPS or StartTLS. Negotiate, Windows only, never sends it; it worked on 389 against a controller that had no LDAPS certificate yet, and suits a mail server not joined to the domain. Search or template. A search binds with the service credentials, finds the user under SearchBase with UserSearchFilter, then binds as the user. It asks for two entries to see ambiguity: more than one match is a configuration fault and authenticates nobody. UserDnTemplate skips the search, so no service credential need be stored at all; with a simple bind, that is the configuration the feature was proven with. Linking an account to the directory The directory is asked only about linked accounts; others keep their own password. The webmail's sign-in shares the IMAP and POP3 logon, so it takes the directory password too. Control Panel: the account dialog's Windows directory tab, with the domain (ADDomain) and user name (ADUsername); Browse Active Directory searches the forest to fill them. Since 6.3.4 the tab has that name; it was called Directory, and the new Directory entry tab is the organisation's address book, unrelated to Active Directory. REST: PUT /api/v1/accounts/{address} with ad_enabled, ad_domain and ad_username. COM (Windows): IsAD, ADDomain and ADUsername. What a link changes. The account's stored password is no fallback: in the proof run, a linked account's local password was refused. Linked accounts bypass local hashing, so they are exempt from the minimum-hash policy and from password expiry, and cannot sign in with SCRAM-SHA-256; their clients use PLAIN or LOGIN over TLS. App passwords work for them: the directory holds the password, but an app password is local, which is what makes a second factor possible for a directory account at all. The person cannot change the password in the webmail (POST /api/v1/me/password answers 409), and Forgotten your password? cannot reset it, because this server does not hold it. Without LDAP, on Windows, a linked account is checked by LogonUser, which needs the mail server's host joined to the domain. On a host that is not joined, Windows answers every domain name, real or misspelt, as a wrong password, so the account can never sign in; HM5911 says so, naming the domain and the computer. An empty domain, . or the computer's own name means a local Windows account. Never leave the domain empty by accident: with [LDAP] Enabled off, an account linked with an empty domain and the user name administrator would accept the server's local Administrator password. FallbackToWindowsLogon never applies to a wrong password: a directory's rejection deliberately does not fall through to LogonUser, because that would double every failed attempt and trip a lockout policy on a single wrong password. Leave it off on a host that is not domain-joined. Setting it up on Windows For LDAPS or StartTLS, put the directory's issuing CA in LocalMachine\Root on the mail server. On the mail server, open Directory authentication (LDAP). Switch directory authentication on (Enabled); until then a linked account is checked by Windows LogonUser, not with these settings. Fill in the server, the security, the bind, and either the search or UserDnTemplate; the first card asks Is this configuration complete? Test the connection with a directory user name, its AD domain and its password. It tries the values typed on the page. Save. The server re-reads [LDAP] within two seconds. Link one account and sign in with the right password and a wrong one. Over POP3 the proof run got +OK Mailbox locked and ready and -ERR, and the log showed the bind and the rejection separately. Link the others by hand, or with directory synchronisation. Setting it up on Linux The only directory path is an LDAP simple bind over LDAPS or StartTLS, proven against OpenLDAP's slapd. Make sure the directory's certificate chains to the system trust store, and use the name it carries. Add the section with sudoedit /etc/hmailserver/hMailServer.ini, filling in the angle brackets. Without a UserSearchFilter line the search uses Active Directory's filter, (&(objectCategory=person)(objectClass=user)(sAMAccountName=%u)), which finds nobody in another directory such as OpenLDAP; there, write a filter that matches its user entries, with %u for the name: [LDAP] Enabled=1 Server=<the host name the directory's certificate carries> Security=2 VerifyCertificate=1 BindMethod=0 SearchBase=<the DN the users are under> UserSearchFilter=<that filter, unless the directory is Active Directory> ServiceUsername=<the search account> ServicePassword=<its password> Two seconds later the server has it. Link an account over REST (ad_enabled, ad_domain, ad_username) and sign in with the right password and a wrong one. Linux has no Negotiate (BindMethod=1 is refused as a directory failure, not a wrong password: HM6420, reported once), no LogonUser (a linked account while [LDAP] Enabled is off is refused with HM6421, which names the settings to set; FallbackToWindowsLogon refuses with HM6404), and no GSSAPI bind. When the directory cannot answer So that an outage does not read as users mistyping, infrastructure failures are reported apart from credential rejections, throttled per error and endpoint, and TimeoutSeconds bounds the wait. Code Meaning HM5920 The directory could not be used (a certificate not matching Server, say). Reported at High: unrelated to the credentials, and every account on that directory is affected HM5921 A simple bind would have sent the password unprotected, so the logon was refused first HM5911 Windows LogonUser: the host is not joined to the domain; at most once a minute per problem Directory synchronisation: preview, then apply Synchronisation reads the directory, works out which mailboxes should exist, and only when asked creates and updates them; it was verified end to end against a Windows Server 2025 domain controller. The planner writes nothing and the applier decides nothing, so a preview cannot promise what the apply would not do. Per-domain opt-in. A domain takes part only if its Active Directory domain name is set (ADDomainName in the domain dialog, ad_domain_name over REST), so a search base one level too high cannot provision into unrelated hosted domains. Nothing is ever deleted, account or message. The most it does is clear Active, only when asked, and never on a truncated or empty enumeration or for a domain in which nothing was seen. No password. A made account is marked IsAD and signs in through the directory from the start. No existing account claimed. An account that is not linked, or is linked to another AD domain, is reported, not changed. Paged. Active Directory answers a search beyond MaxPageSize (1000) with the first page and a success status, so the enumeration pages; SyncMaxUsers is the ceiling. [LDAP] key Default SyncFilter (&(objectClass=user)(objectCategory=person)(mail=*)(!(userAccountControl:1.2.840.113556.1.4.803:=2))) SyncMailAttribute mail; proxyAddresses works, preferring the capitalised SMTP: primary SyncUsernameAttribute, SyncDisplayNameAttribute sAMAccountName, displayName SyncMaxUsers 5000; at most 100000 SyncScheduleMinutes 0, off; 15 at least, 10080 (seven days) at most On Windows: Get authentication working first. Synchronisation binds as the service account, honouring BindMethod, so Negotiate works where there is no LDAPS certificate. Set the Active Directory domain name on each mail domain that takes part. Open Directory synchronisation, check the selection keys, choose the domain and whether absent accounts are marked inactive, and run Preview. It changes nothing. Read the report, including the existing accounts it reports and leaves alone. Apply. The button stays disabled until a preview of the same options has run. Behind the page are Settings.PreviewDirectorySync(DomainName, DisableMissing) and Settings.ApplyDirectorySync, for a server administrator and callable from a script. From a Control Panel on another machine the six selection boxes are read-only and, up to 6.3.6, show their defaults; since 6.3.7 the Control Panel no longer reads its own computer's file for the [LDAP] values, which it cannot read from the server, so it no longer shows them as the server's. Preview and Apply still run inside the server. The unattended schedule (SyncScheduleMinutes) runs at start and then every so many minutes. It creates and updates but never disables: unattended is where nobody reads the report that would show a mailbox about to be taken away. The schedule is one of the tasks the server registers as it starts, and only if SyncScheduleMinutes is set then, so restart the service once after first setting it. On Linux, with no COM, there is no preview or apply, and whether the schedule runs there is not documented: treat it as untested. OAuth2 bearer tokens on IMAP, POP3 and SMTP With OAuth2Enabled=1, SMTP, IMAP and POP3 offer XOAUTH2 and OAUTHBEARER (RFC 7628), by default only over TLS (OAuth2RequireTLS=1). The server verifies the token's signature itself, takes the login name from the claim OAuth2UsernameClaim names (email), and signs in to that mailbox; a user= the client asserts must match. The mailbox must already exist: a token never makes one. Key Default What it does OAuth2JwksUrl empty The provider's JWK Set; the token's kid picks the key. https, or plain http to loopback only OAuth2JwksCacheSeconds 3600 How long the set is kept OAuth2PublicKeyFile empty A PEM RSA or EC public key; the fallback OAuth2HmacSecret empty The HS256 key OAuth2AllowedAlgorithms RS256 HS256, RS256 and ES256 recognised; none never OAuth2Issuer, OAuth2Audience empty Expected iss and aud; empty turns the check off OAuth2IntrospectionUrl empty RFC 7662 endpoint asked whether a verified token is still active OAuth2IntrospectionClientId, OAuth2IntrospectionClientSecret empty Who this server introspects as, over HTTP Basic OAuth2IntrospectionCacheSeconds 300 Verdict reuse, never past the token's exp OAuth2IntrospectionFailOpen 0 1 accepts a token the endpoint cannot judge, logging each An unknown kid, which is what a rotation looks like, fetches the set again at once, at most once a minute; a failed fetch keeps the keys held. HttpProxy, when set, carries these fetches. Where to set them. Each applies when saved: on the Control Panel's Authentication page, with PUT /api/v1/settings/ini/{name} and the administrator password, with hmctl ini set, or with COM Settings.SetIniSetting. Not in hMailServer.ini: since 6.3.4 the database holds them, and a line edited in the file is not used; Settings: when a save takes effect explains the store. Choose the key source: OAuth2JwksUrl, so a rotation needs no copied file; or OAuth2PublicKeyFile; or OAuth2HmacSecret with HS256 allowed. Set OAuth2Issuer and OAuth2Audience to what the provider writes into its tokens. Left empty, neither is checked: any token the provider's keys signed is accepted, whatever client it was issued to. Optionally set introspection, so a revoked token stops working before it expires. Only then set OAuth2Enabled to 1. On Windows, read the OAuth2 / XOAUTH2 item of the Control Panel's External setup list. Why that order. Switched on without usable key material, the mechanisms are still advertised, every token fails, and failed bearer sign-ins feed the auto-ban, so a correct client can have its address banned. On Linux before the REST listener is up, hmailserver --set-setting stores a key; a secret is given by name alone and typed at a prompt. A value stored this way reaches the running server at its next save, reinitialise or restart. Set the issuer and audience too, as step 2 says, putting your provider's values in place of these examples: sudo hmailserver --set-setting OAuth2JwksUrl=https://idp.example.com/keys sudo hmailserver --set-setting OAuth2Issuer=https://idp.example.com sudo hmailserver --set-setting OAuth2Audience=hmailserver sudo hmailserver --set-setting OAuth2Enabled=1 sudo systemctl restart hmailserver Since 6.3.4: a bearer token meets the domain's and the account's country policy, as a password does (a stolen token used to sign in from a refused country); an SMTP session signed in with a token can send more than one message (the second used to be refused with 550 "Login credentials no longer valid"); and OAuth2HmacSecret and OAuth2IntrospectionClientSecret are sealed at rest once the installation has a stored-secrets key, which an upgrade does not make until an administrator runs --create-stored-secrets-key. Tested how. The regression fixtures play the identity provider on the loopback, to make it misbehave: RS256 and ES256, the JWK Set and its rotation, introspection and fail-open. A real provider's tokens are not yet part of them. This is inbound only. The OutboundOAuth2 keys and FetchOAuth2Hosts are the server's own token for relaying through and collecting from Microsoft 365; Routes, relays and smart hosts covers relaying with it. Where these end: SCIM, single sign-on and Kerberos The LDAP backend authenticates; SCIM provisions. Synchronisation reads the directory; SCIM is the other direction, Entra ID or Okta calling /scim/v2 under an API key whose role holds provision, and it removes accounts, which synchronisation never does. Single sign-on, OpenID Connect or SAML 2.0, opens the webmail and the Control Deck only, ending in POST /api/v1/session; the protocols' bearer tokens are a separate, older mechanism with the OAuth2 settings above, and single sign-on does not reach mail clients. On Windows, SASL GSSAPI (GssapiEnabled) lets a mail client sign in with a Kerberos ticket, mapped to the account linked to that directory user. SCIM provisioning and single sign-on sets those up; Signing in and Connecting a mail client show the person's side. What it does not do No Negotiate, Windows logon or GSSAPI bind on Linux. hMailServer on Linux lists the rest. No LDAP server here, and the webmail's organisation directory is typed in, not imported from Active Directory. Where to read more Wiki: Settings Reference sections 9.2 and 11, Capabilities and Configuration, Control Panel Reference, Installing on Linux, Security Hardening, Sign-in and Identity, Architecture Documents: SettingsApply.md, ServiceAccount.md, Scim.md, PasskeysAndSingleSignOn.md, TestLab.md The Roadmap rows on LDAP, which record how each part was proven On this forum: the stored-secrets key, auto-ban and the warm standby The public documentation's Creating accounts from Active Directory and Modern authentication Report a security flaw in any of this by SECURITY.md's routes: a confidential GitLab issue, or email to contact-project+hmailserver-hmailserver-86730559-issue-@incoming.gitlab.com, which GitLab files as one.