Repository navigation
APIs Reference
The REST API (listener, credentials, every route), the WebAdmin page, the web services listener (autoconfig, autodiscover, MTA-STS hosting, security.txt, ACME), the metrics and health listener, and the COM object model with verified script examples. File and line references point into the repository at the commit this page was written from (6.2.24, master of 4 September 2026). Sections headed Unconfirmed or Contradictions record what could not be verified or where documents disagreed, and are left in on purpose.
Scope: hMailServer 6.2.24 fork as checked out on 2026-09-04 (branch server-fixes-wave, HEAD 4d5da3f); passages corrected on 2026-09-08 cite the tree at 40ae9491d, a few commits before the v6.2.28 tag, except the HttpProxy ones, which cite the tag's own tree - there IniFileSettings.cpp:492 moves what follows it down by one line. Anything marked "new in 6.2.28" reached a published release on 8 September 2026 and not before. The write surface, the mailbox's folder and change routes and the administrator session were added on 9 and 10 September 2026 and cite RestApiSettings.cpp, RestApiRules.cpp, RestApiRoutes.cpp, RestApiCertificates.cpp, RestApiMailbox.cpp and RestApiPortal.cpp by name rather than by line, because those units were split out of RestApiServer.cpp as they were written; anything marked "new in 6.3.0" reached a published release on 10 September 2026 and not before.
Paths below are relative to hmailserver/source/ unless stated. RestApiServer.cpp = Server/Common/Util/RestApiServer.cpp,
WebServicesServer.cpp = Server/Common/Util/WebServicesServer.cpp, MetricsServer.cpp = Server/Common/Util/MetricsServer.cpp,
IniFileSettings.cpp = Server/Common/Application/IniFileSettings.cpp, Application.cpp = Server/Common/Application/Application.cpp,
hMailServer.idl = Server/hMailServer/hMailServer.idl. Legacy doc = hmailserver documentation/hmailserver-documentation.md.
Every fact is one line with a source. Anything I could not confirm is under "Unconfirmed" at the end.
Brought up to master 540347500 on 24 September 2026 (schema 6096): the REST route families added since the page's last pass (section 4, the second table), the credentials that came with them (sections 3b and 3d), the Control Deck's views (section 5), the web services routes (section 6b) and the COM members (section 8). And to 6.3.4 (27 September 2026, schema 6104), from 92b8f9f63, before the last batch the release carries: the twenty-four REST paths added after 540347500 (eDiscovery, mail in to a webhook, mailbox encryption at rest, rooms and equipment, the message store's measurement and the webmail's bulk action), the route counts, the Deck's two new views, the COM members and the event-script languages. What this page marks 6.3.4 is in that release and not in 6.3.3. Line numbers elsewhere are from the trees named above; the rows added on this date cite files and functions by name. The route counts are the OpenAPI document's, counted from the path literals in RestApi*.cpp as section 4 says. Brought up on 5 October 2026 to 6.3.6 (6 October 2026, schema 6108), for what 6.3.6 changes on this page. 6.3.7, the latest release (17 October 2026, schema 6108), is not yet reflected here. 6.3.5's three quarantine fields and four routes, and its COM members Quarantine.Bulk and QuarantinedMessage.HeloName to ReadPreview, are not yet written in.
-
There are FOUR separate HTTP(S) listeners, each with its own bind address and port, each disabled until its port is set to non-zero in
[Settings]of hMailServer.ini (source: Application.cpp:495-548). -
REST administration API:
RestApiPort(default 0 = off),RestApiBindAddress(default127.0.0.1),RestApiCertificateFile(default empty),RestApiPrivateKeyFile(default empty) (source: IniFileSettings.cpp:591-594). -
Prometheus metrics + health probes:
MetricsServerPort(default 0),MetricsServerBindAddress(default127.0.0.1),MetricsServerAuthToken(empty),MetricsServerAuthUsername(empty),MetricsServerAuthPassword(empty),MetricsServerCertificateFile(empty),MetricsServerPrivateKeyFile(empty),MetricsPerDomainEnabled(default 0) (source: IniFileSettings.cpp:426-437, 656). -
Public web services (MTA-STS hosting, autoconfig/autodiscover, security.txt, ACME challenges, CalDAV/CardDAV redirects):
WebServicesHttpPort(default 0),WebServicesHttpsPort(default 0),WebServicesBindAddress(default0.0.0.0),WebServicesCertificateFile(empty),WebServicesPrivateKeyFile(empty) (source: IniFileSettings.cpp:602-606). -
ManageSieve (not HTTP, listed for completeness of "extra listeners"):
ManageSieveServerPort(default 0),ManageSieveServerBindAddress(default127.0.0.1) (source: IniFileSettings.cpp:453-454; started at Application.cpp:539-545). -
Startup order after the mail servers are running: metrics listener, then OTel exporters, then REST, then web services, then ManageSieve (source: Application.cpp:495-548).
-
The REST API and the web services listeners run on
Server/Common/Util/HttpServer.cpp, an HTTP/1.1 server on Boost.Asio with its own io_context, a bounded pool of worker threads (4 for both), a connection cap enforced at accept before the TLS handshake (64 for REST, 128 for web services), keep-alive (the HTTP/1.1 default; an HTTP/1.0 client gets it only when it asks), at most 1000 requests per connection, and absolute deadlines rather than idle timeouts (source: HttpServer.cpp:4-40, 516-527, 759, 872, 1006-1010; RestApiServer.cpp:128-137, 647-656; WebServicesServer.cpp:45-49, 501-508). New in 6.2.28. The metrics listener is still a raw-socket, single-worker-thread server speaking HTTP/1.0 withConnection: closeon every response (source: MetricsServer.cpp:509, 761, 2295). -
A bind address of
::is made dual-stack (IPV6_V6ONLY cleared) on all three listeners; a specific IPv6 literal is IPv6-only. The REST and web-services listeners bind throughHttpServer::Listen, which translateslocalhostto127.0.0.1and parses the address withboost::asio::ip::make_address, logging "Invalid bind address" when that fails (source: HttpServer.cpp:683-733; MetricsServer.h:206-213). -
Control Panel: the REST, metrics, OTel, ManageSieve and Windows-event-log settings live on the page
Monitoring & troubleshooting > API & monitoring(page idapi); API keys have their own pageMonitoring & troubleshooting > REST API keys(idapikeys); the web-services settings live onConnections & protocols > Web services & autoconfiguration(idwebservices); ACME onTLS & certificates > Certificates (ACME)(idacme). The older pathsSettings > Network > API & monitoring,Settings > Network > Web services & autoconfigurationandSettings > Security > Certificates (ACME)are accepted as search aliases (source: Tools/ControlPanel/Services/NavigationMap.cs:262-268 aliases; groups and pages at 358, 427, 436, 580-594, 605-613). -
A fifth listener, not HTTP, since 6.3.4: the SNMP agent (UDP,
SnmpPort161), read-only, SNMPv3 at authPriv with SNMPv2c optional, off (SnmpEnabled) and answering nobody untilSnmpAllowedAddressesnames a manager. Its settings arehm_settingsrows, keyssnmp_*of/api/v1/settings, with no COM property; it serves the same counters as/metrics(source:Common/Util/SnmpAgent.h, RestApiSettings.cpp;hmailserver/docs/Snmp.md; Ports Reference 4.6). -
Since 6.3.4 (schema 6042), the port and bind keys above are read from the settings store in the database, not from the file.
hMailServer.INIkeeps a copy; a key the server has never stored is taken in from the file at the next start, a change to a stored key is reported as HM5804 and the stored value used, the line put back where the service may write the file (underNT SERVICE\hMailServer, to which the file is read-only, the edit stays in the file - The Service Account), and a[SettingsOverride]section is applied over the store at every start (HM5807). The Linux package'shMailServer.inishipsRestApiPort=0in[Settings], so on Linux the REST listener is turned on after the first start through[SettingsOverride]- see The REST API section 1 (source: IniSettingStore.cppSynchronize; IniFileSettings.cppLoadDatabaseSettings; platform/packaging/hMailServer.ini).
The four, side by side. Every row is from the sources named in the bullets above.
| REST API | Web services | Metrics and health | ManageSieve | |
|---|---|---|---|---|
| Port key | RestApiPort |
WebServicesHttpPort / WebServicesHttpsPort
|
MetricsServerPort |
ManageSieveServerPort |
| Default | 0 (off) | 0 / 0 (off) | 0 (off) | 0 (off) |
| Bind key, default |
RestApiBindAddress, 127.0.0.1
|
WebServicesBindAddress, 0.0.0.0
|
MetricsServerBindAddress, 127.0.0.1
|
ManageSieveServerBindAddress, 127.0.0.1
|
| Engine |
HttpServer (Boost.Asio, HTTP/1.1, keep-alive) |
HttpServer (same) |
raw sockets, HTTP/1.0, Connection: close on every response |
its own protocol, not HTTP |
| Worker threads | 4 | 4 | 1 | n/a |
| Connection cap | 64, at accept | 128, at accept | n/a | n/a |
| Request cap | 64 KB; 16 MB on two routes | 64 KB | n/a | n/a |
| Request deadline | 30 s; 300 s on those two routes | 15 s | n/a | n/a |
| Connection deadline | 300 s | 120 s | n/a | n/a |
| Authentication | administrator password, API key, or an account's own credential | none | optional Bearer and/or Basic on /metrics only; probes never |
SASL |
| TLS | mandatory off loopback; refuses to start without it | optional; the HTTPS listener stays down without a certificate | opt-in; both cert and key required, then the whole port is HTTPS | per TlsKeyExchangeGroups etc. |
| Refuses to start when | the administrator password is empty | — | — | — |
flowchart TD
START["Application::InitInstance, then StartServers"] --> MAIL["SMTP, POP3, IMAP listeners"]
MAIL --> M{"MetricsServerPort greater than zero?"}
M -- yes --> M1["MetricsServer on MetricsServerBindAddress"]
M -- no --> OT
M1 --> OT["OtelTracer, OtelMetricsExporter, OtelLogExporter - each a no-op without its own endpoint"]
OT --> R{"RestApiPort greater than zero?"}
R -- yes --> R1["RestApiServer, with the ACME certificate substituted when none is configured"]
R -- no --> W
R1 --> W["WebServicesServer::ReportUnreachableFeatures runs whatever the ports are"]
W --> W2{"WebServicesHttpPort or WebServicesHttpsPort greater than zero?"}
W2 -- yes --> W3["WebServicesServer"]
W2 -- no --> S
W3 --> S{"ManageSieveServerPort greater than zero?"}
S -- yes --> S1["ManageSieve listener"]
S -- no --> DONE["Running"]
S1 --> DONE
- The report at
Wis deliberately outside the port check: it exists to say "these features are enabled but nothing is listening", which is only ever true when both web-services ports are 0 — the shipped default (source: Application.cpp comment aboveReportUnreachableFeatures).
- The REST listener refuses to start when the administrator password (
[Security] AdministratorPassword) is empty; log line "RestApi: Refusing to start - the administrator password is not set." (source: RestApiServer.cpp:685-689; password read at IniFileSettings.cpp:97). - TLS is mandatory unless
RestApiBindAddressis exactly127.0.0.1,localhostor::1; otherwise the listener refuses to start with "TLS certificate is required unless bound to 127.0.0.1 or ::1. Set RestApiCertificateFile and RestApiPrivateKeyFile." (source: RestApiServer.cpp:699-706). - TLS is used when BOTH
RestApiCertificateFileandRestApiPrivateKeyFileare non-empty; when the certificate setting is empty,Application.cppsubstitutes the ACME certificate (<AcmeCertificateDirectory>\fullchain.pem+privkey.pem) if both files exist, logging "RestApi: Using the ACME certificate for HTTPS." (source: Application.cpp:510-533; RestApiServer.cpp:691). - The TLS context comes from the shared
SslContextInitializer::InitServer(same cipher list, protocol toggles, DH params andTlsKeyExchangeGroupsas the mail protocols), then a TLS 1.2 floor is applied on top (source: RestApiServer.cpp:709-770, floor at 762). -
localhostis translated to127.0.0.1before binding (source: RestApiServer.cpp:774). - Startup log line:
RestApi: Listening on <addr>:<port> (https)or(http, loopback only)(source: RestApiServer.cpp:808-810). - Request limits (HttpServer, new in 6.2.28): head + body capped at 64 KB -> 413
{"error":"request too large"}, refused from the declared Content-Length before the body is read;POST /api/v1/me/messagesandPOST /api/v1/me/draftsalone get a second cap of 16 MB and a 300 s request deadline; every other request has an absolute 30 s deadline from the moment the server starts waiting for it (the TLS handshake counts) to the last byte of the response, and a connection lives at most 300 s whatever it is doing - when either deadline passes the connection is closed without a response; any NUL byte in the head or body -> 400{"error":"malformed request"}; a chunked request body -> 411{"error":"length required"};Expect: 100-continueis answered once the declared length has passed the cap (source: RestApiServer.cpp:128-137, 647-656, 673-685, 6845-6856; HttpServer.h:64-101; HttpServer.cpp:221-242, 337-352, 362-380, 436-441, 499-513). - Per-credential rate limit: 200 requests per fixed 10-second window per credential identity (
administratororkey:<id>); over budget -> 429{"error":"too many requests"}withRetry-After: 10; refused requests count towards the window; the table holds at most 64 credentials (source: RestApiServer.cpp:158-171, 1163-1226, 2005-2013). - Failed authentication feeds the same per-IP auto-ban accounting as SMTP/IMAP/POP3 (
AccountLogon::RegisterFailedLoginwith label "REST API"); loopback peers (127.0.0.1,::1) are excluded; when the auto-ban trips, the address is additionally refused at accept() (before TLS, before any DB access) for 5 minutes, in an in-process set bounded at 256 addresses (source: RestApiServer.cpp:230-232, 1238-1340, 1344-1407). Since 6.3.6 a wrong administrator password also costs a growing delay, capped at ten seconds, before the next one from the same address is looked at - from the loopback address too, an IPv6 address counted by its/64, and for the password asked again at an administrator passkey's registration; a guess inside the delay is answered 429 withRetry-After. The delay is cleared by the right password and does not touch API keys, passkeys or single sign-on. At the auto-ban's limit an IPv6 address's whole/64is refused at accept() for the five minutes. An address that carries an IPv4 client's own -::ffff:a.b.c.d, the NAT64 and SIIT prefix64:ff9b::/96, Teredo's2001::/32- is counted and refused as that IPv4 address. Behind a reverse proxy on the same machine every browser shares one source, so one client's wrong guesses keep the password sign-in waiting for everyone while they last (item 35; CVE-2026-104659; DevicesAndSessions.md, The administrator password's delay). -
Which
Hostthe listener answers (6.3.6): the plain loopback port answers only a request whoseHostislocalhost, an IP address, the server's host name or the host of an outside address configured for the webmail, and refuses any other with 421 before authentication. A reverse proxy that forwards the browser's ownHostneeds that name configured first. A listener with its own certificate is unchanged (DevicesAndSessions.md, WhichHostthe listener answers). A response to a browser over HTTPS carriesStrict-Transport-Securityfor a year (never on the plain loopback path). The audit trail also records, since 6.3.6, creating or revoking an API key, enrolling or disabling the administrator's second factor, a read of another account's mail through an administrator's route, and a support session. - Every API response body is JSON (
Content-Type: application/json) and carriesCache-Control: no-store; the exceptions are the static pages/,/index.html,/portaland/portal.jsand the attachment downloadGET /api/v1/me/messages/<id>/attachments/<index>, which serve their own media types (all alsono-store). Status codes the HTTP layer knows: 200, 201, 202, 204, 301, 302, 304, 400, 401, 403, 404, 405, 409, 411, 413, 415, 429, 500, 501, 503 (and 100 forExpect: 100-continue); anything else is rewritten to 500 (source: RestApiServer.cpp:2297-2315, 2998, 6295-6305, 7549-7576; HttpServer.cpp:968-995). - 401 responses carry
WWW-Authenticate: Basic realm="hMailServer"and the body{"error":"authentication failed"}; the same 401 is returned for no credential, wrong password, unknown key, expired key and out-of-network key (deliberately indistinguishable). One exception: when the administrator password is right but a second factor is enrolled ([Security] AdministratorTotpSecret) and theX-hMailServer-OTPheader is missing or wrong, the 401 addsX-hMailServer-OTP: requiredand the body{"error":"authentication failed","second_factor":"required"}; a missing code is not counted towards the auto-ban, a wrong one is (source: RestApiServer.cpp:846-869, 877-917, 2318-2344; since 6.2.27). - Every request is wrapped in an OpenTelemetry server span (continuing an inbound
traceparent) that is a no-op unlessOtelEndpointis set (source: RestApiServer.cpp:1428-1441). - The JSON request parser is a minimal key-by-key extractor (keys are looked up independently, not as a document): string values with every RFC 8259 escape honoured (
\",\\,\/,\n,\r,\t,\b,\f,\uXXXXwith surrogate pairs, decoded to UTF-8), boolean values (GetJsonBoolValue_) and arrays of strings (GetJsonStringArray_) (source: RestApiServer.cpp:4190-4259, 8069-8200).
stateDiagram-v2
[*] --> Accepted: async_accept
Accepted --> Refused: accept_filter_ says the address is auto-banned
Accepted --> Refused: 64 connections already open
Refused --> [*]: socket closed, no response written
Accepted --> Handshaking: TLS configured
Accepted --> ReadingHead: plain HTTP
Handshaking --> ReadingHead: handshake done
Handshaking --> Closed: handshake failed, or the 30 s request deadline passed
ReadingHead --> ParsedHead: blank line found
ReadingHead --> Failed413: buffer filled with no blank line
ParsedHead --> Failed400: NUL in the head, bad request line, header without a colon, Content-Length not 1 to 9 digits
ParsedHead --> Failed413: head over 64 KB, or head plus declared body over the cap
ParsedHead --> Continue100: client sent Expect 100-continue and the length passed the cap
Continue100 --> ReadingBody
ParsedHead --> ReadingBody
ReadingBody --> ParsedBody
ParsedBody --> Failed400: NUL in the body
ParsedBody --> Failed411: a Transfer-Encoding header - no declared length to hold against the cap
ParsedBody --> InHandler: keep_alive_ decided from the version and the Connection header
InHandler --> Writing: HttpResponse
InHandler --> Writing: the handler threw, so 500 internal error and close
Failed400 --> Writing
Failed411 --> Writing
Failed413 --> Writing
Writing --> ReadingHead: keep-alive, and fewer than 1000 requests so far
Writing --> Closed: close requested, a refusal, the 1000th request, or the server is stopping
ReadingHead --> Closed: EOF, the 30 s request deadline, or the 300 s connection deadline
Closed --> [*]
- Both deadlines are absolute, not idle timeouts: the request timer runs from the moment the server starts waiting for a request (on a kept-alive connection, the moment the previous response was written) to the last byte of the response, handler time included, and the connection timer runs from accept. A client sending one byte at a time cannot extend either (source: HttpServer.h:64-101 comment;
ArmRequestTimer_,ArmConnectionTimer_). - The large-request exception is granted after the head has been read and has passed the 64 KB cap on its own, and only for
POST /api/v1/me/messagesandPOST /api/v1/me/drafts; it raises the body cap to 16 MB and re-arms the request timer at 300 s (source:HttpConnection::OnHead_;RestApiServer::IsLargeRequest_). - A
Fail_response always closes the connection: after a malformed or oversized request what is on the wire can no longer be framed (source:HttpConnection::Fail_). - An exception escaping a worker's completion handler is caught at the top of
RunWorker_, logged as "An exception escaped a worker thread. The worker continues.", and the worker goes back to work; an exception escaping the handler becomes a 500 and closes the connection (source:HttpServer::RunWorker_,HttpConnection::OnBody_).
| Ceiling | REST | Web services | Symptom at the client | Enforced in |
|---|---|---|---|---|
| Head + body | 64 KB | 64 KB | 413 {"error":"request too large"}, then the connection closes |
OnHead_ |
| Head + body, two routes | 16 MB | n/a | as above beyond it | IsLargeRequest_ |
| Request deadline | 30 s (300 s on those two) | 15 s | connection closed with no response at all | ArmRequestTimer_ |
| Connection lifetime | 300 s | 120 s | connection closed | ArmConnectionTimer_ |
| Requests per connection | 1000 | 1000 | the 1000th response carries Connection: close
|
OnBody_ |
| Concurrent connections | 64 | 128 | connection closed at accept; logged on the 1st and every 1000th refusal |
Track_, Accept_
|
| Requests per credential | 200 per fixed 10 s window | n/a | 429 {"error":"too many requests"} with Retry-After: 10
|
IsWithinRequestRate_ |
| Credentials tracked | 64 | n/a | — | MaxRateLimitedCredentials |
| Auto-banned addresses held | 256, 5 minutes each (an IPv6 /64 as one, 6.3.6) |
n/a | connection closed at accept, before TLS and before any database access | IsRefusedAddress_ |
- The rate window is fixed rather than sliding, so
Retry-After: 10is exact — a caller that waits it out is certainly inside a new window — at the cost of allowing up to twice the budget across a window boundary (source: RestApiServer.cpp comment aboveMaxRequestsPerWindowPerCredential). - The refused-address window is a ceiling, not a rolling one: while an address is refused its connections are closed before authentication, so no further failure is registered for it and no later deadline can be written (source: RestApiServer.cpp comment above
RefusedAddressMinutes).
-
Authorization: Basic base64("Administrator:<password>"): the user nameadministrator(case-insensitive) is validated against[Security] AdministratorPasswordviaCrypt::Validate, so it works whether the ini holds a hash or plain text. Any other Basic user name is treated as an account's own credential (address and password, or an app password) and authenticated throughAccountLogon::Logon- the same path, lockout and auto-ban as an IMAP logon; an inactive account is refused; the two are told apart before either password is tried, so an account is never tested against the administrator password nor the reverse (source: RestApiServer.cpp:813-846, 877-917, 4323-4351). The account path is new in 6.2.28; see 3d. - The administrator credential is full authority: not read-only, not domain-restricted, and it is the ONLY credential that can list/create/revoke API keys (source: RestApiServer.cpp:1049-1057, 1843-1859).
- A rejected Basic credential logs "REST API: administrator authentication failed." and registers an auth failure; presenting no credential at all is not logged (source: RestApiServer.cpp:1059-1063).
- Verified example (tests use this exact form):
curl -u Administrator:<password> http://127.0.0.1:9104/api/v1/status- regression fixtureRestApiApiKeysruns the listener on loopback port 9104 with admin passwordtestarover plain HTTP (source: hmailserver/test/RegressionTests/API/RestApiApiKeys.cs:58-64).
-
Authorization: Bearer hmapi_<64 lower-case hex chars>; a token ishmapi_+ 32 random bytes (RAND_bytes) as hex; the key id is 8 random bytes as 16 hex chars (source: RestApiServer.cpp:93-103, 2474-2478). - Bearer is tried first when present; a token with the wrong length/prefix/alphabet is rejected before the store is read (source: RestApiServer.cpp:1023-1041, 1095-1109).
- Only the SHA-256 (unsalted, lower-case hex) of a token is stored; comparison is constant-time over every stored record (source: RestApiServer.cpp:2058-2085, 1114-1125).
- Key store file:
hMailServerApiKeys.iniin the same directory as hMailServer.ini; one[Key.<id>]section per key withLabel,Hash,Expires(YYYY-MM-DD HH:MM:SS local),AllowedFrom,Scope,Domains(source: RestApiServer.cpp:2017-2055). - The store is re-read on EVERY authentication attempt (parsed from bytes, not the cached profile API), so creating, revoking, hand-editing or deleting the file takes effect on the next request with no restart (source: RestApiServer.cpp:2075-2085, 2098-2105).
- Fail-closed rules: a record whose
Hashis not 64 lower-case hex is ignored (logged as "RestApi: Ignoring a record ... with no usable Hash value"); a missing/unparseableExpirescounts as expired;Scopeis read-only unless it is the literalfull(case-insensitive); anAllowedFromthat cannot be parsed refuses every request;Domainsentries are trimmed/lower-cased, empty list = every domain (source: RestApiServer.cpp:2116-2135, 2189-2232, 2238-2254, 2257-2283). -
AllowedFromforms: single address,lower-upperrange, or CIDR (IPv4 prefix <= 32, IPv6 <= 128); families must match the peer (source: RestApiServer.cpp:317-431, 2257-2283). Since 6.3.6 an address is four decimal parts, or IPv6 as RFC 4291 writes it, on Windows as on Linux: a source restriction written in an older form (192.168.1,0x7f.1, a leading zero) is one the server cannot read, so the key is refused from every address; the Control Panel's API keys page reads an address as the server does (item 42). - Expired or out-of-network keys are logged by label ("was presented after it expired" / "outside its allowed source") and answered 401 (source: RestApiServer.cpp:1128-1150).
- Default lifetime when a create request names no
expires: 90 days; there is no "never expires" option (source: RestApiServer.cpp:105-108, 2431-2440). - Default scope when a create request names no
scope:readonly(source: RestApiServer.cpp:2358-2370). - Scope enforcement: read-only keys are refused (403
{"error":"this api key is read-only"}) on the mutating route kinds: create/revoke key, create/delete account, queue retry/delete, quarantine release/delete, IP range create/delete, distribution list create/delete, backup start, archive hold/release, and (new in 6.2.28) update check/download/install (source: RestApiServer.cpp:2085-2135, 2198-2202). Decided by route kind, not by HTTP method. - Domain enforcement: a key with a
Domainslist may only list/create accounts, list aliases, list/create distribution lists and read DKIM in those domains, may only delete an account or a list whose address's domain is in the list (403 "this api key is not permitted for that domain"), sees only its domains inGET /api/v1/domainsandGET /api/v1/srv, must name one of its domains onGET /api/v1/archive(checked in the handler), and is refused with 403 the queue routes ("...the delivery queue is server-wide"), the quarantine routes ("...the quarantine is server-wide") and the IP range, certificate, rules, logs, backup, settings and (new in 6.2.28) update routes ("...that resource is server-wide") (source: RestApiServer.cpp:2157-2295, 4261-4266). - Any API key (any scope) presented to
/api/v1/apikeys*(including unsupported verbs under that prefix) gets 401, not 403, so a key cannot learn anything about key management (source: RestApiServer.cpp:1779-1795, 1851-1859). -
6.3.4, a key also carries
Role- the administrator role it holds (schema 6047), which decides what it reaches as an account's role does - and the send API'sSendFrom,SendPerMinuteandSendPerDay(batch 31), all written beforeHash. A key of any scope is answered 401 on the routes that answer to the administrator password alone (IsApiKeyRoute_): the key store, the ini routes,/api/v1/settings/changed,/api/v1/server/settings/pending, the audit trail and the alerts, the webhooks, the compromised-account review, an account's export, the address erasure and the second factor's enrolment. A key is the only credential ofPOST /api/v1/send(naming a sender) and of/scim/v2(holdingprovision) (source: RestApiServer.cppIsApiKeyRoute_,Authorize_,HandleCreateApiKey_; hmailserver/docs/Roles.md, SendApi.md, Scim.md).
- The Control Panel page "REST API keys" reads, creates and revokes keys by editing
hMailServerApiKeys.inidirectly (no server round trip), so it works while the REST listener is off; the store path is derived from hMailServer.INI's location and is null (page explains keys are server-local) when the panel is not on the server (source: Tools/ControlPanel/Services/ApiKeyStore.cs:74-96, 128-146; Views/ApiKeysView.cs:17-38, 357-372). - The panel mirrors the server constants: prefix
hmapi_, 32 secret bytes, 8 id bytes, 90-day default, 64-char label cap, SHA-256 over ASCII, Hash written last (source: ApiKeyStore.cs:99-125, 341-352, 407-411). - The create dialog defaults to read-only; the expiry date picker defaults to today + 90 days, must be at least tomorrow, and the stored expiry is the END of the chosen day (23:59:59) (source: Views/ApiKeysView.cs:614-676, 693, 725).
- The new token is shown once in a card with "Copy to clipboard" and is cleared when the page is left (source: Views/ApiKeysView.cs:99-103, 174-232).
- The panel's preamble for a new store differs in wording from the server's (mentions the Control Panel page as a revoke route) but the field semantics are identical (source: ApiKeyStore.cs:587-618 vs RestApiServer.cpp:2493-2521).
- New in 6.2.28. An account presents
Authorization: Basic base64("<address>:<password>")(an app password works too); it is authenticated throughAccountLogon::Logon- the IMAP logon path, with the per-name lockout and the auto-ban - and reaches only the routes under/api/v1/meand/api/v1/session; the administrator password and API keys are refused there with 403, and an account is refused everywhere else with 403 (source: RestApiServer.cpp:813-829, 2157-2184, 4323-4351). -
POST /api/v1/sessionwith the account's password answers 201 andSet-Cookie: hmailsession=<64 hex>; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200(plus; Securewhen the listener speaks TLS). The value is 32 random bytes; only its SHA-256 is kept, in memory, compared constant-time. A session ends after 30 minutes idle or 12 hours after it began, whichever comes first; at most 1,000 sessions are held, and since 6.3.6 at most fifty for one account or the administrator: a fifty-first sign-in of an account ends that account's least recently used session; a full table gives up a session of the holder with the most (among holders tied, of the domain holding the most); the administrator's sessions do not count towards the thousand and nobody else's sign-in takes one; a support session counts against the credential that opened it (item 32; up to 6.3.5 a full table dropped its least recently used session whoever held it). A session cannot be started with another session (403);DELETE /api/v1/sessionends it and clears the cookie; a password change ends the account's other sessions; the account is re-read on every request and a deactivated or deleted account's sessions are dropped; stopping the listener clears every session (source: RestApiServer.cpp:327-331, 4584-4785, 723). - CSRF: a request authenticated by the cookie that is not GET or HEAD must carry
X-Requested-With: hMailServer, otherwise 403{"error":"a request that changes something must carry X-Requested-With: hMailServer when it is authenticated by a session cookie"}(source: RestApiServer.cpp:1298-1304). - The rate limit of section 2 applies to accounts as identity
account:<address>(source: RestApiServer.cpp:4350). -
6.3.4: an account that holds an administrator role (schema 6047) is not confined to
/api/v1/me- it reaches every administrative route its role's permissions allow, in the domains its role reaches, and a route it may not call answers 403 naming the role and the permission it lacks (Roles.md). A browser session is also minted from a passkey assertion or a one-use OpenID Connect or SAML ticket presented toPOST /api/v1/session, all under the same session policy;POST /api/v1/session?bearer=1answers an account's session as a token in the body, for a second account in the same page, and never for the administrator; a one-time recovery code travels inX-hMailServer-Recovery-Codewherever an authenticator's code is asked for; and the idle and lifetime ceilings, one session per device and binding to the address are a policy of the server, a domain or an account (Session*settings, schema 6053) (source: RestApiServer.cppAuthenticate_,HandleSessionCreate_; hmailserver/docs/PasskeysAndSingleSignOn.md, SecondAccount.md, AccountRecovery.md, DevicesAndSessions.md).
Every box is a function in the sources named in sections 2 and 3; the order is the order
ProcessRequest_ runs them in, and it is the order that matters — the rate budget is
spent after authentication so that it belongs to the credential rather than to a source
address, and before routing so that being over it costs nothing but one comparison.
sequenceDiagram
autonumber
participant CL as Client
participant AC as HttpServer::Accept_
participant CN as HttpConnection
participant PR as RestApiServer::ProcessRequest_
participant AU as Authenticate_
participant KS as hMailServerApiKeys.ini
participant AZ as Authorize_
participant HD as Handler
CL->>AC: TCP connect
AC->>AC: accept_filter_ - IsRefusedAddress_
AC->>CN: Track_ then Start
CN->>CN: TLS handshake if configured, then read head and body
CN->>PR: HttpRequest.raw and HttpRequest.peer
PR->>PR: split the request line, strip the query string
PR->>PR: OtelSpanScope - no-op unless OtelEndpoint is set
alt GET / or /index.html or /portal or /portal.js
PR-->>CL: static page, unauthenticated
else everything else
PR->>AU: Authenticate_
alt Authorization: Bearer
AU->>AU: syntactic check - hmapi_ plus 64 lower-case hex
AU->>KS: LoadKeys_ reads the file bytes, every request
KS-->>AU: records
AU->>AU: constant-time compare of SHA-256 over every record
AU->>AU: expiry, AllowedFrom, Scope, Domains - all fail closed
else Authorization: Basic, user is not administrator
AU->>AU: AuthenticateAccount_ via AccountLogon::Logon
else Authorization: Basic, user is administrator
AU->>AU: Crypt::Validate, then the TOTP code if one is enrolled
else no Authorization header
AU->>AU: AuthenticateSession_ reads the hmailsession cookie
end
AU-->>PR: Caller - result, identity, read_only, domains, account, via_session
opt cookie-authenticated and not GET or HEAD
PR->>PR: X-Requested-With must be hMailServer, else 403
end
alt authentication failed
PR-->>CL: 401 with WWW-Authenticate Basic
else
PR->>PR: IsWithinRequestRate_ - 200 per 10 s for this identity
PR->>PR: ParseRoute_ - method plus path becomes a RouteKind
PR->>AZ: Authorize_
AZ-->>PR: Allowed, Forbidden with a reason, or Unauthenticated
alt Allowed
PR->>HD: the one handler for that RouteKind
HD-->>PR: HttpResponse
else Forbidden
PR-->>CL: 403 and one line in the application log naming the credential
else Unauthenticated
PR-->>CL: 401
end
end
end
PR-->>CN: status, application/json, Cache-Control no-store
CN-->>CL: response
- The dispatch at the bottom of
ProcessRequest_is the only way a handler is reached, andAuthorize_sits above it: "an endpoint that forgets to check something cannot exist if no endpoint does the checking" (source: RestApiServer.h comment aboveRouteKind).
flowchart TD
A["Authorize_ receives the Caller and the Route"] --> B{"Is the route a self-service route?"}
B -- yes --> C{"Is the caller an account?"}
C -- yes --> OK1["Allowed"]
C -- no --> F1["403 - this endpoint answers to an account's own credentials, not to the administrator password or an api key"]
B -- no --> D{"Is the caller an account?"}
D -- yes --> F2["403 - an account's credentials reach only the account's own endpoints under /api/v1/me"]
D -- no --> E{"Is the caller the administrator?"}
E -- yes --> OK2["Allowed - full authority, nothing below narrows it"]
E -- no --> G{"Is the route under /api/v1/apikeys, including an unsupported verb?"}
G -- yes --> U["401, deliberately not 403"]
G -- no --> H{"read_only and IsMutatingRoute_?"}
H -- yes --> F3["403 - this api key is read-only"]
H -- no --> I{"Is the key's Domains list empty?"}
I -- yes --> OK3["Allowed"]
I -- no --> J{"Queue or quarantine route?"}
J -- yes --> F4["403 - the delivery queue is server-wide / the quarantine is server-wide"]
J -- no --> K{"IP ranges, certificates, rules, logs, backup, settings or update?"}
K -- yes --> F5["403 - that resource is server-wide"]
K -- no --> L{"Does the route carry a target domain?"}
L -- no --> OK4["Allowed - status, tlsa, srv and the domain list filter inside their handlers"]
L -- yes --> M{"IsDomainAllowed_?"}
M -- yes --> OK5["Allowed"]
M -- no --> F6["403 - this api key is not permitted for that domain"]
- Target domain by route kind: the path segment for
RouteAccountList,RouteAccountCreate,RouteAliasList,RouteListList,RouteListCreateandRouteDkimGet;StringParser::ExtractDomainof the address forRouteAccountDeleteandRouteListDelete; nothing for everything else (source:Authorize_). -
RouteAccountDeleteis the reason the mechanism exists: without it a key issued for one domain could delete a mailbox in another by editing one path segment (source: RestApiServer.cpp comment inAuthorize_). -
IsMutatingRoute_decides by kind and not by HTTP method, "so a route that changed something under a GET could not slip past a read-only key by being spelled harmlessly" (source: RestApiServer.cpp comment aboveIsMutatingRoute_). Its members are listed in section 4's "RO ok = no" rows.
sequenceDiagram
autonumber
participant BR as Browser
participant PR as RestApiServer
participant AL as AccountLogon::Logon
participant PA as PersistentAccount
participant TB as browser_sessions
BR->>PR: GET /portal
PR-->>BR: HandlePortalPage_ - static HTML, CSP script-src 'self', frame-ancestors 'none'
BR->>PR: GET /portal.js
PR-->>BR: HandlePortalScript_ - static script, text/javascript
BR->>PR: POST /api/v1/session with Basic address:password
PR->>PR: Authenticate_ sees a Basic user that is not administrator
PR->>AL: Logon - password schemes, app passwords, per-name lockout, auto-ban, last-logon stamp
AL-->>PR: account, or nothing
alt no account, or the account is inactive
PR->>PR: RegisterAuthenticationFailure_
PR-->>BR: 401 authentication failed
else accepted
PR->>PR: HandleSessionCreate_ - refuse if this request already carried a session
PR->>PR: RAND_bytes 32, hex, then SHA-256 of the hex
PR->>TB: drop expired rows; a fifty-first of this holder's ends its least recently used; a full table takes from the largest holder (6.3.6)
PR->>TB: store token hash plus account id plus created_at plus last_seen_at
PR-->>BR: 201 address, idle_seconds 1800, lifetime_seconds 43200
Note over PR,BR: Set-Cookie hmailsession is 64 hex characters, with Path=/, HttpOnly, SameSite=Strict, Max-Age=43200, and Secure when the listener speaks TLS
end
BR->>PR: GET /api/v1/me with the cookie
PR->>TB: constant-time compare, then check 30 min idle and 12 h absolute
TB-->>PR: account id, last_seen_at refreshed
PR->>PA: ReadObject - fresh on every request
alt deleted or deactivated since sign-in
PA-->>PR: nothing, or Active false
PR->>TB: RevokeSessionsForAccount_
PR-->>BR: 401
else still there
PR-->>BR: 200
end
BR->>PR: POST /api/v1/me/password with the cookie and X-Requested-With
PR->>TB: RevokeSessionsForAccount_ keeping this one
PR-->>BR: 200 changed
BR->>PR: DELETE /api/v1/session
PR->>TB: erase this row
PR-->>BR: 200 ended, plus a clearing Set-Cookie with Max-Age 0
- The account is read from the database on every cookie-authenticated request rather than remembered from sign-in, so a deactivated or deleted account is refused from its next request and its sessions are dropped (source:
AuthenticateSession_). -
RestApiServer::Stopclears the refused-address set, the request-rate table and every browser session, so a restarted listener holds none of them (source:RestApiServer::Stop).
Routing is exact-match on method and path after stripping any query string; StartsWith/EndsWith comparisons are case-insensitive, == is case-sensitive (source: RestApiServer.cpp:1418-1425, 1580-1588). "Admin" = administrator password only. "RO ok" = a read-only key may call it. "Dom" = how a domain-restricted key is treated. "Account" = an account's own credential (Basic <address>:<password> or the hmailsession cookie; the administrator password or an API key gets 403) - those routes, the update routes and the portal are new in 6.2.28; the ipranges, lists, dkim, certificates, rules, logs, backup, settings, archive and metrics/history routes are in 6.2.27. New in 6.3.0 is the write surface, and it is the reason to read the method column rather than the path: the 6.2.27 routes named above could only be read. PUT /api/v1/settings and its anti-spam and logging groups, POST/PUT/DELETE for a global rule, an SMTP route, a certificate and a listener, POST/DELETE for an alias, PUT /api/v1/accounts/{address}, and POST /api/v1/server/reinitialize all arrived in 6.3.0, as did the mailbox's own POST/PUT/DELETE /api/v1/me/folders, GET /api/v1/me/changes, and a POST /api/v1/session that mints a session for the administrator rather than only for an account. A row below with no release named in it is one of these. A row marked 6.3.4 arrived after 6.3.3 and is in 6.3.4 ("unreleased until 6.3.3" means it shipped in 6.3.3); 6.3.4 (27 September 2026, schema 6104) was the latest release when these markers were written, and 6.3.3 was at schema 6040.
| Method | Path | Auth | RO ok | Dom | Returns / notes | Source (RestApiServer.cpp) |
|---|---|---|---|---|---|---|
| GET |
/ or /index.html
|
none | - | - | WebAdmin page (text/html); see section 5 |
1446-1447, 2615-2640 |
| GET | /api/v1/status |
Basic or Bearer | yes | allowed |
{"version","state","processedMessages","spamMessages","virusesRemoved","sessions":{"smtp","imap","pop3"}}; state 0 Unknown, 1 Stopped, 2 Starting, 3 Running, 4 Stopping; since 13 September 2026 (unreleased until 6.3.3) also "build":{"script_engine","argon2id","post_quantum_key_exchange"} - what this build can do, settled when it was compiled, so a client asks instead of finding out from an error |
1607-1611, 2642-2662; states Server/Common/Util/ServerStatus.h:18-22 |
| GET | /api/v1/domains |
Basic or Bearer | yes | filtered | JSON array [{"name","active"}]
|
1613-1617, 2664-2707 |
| GET | /api/v1/domains/<name>/accounts |
Basic or Bearer | yes | must match | array [{"address","active"}]; 404 domain not found
|
1620-1637, 2709-2749 |
| POST | /api/v1/domains/<name>/accounts |
Basic or full key | no | must match | body {"address","password"} (only these two fields are read); 201 {"address","created":true}; 400 if fields missing, address not in domain, or PreSaveLimitationsCheck fails (message passed through); 404 unknown domain; 409 account already exists; creates the INBOX; password hashed with PreferredHashAlgorithm
|
1638-1643, 2751-2869 |
| DELETE | /api/v1/accounts/<address> |
Basic or full key | no | address's domain must match | 200 {"deleted":true}; 404 account not found
|
1646-1658, 2871-2885 |
| PUT | /api/v1/accounts/{address} |
Basic or Bearer (full) | no | must match; never admin_level "server", never a server administrator's account |
any subset of active, password, max_size_mb, first_name, last_name, forward_enabled, forward_address, forward_keep_original, signature_enabled, signature_plain_text, signature_html, admin_level (user, domain, server); 200 the account; 400 an unknown field, a wrong type, forwarding without an address or to itself, an empty password, the policy's sentence; 403 as the scope says; 404; 409 a password reused |
RestApiRoutes.cpp |
| GET | /api/v1/queue |
Basic or Bearer | yes | REFUSED 403 |
{"count","messages":[{"id","created","from","recipients","next_try","locked","tries"}]} (same query as COM Status.UndeliveredMessages) |
1660-1664, 2887-2930 |
| POST | /api/v1/queue/<id>/retry |
Basic or full key | no | REFUSED 403 | 200 {"retried":true} after DeliveryQueue::ResetDeliveryTime + StartDelivery; 404 unless the id is a Delivering (type 1) or ETRN-held (type 3) message |
1667-1687, 2932-2970 |
| DELETE | /api/v1/queue/<id> |
Basic or full key | no | REFUSED 403 | 200 {"deleted":true}; 404 as above |
1689-1707, 2972-2983 |
| GET | /api/v1/tlsa |
Basic or Bearer | yes | allowed |
{"host","count","records":[{"certificate","spki_sha256","record":"_25._tcp.<host>. IN TLSA 3 1 1 <hex>"}]} for every configured SSL certificate file, falling back to the ACME fullchain.pem; host placeholder <your-mx-hostname> when no host name is configured |
1710-1714, 3141-3195 |
| GET | /api/v1/srv |
Basic or Bearer | yes | filtered |
{"target","count","services":[{"service","priority","weight","port"}],"records":[{"domain","service","record"}]}; services _imaps/_imap/_pop3s/_pop3/_submissions/_submission._tcp derived from enabled, non-loopback ports (port 25 never advertised), plus _autodiscover._tcp when the web-services HTTPS listener is RUNNING and AutoconfigEnabled=1; one record set per active domain; target = AutoconfigClientHost else host name else <your-mail-hostname>
|
1716-1720, 3197-3437 |
| GET | /api/v1/quarantine |
Basic or Bearer | yes | REFUSED 403 | array [{"id","sender","recipients","subject","reason","score","size","created"}], newest 1000 |
1722-1727, 2985-3018 |
| POST | /api/v1/quarantine/<id>/release |
Basic or full key | no | REFUSED 403 | 200 {"released":true}; 404 quarantined message not found; 500 with the store's error text |
1729-1745, 3020-3039 |
| DELETE | /api/v1/quarantine/<id> |
Basic or full key | no | REFUSED 403 | 200 {"deleted":true}; 404 |
1747-1757, 3041-3052 |
| GET | /api/v1/domains/<name>/aliases |
Basic or Bearer | yes | must match | array [{"name","value","active"}]; 404 unknown domain |
1760-1772, 3054-3095 |
| POST | /api/v1/domains/{domain}/aliases |
Basic or Bearer (full) | no | must match | body name (an address in the domain), value (an address), active (default true); 201 the entry; 400 the limitation check's sentence; 404 the domain; 409 exists. The alias delivers on the next message |
RestApiRoutes.cpp |
| DELETE | /api/v1/aliases/{address} |
Basic or Bearer (full) | no | must match | 200 {"deleted":true}; 404 |
RestApiRoutes.cpp |
| GET | /api/v1/openapi.json |
Basic or Bearer | yes | allowed | a static OpenAPI 3.0.3 document (see contradictions - it is not fully accurate) | 1775-1776, 3097-3139 |
| GET | /api/v1/apikeys |
Admin | - | - |
{"count","keys":[{"id","label","scope","domains","expires","allowed_from","expired"}]}; hashes never returned |
1590-1605, 2285-2324 |
| POST | /api/v1/apikeys |
Admin | - | - | body {"label"(required, <=64 chars, no control chars),"scope"("readonly" or "full", default readonly),"domains"(comma list, validated with IsValidDomainName),"expires"(YYYY-MM-DD HH:MM:SS, default +90 days, must be future),"allowed_from"}; 201 {"id","label","scope","domains","expires","allowed_from","key"} - key is the clear-text token, shown once |
2326-2569 |
| DELETE | /api/v1/apikeys/<id> |
Admin | - | - | 200 {"revoked":true}; 404 api key not found (id must be 16 lower-case hex) |
2571-2613 |
| GET | /api/v1/metrics/history?metric=<name>&range=24h|7d|30d |
Basic or Bearer | yes | allowed |
{"metric","known","enabled","retention_days","minutes_back","bucket_minutes","samples":[{"time","value"}]} - one metric averaged per minute / ten minutes / hour; 400 {"error":"unknown metric","metrics":[...]} for an unknown name, 400 unknown range: use 24h, 7d or 30d; empty when MetricsHistoryDays is 0 |
1847-1851, 7769-7815 |
| GET | /api/v1/ipranges |
Basic or Bearer | yes | REFUSED 403 | array [{"id","name","lower","upper","priority", ...the permission booleans}]
|
1930-1937, 3465-3517 |
| POST | /api/v1/ipranges |
Basic or full key | no | REFUSED 403 | body {"name","lower","upper","priority", permission booleans}; 201 {"id"}; 400 name, lower and upper are required / lower and upper must be IP addresses / the save error |
1930-1937, 3520-3596 |
| DELETE | /api/v1/ipranges/<id> |
Basic or full key | no | REFUSED 403 | 200 {"deleted":true}; 404 ip range not found
|
1939-1948, 3599-3613 |
| GET | /api/v1/domains/<name>/lists |
Basic or Bearer | yes | must match | array [{"address","active","require_auth","members":[...]}]; 404 domain not found
|
1950-1968, 3616-3667 |
| POST | /api/v1/domains/<name>/lists |
Basic or full key | no | must match | body {"address","members":[...],"require_auth"}; 201 {"address","members":<count saved>}; 400 address is required / address does not belong to the domain / a member's validation error; 404 domain not found; 409 a list with that address exists
|
1950-1968, 3670-3724 |
| DELETE | /api/v1/lists/<address> |
Basic or full key | no | address's domain must match | 200 {"deleted":true}; 404 list not found
|
1970-1979, 3727-3747 |
| GET | /api/v1/domains/<name>/dkim |
Basic or Bearer | yes | must match |
{"domain","enabled","selector","sign_aliases","private_key_file"}; 404 domain not found
|
1981-1990, 3782-3798 |
| GET | /api/v1/certificates |
Basic or Bearer | yes | REFUSED 403 | array [{"id","name","certificate_file","private_key_file"}] - never the private key password |
RestApiCertificates.cpp |
| POST | /api/v1/certificates |
Basic or Bearer (full) | no | REFUSED 403 | body name, certificate_file, private_key_file (required), private_key_password (write-only); 201 the entry; 400 a missing field or a file that is not there; 409 a name in use |
RestApiCertificates.cpp |
| DELETE | /api/v1/certificates/{id} |
Basic or Bearer (full) | no | REFUSED 403 | 200 {"deleted":true}; 404; 409 port_id names the port that binds it |
RestApiCertificates.cpp |
| GET | /api/v1/ports |
Basic or Bearer | yes | REFUSED 403 | array [{"id","protocol","address","port","connection_security","certificate_id","client_certificate_policy","client_certificate_ca_file"}]; since 6.3.6 also smtp_authentication (on, off or honeypot; SMTP listeners only - on on POP3 and IMAP) |
RestApiCertificates.cpp |
| POST | /api/v1/ports |
Basic or Bearer (full) | no | REFUSED 403 | body protocol (smtp, pop3, imap) and port required; address (default 0.0.0.0), connection_security (none, tls, starttls_optional, starttls_required), certificate_id, the client-certificate fields, and since 6.3.6 smtp_authentication (on, off, honeypot; refused on a POP3 or IMAP listener); 201 the entry; 400 including Certificate must be specified.; 409 an address and port already listened on. Takes effect on a restart in place or a service restart |
RestApiCertificates.cpp |
| PUT / DELETE | /api/v1/ports/{id} |
Basic or Bearer (full) | no | REFUSED 403 | PUT is the whole record (fields omitted take their defaults; a PUT that leaves smtp_authentication out writes on, 6.3.6); 200 the entry / {"deleted":true}; 404 |
RestApiCertificates.cpp |
| GET / POST | /api/v1/never-ban-names |
Basic or Bearer; POST needs a full key | GET yes | REFUSED 403 | the never-ban list by reverse name, each entry with id, pattern and note; POST pattern (and note) - mail.example.net for that name, .example.net for it and every name beneath; a public suffix, an address, a network or * is refused. An address is spared from all three auto-bans when one of its reverse names is covered and resolves back to it. 6.3.6 (schema 6108, hm_neverbannames) |
RestApiNeverBanNames.cpp |
PUT / DELETE; POST .../check
|
/api/v1/never-ban-names/{id}, /api/v1/never-ban-names/check
|
full key; the check is refused for a read-only key | no | REFUSED 403 | change or remove an entry; POST .../check with {"address": "..."} says what the list would decide about an address now (sends DNS queries). The check of an address that is not public - private, loopback, link-local, carrier NAT, reserved or documentation - is the administrator password's alone: an API key and an account holding a role get 403 for one and check a public address. 6.3.6
|
RestApiNeverBanNames.cpp |
| POST | /api/v1/server/reinitialize |
Basic or Bearer (full) | no | REFUSED 403 | 202 {"reinitializing":true}; the services stop, the configuration reloads and they start again half a second later, the REST listener among them |
RestApiServer.cpp (HandleServerReinitialize_) |
| GET | /api/v1/rules |
Basic or Bearer | yes | REFUSED 403 | array [{"id","name","active","all_criteria","criteria":[{"field","header","match","value"}],"actions":[{"type","value", the type's own parameters}]}]
|
RestApiRules.cpp |
| POST | /api/v1/rules |
Basic or Bearer (full) | no | REFUSED 403 | body name (required), active, all_criteria, criteria[], actions[] with the words GET uses; 201 the rule; 400 names the offending criteria[i]/actions[i] key, an unknown word, a regex that does not compile, a parameter a type does not take or lacks |
RestApiRules.cpp |
| PUT / DELETE | /api/v1/rules/{id} |
Basic or Bearer (full) | no | REFUSED 403 | PUT replaces name, flags, criteria and actions (array order is sort order); 200 the rule / {"deleted":true}; 404 (an account's rule is 404 here) |
RestApiRules.cpp |
| GET | /api/v1/routes |
Basic or Bearer | yes | REFUSED 403 | array of routes: id, domain_name, description, target_smtp_host, target_smtp_port, number_of_tries, minutes_between_try, relayer_requires_authentication, relayer_auth_username, treat_security_as_local_domain, treat_recipient_as_local_domain, treat_sender_as_local_domain, all_addresses, addresses[], connection_security - never the relay password |
RestApiRoutes.cpp |
| POST | /api/v1/routes |
Basic or Bearer (full) | no | REFUSED 403 |
domain_name and target_smtp_host required (port 25, 3 tries, 10 minutes by default); relayer_auth_password write-only; 201 the entry; 400 an unknown field or a bad value; 409 a route for that domain exists |
RestApiRoutes.cpp |
| PUT / DELETE | /api/v1/routes/{id} |
Basic or Bearer (full) | no | REFUSED 403 | PUT is the whole record, the address list diffed, the password kept when absent; 200 / {"deleted":true}; 404; 409 another route has the domain |
RestApiRoutes.cpp |
| GET | /api/v1/logs |
Basic or Bearer | yes | REFUSED 403 | array [{"name","size","created"}]
|
2002-2006, 3969-3996 |
| GET | /api/v1/logs/<name>?lines=N |
Basic or Bearer | yes | REFUSED 403 |
{"name","lines":[...]} - the tail, newest last, N default 200 and at most 2000; 400 not a log file name (must be a bare *.log name); 404 no such log file
|
2007-2016, 3946-3967, 3999-4095 |
| GET | /api/v1/backup |
Basic or Bearer | yes | REFUSED 403 |
{"status","log":[...]} - the manager's status text and the backup log's last lines; 503 the backup manager is not running
|
2018-2024, 4117-4157 |
| POST | /api/v1/backup |
Basic or full key | no | REFUSED 403 | 202 {"started":true} (runs on the maintenance queue; poll GET); 409 {"error":"the backup did not start","status":"..."} when one is running or nothing is configured; 503 the backup manager is not running
|
2018-2024, 4098-4114 |
| GET | /api/v1/settings |
Basic or Bearer | yes | REFUSED 403 | the server group: the ten keys it always carried (host_name, default_domain, max_message_size_kb, the connection limits, the relayer, the two conversation-log switches) and the rest of the Control Panel's scalar settings, 62 keys, snake_case; no password (6.3.6 adds auto_ban_widen_by_reverse_name). 6.3.4, also the nine audit and alert settings - audit_trail_enabled, audit_retention_days, alerts_enabled, alert_recipient, alert_sender_address, alert_digest_enabled, alert_digest_hour, alert_max_per_hour, alert_webhook_max_attempts - which take effect at once |
RestApiSettings.cpp (ServerRows) |
| PUT | /api/v1/settings |
Basic or Bearer (full) | no | REFUSED 403 | any subset of the group's writable keys; 200 the whole group; 400 <key> is not a setting in this group / must be a string|an integer|true or false|one of ... / is read-only, or the setter's own sentence; nothing applied when anything is refused. smtp_relayer_password is write-only; the OpenAPI document marks the keys that take effect on restart |
|
| GET / PUT | /api/v1/settings/antispam |
as /api/v1/settings
|
GET yes, PUT no | REFUSED 403 | the anti-spam scalars (thresholds, headers, SPF, HELO, MX and PTR checks, DKIM, DMARC, ARC, SpamAssassin, tarpit, greylisting, the anti-spam size limit), 34 keys; since 6.3.6 also refuse_helo_not_fqdn, refuse_mail_from_domain_not_fqdn and surbl_too_many_domains_score
|
RestApiSettings.cpp |
| GET / PUT | /api/v1/settings/logging |
as /api/v1/settings
|
GET yes, PUT no | REFUSED 403 | the logging switches, device, log_format, and five read-only facts (the log directory and the current file names). 6.3.4, also the twelve syslog keys - syslog_enabled, syslog_host, syslog_port, syslog_transport (udp, tcp, tls), syslog_facility, syslog_minimum_severity and six syslog_log_* category switches - each marked as taking effect at restart (Monitoring and Health §16.2a) |
RestApiSettings.cpp (LoggingRows) |
| GET | /api/v1/archive?domain=&mailbox=&sender=&recipient=&subject=&since=&until=&hold=1&limit= |
Basic or Bearer | yes | must name one of its domains (checked in the handler, 403) | array of index rows {"id","time","domain","mailbox","direction","sender","recipients","subject","message_id","path","size","hold"}, newest first, limit 1-1000 default 200; 500 the archive index could not be read
|
2031-2036, 4261-4294 |
| GET | /api/v1/archive/<id> |
Basic or Bearer | yes | handler-checked | the index row; 404 archive entry not found
|
2051-2060, 4297-4305 |
| POST | /api/v1/archive/<id>/hold |
Basic or full key | no | handler-checked | 200 {"hold":true}; 404 archive entry not found; 500 the hold could not be changed
|
2040-2049, 4308-4320 |
| DELETE | /api/v1/archive/<id>/hold |
Basic or full key | no | handler-checked | 200 {"hold":false}; 404 archive entry not found
|
2040-2049, 4308-4320 |
| GET | /api/v1/update |
Basic or Bearer | yes | REFUSED 403 | the update check's verdict: {"state","stateName","runningVersion","channel","checkEnabled","availableVersion","releaseName","publishedAt","releaseUrl","installer":{"name","url","size","digest","bundleUrl"},"downloaded":{"path","signer","logTime","verifiedAt"},"apply":{"status","version","detail"},...}; state 0 not checked, 1 up to date, 2 available, 3 downloaded and verified, 4 installing, 5 the last check failed (lastError), 6 while the service prepares an administrator's install and 7 when it is ready (6.3.6); fetches nothing itself. 6.2.28. Since 6.3.6 also nextCheck, installedBy (service, administrator or elsewhere), installAdvice, serviceAccount, serverName, preparation, installKind, liveUpdate, updateCommand and updateAdvice; and apply keeps the last outcome until the service stops or the next outcome replaces it (6.3.4 and 6.3.5 lost it at the check a second after the start) |
1853-1857, 8203-8207; UpdateChecker.cpp:539-585 |
| POST | /api/v1/update/check |
Basic or full key | no | REFUSED 403 | reads the release feed now, whether or not UpdateCheckEnabled is on; 200 with the verdict as GET /api/v1/update (state 5 + lastError when the feed could not be read). 6.2.28 |
1859-1863, 8226-8235 |
| POST | /api/v1/update/download |
Basic or full key | no | REFUSED 403 | fetches the installer the last check found and its Sigstore bundle into <DataDirectory>\Updates and verifies it; a file that fails is deleted; nothing is run; 200 with the verdict (state 3 when in place, 5 + lastError when not). 6.2.28 |
1865-1869, 8218-8224 |
| POST | /api/v1/update/install |
Basic or full key | no | REFUSED 403 | verifies the downloaded installer again and hands it to hMailServer.Updater.exe, which runs it, waits for the service, and rolls back if it does not return; the service stops and starts during the update; 200 with the verdict (state 4 when the helper was started, 5 + lastError when not). 6.2.28. Since 6.3.6, where the service runs as NT SERVICE\hMailServer, 409 update_install_by_administrator: Install update in the Control Panel on the server installs it with one Windows prompt |
1871-1876, 8209-8216; UpdateInstaller.cpp:26 |
| GET |
/portal, /portal.js
|
none | - | - | the self-service sign-in page (text/html) and its script (text/javascript), static and compiled into the server, with a Content-Security-Policy allowing no inline script (script-src 'self', connect-src 'self', frame-ancestors 'none'), X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Cache-Control: no-store. 6.2.28 |
1281-1285, 6860-7576 |
| GET | /api/v1/me |
Account | - | - | {"address","domain","active","quota":{"limit_mb","used_bytes"},"vacation":{"enabled","active","subject","message","expires","expires_date"},"password_changed","second_factor","directory_linked"} |
1581-1585, 4391-4425 |
| POST | /api/v1/me/password |
Account | - | - | body {"current","new"} (current must be the account password itself); an account with a second factor sends the code in X-hMailServer-OTP, else 401 with X-hMailServer-OTP: required; 200 {"changed":true}; 400 fields missing or the password policy refused (reason in error); 403 the current password is not correct; 409 directory-linked account or a recently used password; ends the account's other sessions |
1720-1724, 4428-4513 |
| PUT | /api/v1/me/vacation |
Account | - | - | body {"enabled"(required),"subject"(<=200),"message"(<=20000),"expires","expires_date"(YYYY-MM-DD, required when expires)} - the whole state at once; 200 {"enabled","subject","message","expires","expires_date"} as saved; 400 |
1726-1730, 4516-4581 |
| POST | /api/v1/session |
Account (Basic password only; a session cookie -> 403) | - | - | 201 {"address","idle_seconds":1800,"lifetime_seconds":43200} with Set-Cookie: hmailsession=<64 hex>; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200[; Secure]; see section 3d |
1732-1736, 4667-4739 |
| DELETE | /api/v1/session |
Account (session cookie) | - | - | 200 {"ended":true} and a clearing Set-Cookie (Max-Age=0); 400 when the request carried a password rather than a session |
1738-1742, 4742-4764 |
| GET | /api/v1/me/quarantine |
Account | - | - |
{"enabled","messages":[{"id","sender","subject","reason","score","size","created"}]} - only the entries this address is a recipient of, without the other recipients |
1587-1593, 4789-4830 |
| POST | /api/v1/me/quarantine/<id>/release |
Account | - | - | delivers the held message to this address only (the entry stays for its other recipients); 200 {"released":true}; 404 quarantined message not found when not held for this account; 500 with the store's error text |
1595-1613, 4833-4855 |
| DELETE | /api/v1/me/quarantine/<id> |
Account | - | - | this address gives up its copy; the entry and file go when no recipient is left; 200 {"deleted":true}; 404 |
1595-1613, 4858-4872 |
| GET | /api/v1/me/folders |
Account | - | - |
{"delimiter","folders":[...],"shared":[{"owner","account_id","folders":[...]}]} - every folder the account may read as IMAP LIST gives it (id, name, path, parent_id, special_use, subscribed, writable, messages, unseen, uidvalidity, subfolders; since 6.3.4, also uidnext, the folder's current UID plus one, as IMAP STATUS reports UIDNEXT - uidvalidity is the folder's creation time and was never the current UID); shared lists the public folders and the folders other owners shared |
1615-1619, 5143-5291; uidnext in AppendFolderJson_
|
| GET | /api/v1/me/folders/<id>/messages?limit=&before_uid=&q= |
Account | - | - |
{"folder_id","total","query","messages":[{"id","uid","size","received","subject","from","date","flags":{"seen","flagged","answered","draft","deleted"},"message_id","in_reply_to","references"}],"scanned","complete","next_before_uid"} newest first; limit 1-200 (default 200), before_uid pages back, q matches Subject/From/To/Cc/text/HTML case-insensitively over at most 2000 messages per request; 404 folder not found for another account's or an ACL-hidden folder |
1621-1635, 5294-5410 |
| GET | /api/v1/me/search?q=&limit= |
Account | - | - |
{"query","scanned","complete","more","messages":[{"folder_id","folder","id","uid","size","received","subject","from","date","flags",...thread headers}]} - the same match as q over every readable folder, newest first, limit 1-200 default 50, at most 2000 looked at; 400 q is required
|
1661-1665, 5441-5555 |
| GET | /api/v1/me/messages/<id> |
Account | - | - |
{"id","uid","folder_id","size","received","flags","subject","from","date","message_id","in_reply_to","references","truncated","to","cc","text","html","attachments":[{"index","name","size"}]}; a message over 1 MB is described with truncated: true and empty bodies; 404 message not found for another account's or an ACL-hidden message; 404 the message file is missing
|
1712-1716, 5558-5646 |
| PUT | /api/v1/me/messages/<id>/flags |
Account | - | - | body any of seen, flagged, answered, draft, deleted as booleans (only those named change); 200 {"id","folder_id","flags"}; 400 no flag named: ...; 403 the folder does not allow it (the rights STORE asks for); 404; every IMAP session on the folder is told |
1679-1685, 5760-5827 |
| POST | /api/v1/me/messages/<id>/move |
Account | - | - | body {"folder_id"}; as IMAP MOVE: a copy with a new UID then the original expunged; 200 {"id"(the new one),"folder_id"}; 400 folder_id is required / same folder / a folder of another mailbox; 403 the folder does not allow adding or removing; 404 |
1704-1710, 5830-5879 |
| DELETE | /api/v1/me/messages/<id>[?permanent=1] |
Account | - | - | moved to the folder designated \Trash when the account has one and the message is not in it already: 200 {"deleted":false,"moved_to":<folder id>,"id":<new id>}; otherwise, or with permanent=1, expunged: 200 {"deleted":true}; 403 the folder does not allow it; 404 |
1714-1716, 5882-5929 |
| GET | /api/v1/me/messages/<id>/attachments/<index> |
Account | - | - | the attachment decoded, under its own media type except types a browser would run or render (HTML, SVG, XML, script -> application/octet-stream), with Content-Disposition: attachment; filename="..."; filename*=UTF-8''..., X-Content-Type-Options: nosniff, a sandbox CSP and Cache-Control: no-store; index is the position in the message's attachments list; 404 message not found / attachment not found; 413 the message is too large to read here (over 32 MB) |
1687-1702, 6256-6305 |
| POST | /api/v1/me/messages |
Account | - | - | body {"to","cc","bcc"(address lists, comma or semicolon separated),"subject","text","in_reply_to","references","answered_id","attachments":[{"name","type","data"(base64)}]} (at most 20 attachments, 12 MB together; this route takes a request of up to 16 MB with a 300 s deadline); every address goes through the checks RCPT TO makes for an authenticated sender; queued through the SMTP submission pipeline; a read copy kept in the \Sent folder when there is one; 201 {"queued":true,"recipients","sent_id"(0 when no copy was kept)}; 400 no recipient / <address>: not an e-mail address / the refusal reason / too many recipients; 413 the message is larger than the server allows
|
1667-1671, 5995-6181, 6781-6856 |
| POST | /api/v1/me/drafts |
Account | - | - | body {"to","cc","bcc","subject","text","attachments","replace_id"} - kept in the Drafts folder (created as Drafts when the account has none) with \Draft \Seen; replace_id names the draft this one supersedes, expunged once the new one is saved (a new message with a new UID); 201 {"id","folder_id"}; 403 the Drafts folder does not allow it; 413 the mailbox is full; 16 MB / 300 s request cap |
1637-1641, 6580-6780, 6845-6856 |
| GET | /api/v1/me/settings |
Account | - | - | {"name":{"first","last"},"forwarding":{"enabled","address","keep_original"},"signature":{"enabled","text","html"}} |
1643-1650, 6368-6393 |
| PUT | /api/v1/me/settings |
Account | - | - | body any of name, forwarding, signature (each applied whole; one not named is left alone); 200 the settings as saved; 400 nothing to change / a name over 100 characters / forwarding needs an e-mail address / forwarding to the account itself / a signature over 20000 characters |
1643-1650, 6396-6470 |
| GET | /api/v1/me/filters |
Account | - | - | {"active"(the active Sieve script, empty when none),"name"(its ManageSieve name)} |
1652-1659, 6473-6484 |
| PUT | /api/v1/me/filters |
Account | - | - | body {"script"} checked as ManageSieve PUTSCRIPT checks it; an empty script removes the filter; 200 {"active"}; 400 script is required... / a script is at most 256 KB / the parse error |
1652-1659, 6487-6519 |
| any | anything else under /api/v1/apikeys/
|
Admin | - | - | 404 for the administrator, 401 for a key | 1590-1605, 1851-1859, 1559-1560 |
| any | anything else | authenticated | - | - | 404 {"error":"not found"}; unhandled exception -> 500 {"error":"internal error"}
|
1555-1566 |
- Ids in queue, quarantine, IP-range, archive and
/api/v1/me/...paths must be 1-18 decimal digits and > 0, otherwise the route is unknown (404); an attachment index is 1-5 decimal digits and may be 0 (source: RestApiServer.cpp:435-448, 1603-1612, 1631, 1683-1717, 1943, 2044-2056). -
This table is not the whole API, and the server says so itself. The route set was compared with the OpenAPI document the server emits (
GET /api/v1/openapi.json), counted from the path literals inRestApi*.cpp: 137 paths and 202 path-and-method operations in 6.3.3; 145 paths at commit916dec6a1; and 158 paths and 229 operations at commit3233b0cc4, after 6.3.3 (154 paths and 225 operations under/api/, the other four being/deck-lang/{code}.json,/files/{token},/portal-lang/{code}.jsonand/portal-smime.js). At master540347500(24 September 2026) the path literals name 404 paths and 563 path-and-method operations - 376 paths and 527 operations under/api/, and 28 beside them:/scim/v2/...(nine),/portal/oidc/...,/portal/saml/...,/saml/metadata,/portal/reset,/portal/protected/{token},/recovery/verify,/confidential/{token},/lists/...(five),/files/{token},/deck-lang/,/portal-lang/and/portal-smime.js. None of those counts sees the settings groups, whose pathsRestApiSettings.cppwrites into the document from its group tables (OpenApiPath):/api/v1/settingsand its named groups, each aGETand all butdirectoriesaPUT- nine paths and 17 operations in 6.3.3 and at3233b0cc4, thirteen and 25 at540347500. So the document itself names 146 paths and 219 operations in 6.3.3, 167 and 246 at3233b0cc4, and 417 paths and 588 operations at540347500(389 and 552 under/api/). In 6.3.4 (92b8f9f63) the path literals name 428 paths and 596 operations (400 and 560 under/api/), the settings groups are the same thirteen paths and 25 operations, and so the document names 441 paths and 621 path-and-method operations, 413 and 585 of them under/api/. The twenty-four paths added after540347500- nine under/api/v1/discovery/cases, seven under/api/v1/inbound-webhooks, four of mailbox encryption at rest (/api/v1/accounts/{address}/mailbox-encryptionand its/recover,/api/v1/domains/{domain}/mailbox-recovery,/api/v1/me/mailbox-encryption),/api/v1/accounts/{address}/booking-policy,/api/v1/storeand/api/v1/store/measure, and/api/v1/me/folders/{id}/bulk- have rows of their own in the second table below, after the SCIM row, marked 6.3.4. Every family among them now has a row, most in the second table below and many as one row for a family; the census inhmailserver/docs/DeckParity.md, which counts routes its own way, reads 586 path-and-method routes, 258 of them writes, in 6.3.4 (553 and 243 at540347500). The thirteen paths3233b0cc4adds over916dec6a1- the audit trail, the alerts, the remote domain policies and the reports - each have a row in the second table below. The administration families are all named in this section, but roughly forty of the webmail's own/api/v1/me/...paths - contacts, identities, preferences, storage, files, scheduled sends, sessions, the S/MIME key store, a message's HTML, receipt, snooze, source and unsubscribe, a folder's export and empty - are described on this page only as families rather than one row each. For those, read the OpenAPI document, which is generated from the same code that routes the request; it is the authority and this table is a reading aid. - Domain/account path segments may not contain
/; query strings are ignored everywhere (source: RestApiServer.cpp:1418-1425, 1627, 1651).
The routes added after the table above was written, in the same columns. Since the roles of 6.3.4 (schema 6047), Basic also means an account that holds an administrator role, reaching a route when its role holds the route's permission, and a key holds a role too. In Dom, must match means the route names a domain or an address and a key confined to named domains reaches only its own; REFUSED 403 means the route is server-wide and such a key is refused it; role decides means the key is not refused for its domains and its role's permissions decide; handler-checked means the handler judges the domains itself.
| Method | Path | Auth | RO ok | Dom | Returns / notes | Source |
|---|---|---|---|---|---|---|
| GET | /api/v1/settings/directories |
Basic or Bearer | yes | REFUSED 403 | what Settings.Directories reports over COM (the program, database, data, log, temp and event directories) and ini, the file the settings came from; facts about the installation, nothing here is written (in the tree since 13 September 2026; unreleased until 6.3.3) |
RestApiSettings.cpp |
| GET | /api/v1/settings/ini |
Administrator password only - an API key of any scope is answered 401, in 6.3.3 as in 6.3.4 | - | - |
{"names":[...]} - every key of the [Settings] section by name (Settings.IniSettingNames), and in 6.3.3 nothing else. In 6.3.3 the file's keys in file order; since 6.3.4, the union of the settings store (hm_inisettings) and the file, which after a start are the same list where the service may write the file, and beside it settings, the same names as {name, value, secret} with a secret's value as (set), applies, every key this build reads with its mark, pending, the keys saved and not yet running, and environment, the keys the server's environment sets |
RestApiSettings.cpp; IsApiKeyRoute_
|
| GET / PUT / DELETE | /api/v1/settings/ini/{name} |
Administrator password only (401 for any API key) | - | - | one [Settings] key (since 6.3.6 the five update-trust keys are refused with 409: they are read from the file alone), {"name","value","present"}: an absent key is 200 with an empty value and present false, as GetIniSetting answers an empty string; PUT body {"value"} (at most 4000 characters, no line break). In 6.3.3 PUT writes the file as SetIniSetting does and DELETE removes the line, which is a return to the default. Since 6.3.4 (schema 6042), PUT stores the value in hm_inisettings first and then brings the file's copy into line where the service may write the file (under NT SERVICE\hMailServer, to which the file is read-only, the row alone is written; a write of the file that fails is HM5806, and the stored value still counts); DELETE removes the line and then the row where the service may write the file (where it may not and the file has the line, the line stays and the row is kept holding the key's default, which the server treats the same, and for PasswordPepper and a key this build does not read the DELETE is a 500, the reason in the error log as HM5806 - The Service Account), and is now the only way to return a stored key to its default, because a line deleted by hand is written back at the next start where the service may write the file, and the row kept either way; a value is stored trimmed; PasswordPepper is written to the file only, and where the service may not write the file its PUT is a 500, the reason in the error log as HM5806; storing a key that a [SettingsOverride] entry shadows is accepted and reported as HM5808. In 6.3.3 the server reads the value at its next start or POST /api/v1/server/reinitialize; since 6.3.4 PUT and DELETE publish the change, so a key marked to apply now is in effect when they answer, and one marked to wait answers pending true until a reinitialise or a restart takes it. The OpenAPI text for this route has described the database as the store since 6.3.4, and says that the file's copy is written and an edited line put back without the condition that the service may write the file |
RestApiSettings.cpp; IniSettingStore.cpp |
| POST | /api/v1/settings/logon-failures/clear |
Basic or full key | no | REFUSED 403 |
Settings.ClearLogonFailureList: the failures the auto-ban counts are deleted; 200 {"cleared":true}. A ban already placed is an IP range, deleted through /api/v1/ipranges (in the tree since 13 September 2026; unreleased until 6.3.3) |
RestApiSettings.cpp |
| GET / PUT | /api/v1/settings/scripting |
as /api/v1/settings
|
GET yes, PUT no | REFUSED 403 |
enabled, language (VBScript or JScript), and read-only current_script_file and directory; POST .../scripting/reload loads the event-handler script again, POST .../scripting/check answers its syntax check. A build without a script engine (every Linux build) says so in /api/v1/status's build, and with scripting on reports HM5710 at every reload while a script file exists (in the tree since 13 September 2026; unreleased until 6.3.3) |
RestApiSettings.cpp |
| GET / PUT | /api/v1/settings/backup |
as /api/v1/settings
|
GET yes, PUT no | REFUSED 403 |
destination, backup_domains, backup_messages, backup_settings, compress, log_file - Settings.Backup over COM; PUT any subset, checked whole before anything is applied (in the tree since 13 September 2026; unreleased until 6.3.3) |
RestApiSettings.cpp |
| GET / PUT |
/api/v1/settings/messages, /api/v1/settings/messages/{name}
|
as /api/v1/settings
|
GET yes, PUT no | REFUSED 403 | the texts the server puts in the mail it writes itself (Settings.ServerMessages): GET an array of {"id","name","text"}; PUT body {"text"} changes one by its name, in use at once (in the tree since 13 September 2026; unreleased until 6.3.3) |
RestApiSettings.cpp |
| POST | /api/v1/sieve/evaluate |
Basic or Bearer | yes | allowed | body {"script","message"} (RFC 5228 Sieve, the raw message): Utilities.EvaluateSieveScript - 200 {"result"}, the action list the script decided on (keep, fileinto:Folder, discard, redirect:address, ...) or error: and the parser's message; nothing is delivered (in the tree since 13 September 2026; unreleased until 6.3.3) |
RestApiSettings.cpp |
| GET / POST | /api/v1/accounts/{address}/fetch-accounts |
Basic or Bearer (POST: full) | GET yes | must match the address's domain | the remote POP3 or IMAP mailboxes collected into the account (Account.FetchAccounts): entries id, name, server_address, port, server_type (pop3 or imap), username, enabled, minutes_between_fetch, days_to_keep_messages, connection_security, the MIME and scanning switches, mirror_folders; POST creates one (name, server_address, port required; password write-only) (in the tree since 13 September 2026; unreleased until 6.3.3). days_to_keep_messages means one thing for POP3 and IMAP since 6.3.6: -1 deletes each message once collected, 0 leaves it, N deletes it N days later (item 22); a browser session says what it means by the number (days_to_keep_meaning), and a negative value from a page loaded before the upgrade, which does not, is refused with 409, reload the page, and nothing is saved - an API key, a password and COM, which send the documented meaning, are not asked for it |
RestApiFetchAccounts.cpp |
| GET / PUT / DELETE | /api/v1/accounts/{address}/fetch-accounts/{id} |
Basic or Bearer (PUT, DELETE: full) | GET yes | must match | one fetch account, changed (any subset of the POST body) or deleted; POST .../{id}/download collects from the remote mailbox now (DownloadNow) (in the tree since 13 September 2026; unreleased until 6.3.3) |
RestApiFetchAccounts.cpp |
| PUT | /api/v1/domains/{domain} |
Basic or full key | no | must match | any subset of the domain's fields (the Control Panel's domain pages: on or off, postmaster, limits, DKIM, relay, vacation, ...), and name - a different name renames the domain and everything in it, as InterfaceDomain.Name + Save does (in the tree since 13 September 2026; unreleased until 6.3.3). Since 6.3.4 (schema 6044), also the seven domain transform keys, which GET /api/v1/domains lists too: external_tag_subject and external_tag_header (booleans), external_tag_text (at most 100 characters, refused rather than cut; empty means [EXTERNAL]), first_contact_tip, disclaimer_enabled, disclaimer_plain_text and disclaimer_html - see Stopping Spam §10.7a. There is still no GET for one domain: read it from the listing |
RestApiAdministration.cpp (DomainEntryJson_) |
| GET / POST / DELETE |
/api/v1/domains/{domain}/domain-aliases, .../{name}
|
Basic or Bearer (POST, DELETE: full) | GET yes | must match | the other names the domain answers to (DomainAliases): array [{"id","name"}]; POST body {"name"} 201, refused as Save refuses when the name is already a domain or an alias, in effect for the next RCPT TO; DELETE by name 200 {"deleted":true} or 404 (in the tree since 13 September 2026; unreleased until 6.3.3) |
RestApiAdministration.cpp |
| PUT | /api/v1/ipranges/{id} |
Basic or full key | no | REFUSED 403 | change an IP range in place: any subset of the POST body's fields (InterfaceSecurityRange) (in the tree since 13 September 2026; unreleased until 6.3.3) |
RestApiAdministration.cpp |
| GET / POST / PUT / DELETE |
/api/v1/dns-blacklists, /api/v1/surbl-servers, /api/v1/whitelist-addresses, /api/v1/blocked-senders, /api/v1/incoming-relays, /api/v1/greylisting-white-addresses, and /{id} under each |
Basic or Bearer (write: full) | GET yes | REFUSED 403 | the six anti-spam collections as resources rather than as settings, each the collection the Control Panel's page of that name edits: list, add, change one in place, remove one. 6.3.3 | RestApiAntiSpamLists.cpp |
| GET / POST / PUT / DELETE |
/api/v1/blocked-attachments, /api/v1/blocked-attachments/{id}
|
Basic or Bearer (write: full) | GET yes | REFUSED 403 | the attachment names or wildcards refused at delivery, as the Blocked attachments page edits them. 6.3.3 | RestApiBlockedAttachments.cpp |
| GET / POST | /api/v1/accounts/{address}/app-passwords |
Basic or Bearer (POST: full) | GET yes | must match the address's domain | an account's application passwords administered (the account's own are /api/v1/me/app-passwords): each id, name, active, when it was made and last used; POST makes one and is the only time the password itself is answered. 6.3.3
|
RestApiAccountResources.cpp |
| PUT / DELETE | /api/v1/accounts/{address}/app-passwords/{id} |
Basic or full key | no | must match | rename or deactivate one, or revoke it. 6.3.3 | RestApiAccountResources.cpp |
| GET | /api/v1/accounts/{address}/folders |
Basic or Bearer | yes | must match | the account's IMAP folder tree, each with its id, parent, name, special use and message count. 6.3.3 | RestApiAccountResources.cpp |
| GET / POST / PUT / DELETE |
/api/v1/accounts/{address}/folders/{id}/permissions, .../permissions/{pid}
|
Basic or Bearer (write: full) | GET yes | must match | a folder's access-control list: the account or group a right is granted to, and the right itself. This is the administrator's view of what a client sets with IMAP SETACL. 6.3.3
|
RestApiAccountResources.cpp |
| GET / DELETE | /api/v1/accounts/{address}/messages |
Basic or Bearer (DELETE: full) | GET yes | must match | every message of the account as rows - id, uid, folder, size, state, received, sender, flags - or, on DELETE, all of them. 6.3.3 | RestApiAccountResources.cpp |
| GET / POST | /api/v1/accounts/{address}/folders/{id}/messages |
Basic or Bearer (POST: full) | GET yes | must match | one folder's live collection; POST adds a message from its text, on the path IMAP APPEND takes, so the folder's sessions are told. 6.3.3
|
RestApiAccountResources.cpp |
| GET / PUT / DELETE | /api/v1/accounts/{address}/messages/{id} |
Basic or Bearer (write: full) | GET yes | must match | one message: its row with every header on GET; PUT changes its flags on IMAP STORE's path; DELETE removes it on EXPUNGE's. 6.3.3
|
RestApiAccountResources.cpp |
| GET / POST | /api/v1/accounts/{address}/messages/{id}/source |
Basic or Bearer (POST: full) | GET yes | must match | the raw .eml as it sits in the store. 6.3.3
|
RestApiAccountResources.cpp |
| GET / POST | /api/v1/groups |
Basic or Bearer (POST: full) | GET yes | REFUSED 403 | the account groups - the named sets of accounts a folder permission can name as one principal (Settings.Groups). Each entry id, name; POST body {"name"} (255 characters at most) and a second group of the same name is refused, as Group.Save refuses it. Read from the database on every call, so a group the Control Panel made is here (6.3.4)
|
RestApiGroups.cpp |
| GET / PUT / DELETE | /api/v1/groups/{id} |
Basic or Bearer (write: full) | GET yes | REFUSED 403 | one group; PUT renames it under the same check, and nothing changes when it is refused; DELETE takes the group, its membership rows and every folder permission that named it together, and the next IMAP command decides without them (6.3.4) | RestApiGroups.cpp |
| GET / POST | /api/v1/groups/{id}/members |
Basic or Bearer (POST: full) | GET yes | REFUSED 403 | the accounts in a group, as ACLManager reads them for every decision: id (the membership row), group_id, account_id, account (the address, empty when the account is gone). POST body account_id or account, or both if they name the same account; in force for the next IMAP command (6.3.4)
|
RestApiGroups.cpp |
| DELETE | /api/v1/groups/{id}/members/{account_id} |
Basic or full key | no | REFUSED 403 | remove an account from a group, by the account's id, because the membership is the pair (6.3.4) | RestApiGroups.cpp |
| POST | /api/v1/accounts/{address}/messages/{id}/copy |
Basic or full key | no | must match | body {"folder_id"}, a folder of this account's own tree: the file is copied, the row written with the destination folder's next UID, and every session on that folder told, exactly as COM Message.Copy does it. The source is left as it is, flags included. 201 with the copy's row; 404 when folder_id is not a folder of this account (6.3.4)
|
RestApiAccountResources.cpp |
| POST | /api/v1/rules/match |
Basic or Bearer | yes | REFUSED 403 | try one rule criterion against a value without writing a rule: body match_value, match_type (equals, contains, less_than, greater_than, regex, not_contains, not_equals, wildcard - the words the rule listing uses) and test_value; match is what the delivery path would decide. Utilities.CriteriaMatch over COM; nothing is read or written. match is not a rule id, so the /api/v1/rules/{id} routes never claim this path (6.3.4)
|
RestApiRules.cpp |
| POST | /api/v1/calendar/expand |
Basic or Bearer | yes | REFUSED 403 | expand one iCalendar object's recurrence over a window without storing anything: body calendar (one VEVENT or VTODO with any VTIMEZONE it needs), start and end (UTC YYYYMMDDTHHMMSSZ; unbounded when absent) and limit (1 to 10,000, 1,000 by default). Answers what the CalDAV calendar-query computes: uid, component, recurring, first, last (empty for a recurrence with no end), count, truncated, and instances of start, end, all_day, recurrence_id, overridden, summary. An object the server would refuse on PUT - a rule it does not expand, a zone it cannot place - is 400 with the reason (6.3.4)
|
RestApiSettings.cpp |
| GET / POST | /api/v1/remote-domains |
Basic or Bearer (POST: full) | GET yes | REFUSED 403 | the remote domain policies (Settings.RemoteDomainPolicies): each id, domain_name (a name or a wildcard pattern), description, active, outbound_tls (none, encrypted, verified, dane), require_inbound_tls, max_message_size_kb, max_connections, max_messages_per_minute, allow_automatic_replies, allow_forwarding, callout_enabled, callout_host, callout_port, callout_timeout_seconds (at most 60), callout_cache_minutes, callout_max_per_minute. POST takes the same fields with domain_name required and the defaults of Routes and Relays §14.2a; 201 the policy; 400 names the refused field (a wrong type, an unknown key, an address where a pattern belongs, an out-of-range value); 409 a policy for that name exists. In force for the next message (6.3.4; schema 6045)
|
RestApiRemoteDomains.cpp |
| PUT / DELETE | /api/v1/remote-domains/{id} |
Basic or full key | no | REFUSED 403 | PUT is the whole record: a field the body leaves out takes the create's default rather than keeping what was stored; the running policy is left as it was when the body is refused; 200 / {"deleted":true}; 404 unknown id; 409 another policy has the name (6.3.4)
|
RestApiRemoteDomains.cpp |
| GET | /api/v1/remote-domains/effective?domain= |
Basic or Bearer | yes | REFUSED 403 | the policy that governs a delivery to the domain - an exact name over any pattern, a longer pattern over a shorter, an inactive record skipped - as the listing shows it; 400 domain is required; 404 when no policy governs it, which means the delivery is unconstrained (6.3.4)
|
RestApiRemoteDomains.cpp |
| POST | /api/v1/remote-domains/verification-cache/clear |
Basic or full key | no | REFUSED 403 | forget every remembered recipient-callout verdict, so the next message asks the primary again; 200 with the number forgotten. A mutating route: refused to a read-only key, because it makes the server call out again (6.3.4) | RestApiRemoteDomains.cpp |
| GET | /api/v1/reports |
Basic or Bearer | yes | filtered | the index: the sections, what each is counted from, the state of the three sources (the message trace, off by default; the metric history; the message store) with how far back each can see, and what this server cannot answer (spam and virus counts per domain, storage growth per domain) (6.3.4) | RestApiReports.cpp |
| GET | /api/v1/reports/{section}?from=&to=&domain=&top=&format= |
Basic or Bearer | yes | its own domains only; 403 for another domain; volume and storage REFUSED 403 |
section is traffic, failures, senders, recipients, mailboxes, volume, storage or summary; from/to YYYY-MM-DD inclusive, default the last 30 days, at most 366; top 1-200, default 10; format=csv answers text/csv with the same columns and rows (400 for summary); every section carries enabled, truncated (the trace query stops at 100,000 grouped rows) and a note; summary lists the sections a key may not see as omitted, with the reason. 400 a bad date, window, top or format; 404 no such section or domain (Monitoring and Health §16.6a) (6.3.4)
|
RestApiReports.cpp, Reports.cpp |
| GET | /api/v1/audit?actor=&object_type=&action=&since=&until=&limit=&offset= |
Administrator password only - 401 for any API key | - | - |
{"total","offset","entries":[{"id","time","actor","actor_type","interface","address","object_type","object","action","detail","hash","previous_hash"}]} newest first; actor a substring, since/until seconds since the epoch, limit 1-1000 (default 100); 500 when the trail cannot be read (6.3.4; schema 6043)
|
RestApiAudit.cpp, AuditTrail.cpp |
| GET | /api/v1/audit/verify |
Administrator password only | - | - |
{"intact","rows_checked","first_broken_id","reason"} - walks the hash chain from the first row; the reason says whether a row was edited or one before it removed. What the trail does not yet record is listed in Monitoring and Health §16.7b (6.3.4)
|
RestApiAudit.cpp |
| GET | /api/v1/alerts/rules |
Administrator password only | - | - |
{"rules":[{"condition","enabled","threshold","actions" (1 mail, 2 webhook, 3 both),"cooldown_minutes","digest","address","webhook","webhook_secret_set"}]} - the secret itself is never returned (6.3.4)
|
RestApiAudit.cpp, AlertManager.cpp |
| PUT | /api/v1/alerts/rules/{condition} |
Administrator password only | - | - | any of enabled, digest, threshold, actions, cooldown_minutes, address, webhook, webhook_secret (write-only; "" clears it); a field left out keeps its value; the condition is folded to lower case; 200 the rule, 201 when it created one, 400 with the refusal (a condition of 1-64 characters; at least one action; a webhook needs an address and a secret) (6.3.4)
|
RestApiAudit.cpp |
| GET | /api/v1/alerts/events?limit= |
Administrator password only | - | - |
{"events":[{"id","time","severity","condition","state" (raised, cleared),"summary","detail","notified","held_for_digest","webhook" (none, pending, delivered, dead-lettered),"webhook_attempts"}]} newest first, limit 1-500 (default 100) (6.3.4)
|
RestApiAudit.cpp |
| POST | /api/v1/alerts/test |
Administrator password only | - | - | body {"condition","summary"?,"occurrence"?} raises the condition through the same rule, cool-down and ceiling a real one meets; 200 {"raised"} - false when the rule is off, missing or the condition already raised; 400 no condition (6.3.4)
|
RestApiAudit.cpp |
| POST | /api/v1/alerts/run |
Administrator password only | - | - | one alerting pass now: 200 {"conditions_raised","notifications_sent","webhooks_delivered","digest_events"} (6.3.4)
|
RestApiAudit.cpp |
| GET | /deck-lang/{code}.json |
none | - | - | the Control Deck's texts in one language, read as bytes from <ProgramDirectory>/WebAdmin/languages/{code}.json; the code must be 2-12 letters with inner dashes, else 404 no such language; nosniff, no-store (6.3.4)
|
RestApiServer.cpp (HandleWebAdminLanguage_) |
| GET / POST / PUT / DELETE |
/api/v1/me/contacts, /api/v1/me/contacts/{id}; GET /api/v1/me/contacts/{id}/photo
|
Account | - | - | the account's address book, one row per address. 6.3.3 has the list, the create, the change and the delete; since 6.3.4, the list takes ?source=manual or collected (an address the person wrote to is a suggestion until kept, and PUT with source manual keeps it), answers birthday, note, phones and has_photo, and fields=card puts the stored vCard on each; the photo route serves the card's embedded image as the image it is, never a URL it points at |
RestApiServer.cpp |
| GET / POST / PUT / DELETE |
/api/v1/me/contacts/groups, /api/v1/me/contacts/groups/{id}, .../{id}/members, .../{id}/members/{ref}
|
Account | - | - | contact groups: a contact whose card is KIND:group (vCard 4.0) or X-ADDRESSBOOKSERVER-KIND:group (3.0), its members read from the card; a group may hold a group, followed eight deep. 6.3.4
|
RestApiContactGroups.cpp |
| GET / POST / PUT / DELETE |
/api/v1/me/address-books, /api/v1/me/address-books/{id}, .../{id}/shares, .../{id}/shares/{target}
|
Account | - | - | several address books; the default one (the lowest id) cannot be deleted; a book shared with an account or a whole domain, read-only or writable, and a shared book left. 6.3.4 (schema 6079) | RestApiAddressBooks.cpp |
| GET / PUT |
/api/v1/me/directory, /api/v1/me/directory/me, /api/v1/me/directory/me/photo, /api/v1/me/directory/{id}/photo, /api/v1/me/directory/{address}/smime
|
Account | - | - | the organisation's directory - every active account and enabled list of the reader's own domain, never another's - with title, department, phones, photo and note; the reader's own entry, written only while DirectoryUserEditable allows; a colleague's certificates from the domain's S/MIME authority. 6.3.4 (schema 6078) |
RestApiDirectory.cpp, RestApiSmimeAuthority.cpp |
| GET / POST / PUT / DELETE |
/api/v1/me/calendars, /api/v1/me/calendars/{id}, /api/v1/me/calendars/{id}/shares/{address}
|
Account | - | - | the account's calendars and those shared with it; make, rename, recolour, delete (the default stays) or leave one; share one with another account or one of the server's groups, read-only, writable or as a delegate who answers invitations. 6.3.4 (schema 6064, 6086) | RestApiCalendar.cpp |
| GET / POST / PUT / DELETE |
/api/v1/me/calendar/events, /api/v1/me/calendar/events/{id}, POST .../{id}/reply; /api/v1/me/tasks, /api/v1/me/tasks/{id}
|
Account | - | - | a window's events occurrence by occurrence across every calendar the account sees, expanded by the server's own recurrence module; one event with its iCalendar text; a change to the series or one occurrence; an invitation in a calendar answered as its owner or as a delegate (SENT-BY); the tasks. 6.3.4
|
RestApiCalendar.cpp |
| GET | /api/v1/me/freebusy?address=&from=&to= |
Account | - | - | the busy and tentative periods of an active account of the caller's own domain over at most 62 days, with no title, location or description; another domain's account, an unknown address and an account that hides its free-busy are the same 404. 6.3.4 | RestApiCalendar.cpp |
| GET / POST |
/api/v1/me/messages/{id}/invitation, /api/v1/me/messages/{id}/invitation/reply
|
Account | - | - | the invitation a message carries (method, event, organiser, guests, whether it is already in a calendar) and the answer to it - accept, maybe, decline, or a new time proposed - mailed to the organiser (iMIP). 6.3.4. Since 6.3.6 remove for a cancellation of some occurrences takes those occurrences out and answers occurrences: true; it removed the whole series |
RestApiCalendar.cpp |
| GET / POST / PUT / DELETE |
/api/v1/me/fetch-accounts, /api/v1/me/fetch-accounts/{id}, POST .../{id}/download; GET / POST /api/v1/me/fetch-accounts/import
|
Account | - | - | the person's own other accounts, collected into this one (POP3 or IMAP, public hosts only unless ReaderFetchPrivateHosts, at most ReaderFetchAccountsMax); the import answers which providers can be signed in to and starts the OAuth sign-in to Gmail or Outlook.com. 6.3.4 (schema 6065). Since 6.3.6 the webmail says what it means by days_to_keep_messages (days_to_keep_meaning: -1 deletes once collected, 0 leaves the message, N deletes it N days later); a negative value from a page loaded before the upgrade is refused with 409, reload the page (item 22) |
RestApiFetchAccounts.cpp |
| GET / POST / DELETE |
/api/v1/me/sender-addresses, .../{id}, POST .../{id}/verify, .../{id}/resend
|
Account | - | - | addresses elsewhere the account sends as, each proved with a six-digit code mailed to it. 6.3.4 (schema 6066) | RestApiSenderAddresses.cpp |
| GET / POST / PUT / DELETE |
/api/v1/me/masked-addresses, /api/v1/me/masked-addresses/{id}
|
Account | - | - | masked addresses: made in the account's own domain, named, switched off and on, removed. 6.3.4 (schema 6067) | RestApiMaskedAddresses.cpp |
| GET |
/api/v1/me/subscriptions, /api/v1/me/brand-logo?domain=
|
Account | - | - | the senders whose mail offers a way to unsubscribe, with counts and the way the newest names; a sender domain's BIMI logo as SVG, or 404 with outcome saying why not (no_record, declined, weak_policy, not_https, fetch_failed, not_tiny_ps, switched_off). 6.3.4
|
RestApiServer.cpp |
| GET / POST / DELETE |
/api/v1/me/recoverable, /api/v1/me/recoverable/{id}, POST .../{id}/restore
|
Account | - | - | deleted items kept for recover_deleted_days, put back into their folder or deleted for good. 6.3.4 (schema 6068) |
RestApiRecoverable.cpp |
| GET / POST |
/api/v1/me/confidential, POST /api/v1/me/confidential/{id}/revoke
|
Account | - | - | the messages that expire this account has sent, and one withdrawn; the message itself is sent with expires_in on POST /api/v1/me/messages. 6.3.4 (schema 6069) |
RestApiConfidential.cpp |
| GET / PUT |
/api/v1/me/offline, GET /api/v1/me/offline/messages
|
Account | - | - | the offline window (the ceiling, the choice, where it opens) and the messages inside it, 500 a page, id, folder, size, time and flags only. 6.3.4 | RestApiOffline.cpp |
| GET / PUT / DELETE |
/api/v1/me/lists, /api/v1/me/lists/{address}
|
Account | - | - | the managed lists the account belongs to; how a list's mail comes (each message or the digest); leave one. 6.3.4 (schema 6092) | RestApiMailingLists.cpp |
| POST | /api/v1/me/recall |
Account (never an app password) | - | - | body message_id and optional sent_id: withdraw a message from the accounts on this server that have not read it, within RecallDays. 6.3.4 (schema 6085) |
RestApiMessageRecall.cpp |
| GET / PUT / DELETE / POST |
/api/v1/me/recovery, POST /api/v1/me/recovery/codes, POST /api/v1/me/recovery/resend
|
Account, and never an app password for a change; setting the address asks for the account's own password again, and it and making codes ask for the one-time code where a second factor is enrolled | - | - | the recovery address (set unverified, masked in every answer), its confirmation link sent again, ten one-time codes made and shown once. 6.3.4 (schema 6076) | RestApiAccountRecovery.cpp |
| GET / PUT / DELETE |
/api/v1/me/openpgp, /api/v1/me/openpgp/{fingerprint}, PUT /api/v1/me/openpgp/publish
|
Account | - | - | the public keys the server publishes for the account through the Web Key Directory, and whether it does; a secret key is refused. 6.3.4 (schema 6050) | RestApiOpenPgp.cpp |
| GET / PUT / DELETE |
/api/v1/me/openpgp/keys, .../keys/own, .../keys/own/{fingerprint}, .../keys/correspondents, .../keys/correspondents/{fingerprint}, .../keys/{id}/verified, .../keys/{id}/accepted; GET /api/v1/me/openpgp/lookup
|
Account (the two marks never on an app password) | - | - | the webmail's OpenPGP ring: the account's own keys, stored under a passphrase the server never sees, and its correspondents', a changed key held apart until accepted, a key marked verified in person; a correspondent's key looked up by WKD or a keyserver. 6.3.4 (schema 6080, 6081) | RestApiOpenPgpKeys.cpp |
| GET / POST / PUT / DELETE |
/api/v1/me/protected, /api/v1/me/protected/{id}, PUT .../{id}/content
|
Account | - | - | password-protected messages: recorded, the ciphertext sent in chunks (anything that is not a symmetric-key OpenPGP message is discarded), listed and withdrawn. 6.3.4 (schema 6082) | RestApiProtectedMessages.cpp |
| GET / PUT / DELETE |
/api/v1/me/zero-access, PUT /api/v1/me/zero-access/rewrap
|
Account | - | - | whether the account is a zero-access mailbox and the key it stands on; switch to it (where ZeroAccessAllowed lets) or back; seal the mailbox key again under a new password-derived passphrase. 6.3.4 (schema 6083) |
RestApiZeroAccess.cpp |
| GET / POST / PUT / DELETE |
/api/v1/me/smime, POST /api/v1/me/smime/chain, /api/v1/me/smime/own[/{fingerprint}], /api/v1/me/smime/recipients[/{fingerprint}]; POST /api/v1/me/smime/enroll, DELETE /api/v1/me/smime/issued/{serial}, POST .../issued/{serial}/confirm
|
Account | - | - | the S/MIME key store - the account's own certificates with keys wrapped in the browser, and its correspondents' - and whether a chain ends at a trusted root (6.3.3); a PKCS#10 request signed by the domain's S/MIME authority, a certificate it issued revoked, and the stored key confirmed (since 6.3.4, schema 6095) | RestApiServer.cpp, RestApiSmimeAuthority.cpp |
| GET / POST / PUT / DELETE |
/api/v1/me/sender-list, /api/v1/me/sender-list/{id}; GET / PUT /api/v1/me/quarantine-digest
|
Account | - | - | the reader's own safe and blocked senders, and whether the quarantine digest reaches them. 6.3.4 (schema 6046) | RestApiSenderLists.cpp |
| GET / DELETE |
/api/v1/me/devices, /api/v1/me/devices/{id} (6.3.4, schema 6053); /api/v1/me/sessions, /api/v1/me/sessions/{id} (6.3.3) |
Account | - | - | every client that has signed in - a browser, or a mail program by protocol, address and user agent, with the credential it used - and revoking one, which ends its session and drops its IMAP, POP3 and SMTP connections; the browser sessions, and ending one or every other | RestApiServer.cpp |
| GET / POST / PUT / DELETE |
/api/v1/me/push, /api/v1/me/push/subscriptions, .../subscriptions/{id}, POST /api/v1/me/push/test
|
Account | - | - | Web Push: one subscription per device, what it is told (a count, the sender, or the sender and the subject), quiet hours, a filter; a test push. 6.3.4 (schema 6054) | RestApiServer.cpp |
| GET | /api/v1/me/export.zip |
Account | - | - | everything the server holds for the account as one .zip - mbox per folder, vCard, the calendars, preferences, the Sieve script, log lines, account.xml; refused past 500 MB with the way to write it to the server's disk. 6.3.4
|
RestApiServer.cpp |
| GET / POST / PUT / DELETE |
/api/v1/me/preferences, /api/v1/me/identities, /api/v1/me/storage, /api/v1/me/files[/{id}[/content]], /api/v1/me/scheduled[/{id}], /api/v1/me/drafts/{id}/schedule, /api/v1/me/folders/{id} (PUT, DELETE), POST .../folders/{id}/empty, GET .../folders/{id}/export, /api/v1/me/messages/{id}/html, .../receipt, .../snooze, .../source, .../unsubscribe, DELETE /api/v1/me/app-passwords/{id}
|
Account | - | - | the webmail's own routes of 6.3.3: preferences, the addresses the account may write as, what the mailbox holds, files sent as links (chunked), what is put off and sending a draft later, a folder renamed, moved, emptied or exported as mbox, a message's HTML as a framed document, a read receipt, a snooze, its source, a one-click unsubscribe, an app password removed. The OpenAPI document has each one's body | RestApiServer.cpp, RestApiMailbox.cpp |
| GET / POST / DELETE |
/api/v1/passkeys, /api/v1/passkeys/{id}, POST /api/v1/passkeys/options
|
the credential a browser session answers to - an account's password, or the administrator password; an API key is refused | - | - | the passkeys that credential holds (the administrator's under a null account id); enrolling one asks for the password again, and the one-time code where one is enrolled, and is refused on an app password. 6.3.4 (schema 6077) | RestApiPasskeys.cpp |
| POST / GET | POST /api/v1/passkeys/challenge; GET /api/v1/signin-methods
|
none | - | - | a passkey sign-in's challenge (32 random bytes, five minutes, spent on first use), answered at POST /api/v1/session; what the sign-in pages may offer - a passkey, the organisation's OpenID Connect or SAML button. 6.3.4
|
RestApiPasskeys.cpp |
| POST / GET | POST /api/v1/password-reset, POST /api/v1/password-reset/complete; GET /portal/reset, GET /recovery/verify
|
none | - | - | a forgotten password: always 202 with the same sentence, whatever the address, rate-limited before anything is looked up; the link mailed to a confirmed recovery address opens /portal/reset, and the new password meets the policy, the history and the breach check; /recovery/verify confirms a recovery address from a page with a button. 6.3.4 (schema 6076) |
RestApiAccountRecovery.cpp |
| GET / POST | GET /portal/oidc/start, /portal/oidc/callback; GET /portal/saml/start, POST /portal/saml/acs, GET /portal/saml/finish, GET /saml/metadata
|
none | - | - | single sign-on into the webmail and the Deck: OpenID Connect (authorization code with PKCE, the id_token verified) and SAML 2.0 (the Response's signature verified over its exclusive canonical form), each ending in a one-use ticket for POST /api/v1/session; the service provider's metadata. 6.3.4
|
RestApiOidcSignIn.cpp, RestApiSamlSignIn.cpp |
| GET / POST | GET /confidential/{token}; GET /api/v1/protected/{token}, POST /api/v1/protected/{token}/reply, GET /portal/protected/{token}
|
none | - | - | the reading pages of a message that expires (a recipient proves who they are by a mailed code or the passcode) and of a password-protected message (the ciphertext, decrypted in the browser, and a reply under the same password). 6.3.4 | RestApiConfidential.cpp, RestApiProtectedMessages.cpp |
| GET |
/lists/{address}/subscribe, /lists/confirm, /lists/approve, /lists/unsubscribe, /lists/{address}/archive
|
none | - | - | the list manager's pages: ask to join, the confirmation a link opens, the owner's approval, a member's own page, the archive by month. 6.3.4 (schema 6092) | RestApiMailingLists.cpp |
| GET |
/api/v1/help, /api/v1/help/{page}
|
none | - | - | the documents built into the server, listed with their headings and the wiki base URL; one as text/markdown, a day's Cache-Control and an ETag; a name with a dot, a separator or anything but letters and digits is the same 404 as a page that is not there. 6.3.4
|
RestApiHelp.cpp |
| GET / PUT |
/api/v1/portal/branding; /api/v1/portal/files
|
GET branding none; the rest Basic or Bearer (PUT: full) | GET yes | role decides | what the webmail says it is (name, logo, announcement - a domain's own where domain= names one), set by the administrator; the policy for files sent as links. 6.3.3
|
RestApiServer.cpp |
| GET | /api/v1/setup/checklist[?dns=0] |
Basic or Bearer | yes | role decides | the first-hour checklist judged by the server: eight items, each done, action_needed, cannot_tell or not_needed, every finding with a code and a sentence. 6.3.4
|
RestApiSetupChecklist.cpp |
| GET / POST / DELETE | /api/v1/setup/sample-domain |
Basic or Bearer (write: full) | GET yes | role decides | whether example.test is there and what is in it; make it (three mailboxes, a list, a rule, eight messages through the delivery path); remove it and say what went. 6.3.4
|
RestApiSampleDomain.cpp |
| POST | /api/v1/diagnostics/run |
Basic or Bearer (full) | no | REFUSED 403 | the Control Panel's diagnostic tests, with the two domain names they take, each result's name, description, success and details. 6.3.4 | RestApiServer.cpp |
| GET / POST / DELETE | /api/v1/server/drain |
Basic or Bearer (write: full) | GET yes | REFUSED 403 | maintenance mode: GET {draining, since, sessions, deliveries, drained}; POST closes the listeners and pauses delivery with the process up (/readyz 503 "draining"); DELETE reopens them and resumes; 409 with the reason when already draining, not running or not draining. 6.3.4
|
RestApiServer.cpp |
| GET | /api/v1/server/settings/pending |
Administrator password only - 401 for any API key | - | - |
[{name, stored, running, applies, secret}]: the saved [Settings] keys the server does not run on yet, a secret's two values (set) or empty. 6.3.4
|
RestApiServer.cpp |
| GET | /api/v1/settings/changed |
Administrator password only - 401 for any API key | - | - | every setting whose value is not the one this version ships, with the shipped value beside it; the same document as COM Settings.GetChangedFromDefault. 6.3.4
|
RestApiSettings.cpp |
| GET |
/api/v1/settings/environment, /api/v1/settings/levels
|
Basic or Bearer | yes | REFUSED 403 | the settings the environment manages, as {variable, store, name, source} and never a value; the twenty-seven settings a domain and an account may hold their own value of, with each one's kind and where each level is held. 6.3.4
|
RestApiSettings.cpp, RestApiSettingLevels.cpp |
| GET / POST |
/api/v1/settings/profiles, /api/v1/settings/profiles/{name}, POST .../{name}/apply, POST /api/v1/settings/profiles/revert, .../discard, .../decline
|
Basic or Bearer (write: full) | GET yes | REFUSED 403 | the four setting profiles and the last choice; one profile's diff against this server with a diff_token; apply it (409 until the confirmation is sent where an entry closes a listener, trusts a range, turns a filter off or shortens a retention; 409 stale when the diff moved); put the last one back, forget the way back, or decline profiles. 6.3.4
|
RestApiProfiles.cpp |
| POST |
/api/v1/settings/cache/clear, /api/v1/settings/indexing/index, /api/v1/settings/indexing/clear, /api/v1/settings/scripting/check, /api/v1/settings/scripting/reload
|
Basic or Bearer (full; scripting/check any key) |
scripting/check yes; the rest no |
REFUSED 403 | empty the caches; index what is not indexed, or empty the index; check the event-handler script's syntax, or load it again. 6.3.3 | RestApiSettings.cpp |
| POST |
/api/v1/settings/breach-check/test, /api/v1/settings/push/vapid
|
Basic or Bearer (full) | no | REFUSED 403 | ask the breach-check endpoint for a range and say what it answered; make a new VAPID key pair for Web Push. 6.3.4. Since 6.3.6, asked by an API key or an account holding a role, the test reaches a public address only, over https and through HttpProxy when one is set, and answers reachable: false with the reason for an address on the server's own network; the administrator password, the Control Panel and COM test a mirror there as before |
RestApiSettings.cpp |
| POST | /api/v1/audit/{id}/revert |
Administrator password, or an account whose role holds manage_settings; 401 for any API key |
- | - | put a setting back to the value its audit row says it had, through the setter the change came through, recorded as a row of its own; a secret's row, a cut value and a row that is not a setting's are refused. 6.3.4. Since 6.3.6 a row of a [Settings] key is put back by the administrator password alone (403 to an account holding a role, with the sentence /api/v1/settings/ini refuses it with), and a switch is put back as a switch (6.3.4 and 6.3.5 wrote its text column and changed nothing); every refusal is named in the OpenAPI description |
RestApiAudit.cpp |
| GET / POST / DELETE | /api/v1/administrator/second-factor |
GET Basic or Bearer; POST and DELETE the administrator password only | GET yes | REFUSED 403 | whether the administrator password has a second factor; enrol one (the otpauth URI answered once; refused while no administrator password is set, and while a factor already stands, since a second enrolment would replace the secret every authenticator holds), or disable it. 6.3.4
|
RestApiOperator.cpp |
| POST | /api/v1/addresses/erase |
Administrator password only - 401 for any API key | - | - | body address, include_archive: every trace of an address that deleting its account leaves, other people's address books and first-contact memory among them. 6.3.4
|
RestApiOperator.cpp |
| POST / GET | POST /api/v1/accounts/{address}/export, GET /api/v1/accounts/{address}/export.zip
|
Administrator password only - 401 for any API key | - | - | the mailbox written to a directory on the server's disk as .eml files, or with archive everything held on the account as one .zip there; the same .zip downloaded. 6.3.4
|
RestApiOperator.cpp |
| GET / POST / PUT / DELETE |
/api/v1/accounts/{address}/rules, .../rules/{id}; /api/v1/domains/{name}/rules, .../rules/{id}
|
Basic or Bearer (write: full) | GET yes | must match | an account's own rules, and a domain's own rules (run after the server's for the recipients' and the sender's domains; schema 6063), in the global rules' documents; a rule that belongs elsewhere is not found. 6.3.4 | RestApiRules.cpp |
| GET / PUT |
/api/v1/accounts/{address}/directory, GET .../directory/photo
|
Basic or Bearer (PUT: full) | GET yes | must match | an account's directory entry and photo, administered. 6.3.4 (schema 6078) | RestApiDirectory.cpp |
| GET / DELETE |
/api/v1/accounts/{address}/address-books, DELETE .../address-books/{id}/shares/{target}
|
Basic or Bearer (DELETE: full) | GET yes | must match | an account's address books and who they are shared with; a share taken back. 6.3.4 | RestApiAddressBooks.cpp |
| DELETE |
/api/v1/accounts/{address}/recovery, /api/v1/accounts/{address}/recovery/codes
|
Basic or Bearer (full) | no | must match | clear an account's recovery address, or end its one-time codes; GET /api/v1/accounts/{address} answers the address masked, whether it is confirmed and how many codes are unused. 6.3.4
|
RestApiAccountRecovery.cpp |
| GET / PUT / POST |
/api/v1/accounts/{address}/review, POST .../review/unlock; GET /api/v1/review, DELETE /api/v1/review/{id}, POST /api/v1/review/{id}/release
|
Administrator password, or an account whose role holds the permission; 401 for any API key | - | - | compromised-account detection: an account's state, its exemption or sensitivity, the compromise lock cleared; the messages held for review, discarded or released to the queue. 6.3.4 (schema 6071) | RestApiAccountReview.cpp |
| GET / DELETE |
/api/v1/accounts/{address}/devices, .../devices/{id}; /api/v1/accounts/{address}/push
|
Basic or Bearer (DELETE: full) | GET yes | must match | the clients that have signed in to an account, and one revoked; its Web Push devices, and push stopped on all of them. 6.3.4 (schema 6053, 6054) | RestApiAccountResources.cpp, RestApiServer.cpp |
| GET / PUT / DELETE |
/api/v1/accounts/{address}/openpgp, .../openpgp/{fingerprint}; GET /api/v1/domains/{domain}/openpgp
|
Basic or Bearer (write: full) | GET yes | must match | an account's published OpenPGP public keys, uploaded or removed by the administrator; every key held for a domain's accounts. 6.3.4 (schema 6050) | RestApiOpenPgp.cpp |
| GET / POST | /api/v1/accounts/{address}/move |
Basic or Bearer (POST: full) | GET yes | must match - both domains, for a move | whether the mailbox can be renamed or moved now, and what it has been through; the rename or move to another domain of this server, carrying the store, every per-account store, memberships and aliases and leaving an alias behind. 6.3.4 (schema 6056) | RestApiMailboxMove.cpp |
| POST |
/api/v1/accounts/{address}/password, /api/v1/accounts/{address}/unlock
|
Basic or Bearer (full) | no | must match | the help desk's two routes: reset an account's password (refused for an account whose authority the credential does not take in), and let a locked account back in. 6.3.4 | RestApiRoles.cpp |
| POST | /api/v1/accounts/{address}/support-session |
Basic or Bearer (full) | no | must match | open a mailbox as its user, for support. 6.3.3 | RestApiServer.cpp |
| GET / POST / PUT / DELETE |
/api/v1/roles, /api/v1/roles/{name}; GET /api/v1/role-assignments, PUT / DELETE /api/v1/role-assignments/{address}
|
Basic or Bearer (write: full) | GET yes | assignments must match; the rest role decides | the four built-in roles and the custom ones, with their permissions (a built-in role, or one somebody holds, cannot be removed); who holds which role and where; a role given or taken away - never one wider than the giver's own. 6.3.4 (schema 6047) | RestApiRoles.cpp |
| GET |
/api/v1/message-trace, /api/v1/message-trace/address/{address}, /api/v1/message-trace/queue/{id}
|
Basic or Bearer | yes | the address route must match; the others REFUSED 403 | the most recent trace events; where mail to or from one address went; the story of one queued message. 6.3.4 | RestApiRoles.cpp |
| GET / POST / PUT / DELETE |
/api/v1/sender-list, /api/v1/sender-list/{id}; /api/v1/domains/{domain}/sender-list, .../sender-list/{id}
|
Basic or Bearer (write: full) | GET yes | server's REFUSED 403; a domain's must match | the server's and a domain's allow and block lists: an address, a domain or a URL, allow or block, with an optional expiry. 6.3.4 (schema 6046) | RestApiSenderLists.cpp |
| GET / POST / PUT / DELETE |
/api/v1/greylisting-bypass, .../{id}; /api/v1/domain-policy-list, .../{id}
|
Basic or Bearer (write: full) | GET yes | REFUSED 403 | the greylisting bypass list (a domain or a dotted suffix, on the envelope, the HELO or both, requiring an SPF, DKIM or DMARC pass for that identity; schema 6072); the domain white and black list by envelope domain or HELO (schema 6073). 6.3.4 | RestApiGreylistBypass.cpp, RestApiDomainPolicyList.cpp |
| GET / POST / DELETE |
/api/v1/domains/{domain}/dkim/rotation, POST .../rotation/start, .../check, .../switch, .../retire, DELETE .../rotation/next; POST /api/v1/dkim/rotation/run
|
Basic or Bearer (write: full) | GET yes | must match; run role decides |
a domain's DKIM key rotation: its state, a next key staged, the records checked, signing switched, the old selector retired, the next key cancelled; one pass of the rotation now. 6.3.4 (schema 6048) | RestApiDkimRotation.cpp |
| GET / POST / DELETE |
/api/v1/domains/{domain}/bounces, .../suppressions, .../suppressions/{address}; POST /api/v1/bounces/classify, /api/v1/bounces/expire
|
Basic or Bearer (write: full) | GET and classify yes |
the domain's routes must match; classify role decides; expire REFUSED 403 |
a domain's bounce counts and suppression list, an address put on or taken off by hand; how a bounce would be classified; the suppression list's sweep now. 6.3.4 (schema 6049) | RestApiBounces.cpp |
| GET / POST |
/api/v1/domains/{domain}/complaints; GET /api/v1/complaints/rates, POST /api/v1/complaints/expire
|
Basic or Bearer (POST: full) | GET yes | the domain's must match; the rest REFUSED 403 | the abuse reports recorded about a domain's mail; every domain's complaint rate; the feedback loops' sweep now. 6.3.4 (schema 6055) | RestApiComplaints.cpp |
| GET / POST / DELETE |
/api/v1/domains/{domain}/spam-traps, GET .../spam-traps/hits; DELETE /api/v1/spam-traps/{address}; GET / DELETE /api/v1/spam-traps/hits
|
Basic or Bearer (write: full) | GET yes | the domain's handler-checked; the trap's address must match; the server-wide hits REFUSED 403 | a domain's spam traps, an address declared one or stopped, and what has addressed them. 6.3.4 (schema 6058) | RestApiSpamTraps.cpp |
| GET / POST |
/api/v1/reputation, POST /api/v1/reputation/check
|
Basic or Bearer (POST: full) | GET yes | role decides | what the last check of this server's own addresses against the block lists found; check now. 6.3.4 (schema 6057) | RestApiReputation.cpp |
| GET |
/api/v1/session-policy, /api/v1/domains/{domain}/session-policy
|
Basic or Bearer | yes | server's REFUSED 403; a domain's must match | the session policy in force server-wide, and for a domain's accounts. 6.3.4 (schema 6053) | RestApiAdministration.cpp |
| GET / POST / PUT / DELETE |
/api/v1/webhooks, /api/v1/webhooks/{name}, GET /api/v1/webhooks/deliveries, POST /api/v1/webhooks/run, /api/v1/webhooks/test
|
Administrator password, or an account whose role holds the permission; 401 for any API key | - | - | outbound webhook subscriptions for five mail events, signed, queued, retried and dead-lettered; the delivery queue; a pass now; an event raised on request. 6.3.4 (schema 6051) | RestApiWebhooks.cpp |
| GET / POST |
/api/v1/domains/{domain}/dmarc-reports, .../dmarc-reports/summary, .../dmarc-reports/{id}, .../tls-reports; POST /api/v1/domain-reports/expire
|
Basic or Bearer (POST: full) | GET yes | the domain's must match; expire REFUSED 403 |
the DMARC aggregate and TLS-RPT reports read from a domain's report mailbox, one report with its records, and who sends as the domain with what result, charted; the retention sweep now. 6.3.4 (schema 6070) | RestApiDomainReports.cpp |
| GET / POST / DELETE |
/api/v1/domains/{domain}/smime-ca, POST .../smime-ca/rotate, DELETE .../smime-ca/issued/{serial}
|
Basic or Bearer (write: full) | GET yes | must match | a domain's S/MIME authority: made (P-256 by default, RSA-3072 on request), rotated, and a certificate it issued revoked onto its CRL. 6.3.4 (schema 6095) | RestApiSmimeAuthority.cpp |
| GET / POST / PUT / DELETE |
/api/v1/journal-rules, /api/v1/journal-rules/{id}
|
Basic or Bearer (write: full) | GET yes | REFUSED 403 | journaling and dual-delivery rules: whose mail (a hosted domain or one account), which way, where the copy goes, and whether it is a journal report or the message itself. 6.3.4 (schema 6075) | RestApiJournalRules.cpp |
| GET / POST |
/api/v1/lists/{address}/members, POST .../members/{member}/approve, .../members/{member}/reset-bounces; GET /api/v1/lists/{address}/archive, .../archive/{id}
|
Basic or Bearer (POST: full) | GET yes | must match | who a list delivers to now, its membership query resolved; a request to join approved; a member's bounces cleared; a managed list's archive and one message of it as text. 6.3.4 (schema 6074, 6092) | RestApiDistributionLists.cpp, RestApiMailingLists.cpp |
| GET |
/api/v1/logs/sessions, /api/v1/logs/sessions/{id}
|
Basic or Bearer | yes | REFUSED 403 | the connections open now and the last two hundred that ended; one session's log lines from the logger's ring, and each new one as it is written. 6.3.4 | RestApiSessionFollow.cpp |
| GET / DELETE |
/api/v1/queue/shaping, DELETE /api/v1/queue/shaping/{destination}
|
Basic or Bearer (DELETE: full) | GET yes | REFUSED 403 | the destinations that asked this server to slow down and the outbound connections in use; one destination's back-off cleared. 6.3.4 | RestApiServer.cpp |
| GET / POST / DELETE |
/api/v1/sandbox/held, DELETE .../held/{queue_id}, POST .../held/{queue_id}/release
|
Basic or Bearer (write: full) | GET yes | REFUSED 403 | messages held for a sandbox's verdict on their attachments; one discarded, or delivered without the verdict. 6.3.4 (schema 6091) | RestApiSandbox.cpp |
| GET / POST / PUT / DELETE |
/api/v1/warmup, /api/v1/warmup/{id}, GET .../{id}/history
|
Basic or Bearer (write: full) | GET yes | REFUSED 403 | IP warm-up schedules with what each sent today at each large provider; a schedule's counts every day it has. 6.3.4 (schema 6093) | RestApiWarmup.cpp |
| GET / POST / DELETE |
/api/v1/migrations, /api/v1/migrations/{id}, POST .../{id}/start, .../pause, .../resume, GET .../{id}/summary.csv, .../{id}/jobs, .../jobs/{job}, POST .../jobs/{job}/retry, .../cancel, .../signin, GET .../jobs/{job}/report
|
Basic or Bearer (write: full) | GET yes | handler-checked | a migration of jobs, one per mailbox, from IMAP, Google Workspace or Microsoft 365: made, started, paused, resumed, deleted; jobs added, retried, cancelled, a new sign-in link minted; per job and per migration reports. 6.3.4 (schema 6090) | RestApiMigrations.cpp |
| GET / POST | GET /api/v1/backup/archives, GET /api/v1/backup/{archive}/mailboxes/{address}, POST .../mailboxes/{address}/restore, GET /api/v1/backup/restore-mailbox/{job}; POST /api/v1/backup/restore
|
Basic or Bearer (POST: full) | GET yes | REFUSED 403 | the archives in the backup destination; one account's folders and messages as an archive holds them; one mailbox or one message restored, merged or replacing, and what became of that job; a whole archive restored. 6.3.4 | RestApiServer.cpp |
| POST / GET | POST /api/v1/send; GET /api/v1/send, /api/v1/send/{queue_id}
|
an API key naming a sender (send_from) whose role holds send_through_api; the administrator password and an account are refused |
- | the key's sender must be in its domains | send a message as an application, under the key's own sender and within its ceilings a minute and a day; the key's own sends, newest first, and what became of each recipient. 6.3.4 (schema 6089) | RestApiSend.cpp |
| GET / POST / PUT / PATCH / DELETE |
/scim/v2/ServiceProviderConfig, /scim/v2/Schemas[/{id}], /scim/v2/ResourceTypes[/{id}], /scim/v2/Users[/{id}], /scim/v2/Groups[/{id}]
|
Bearer: an API key whose role holds provision; anything else is 401 in SCIM's shape with WWW-Authenticate: Bearer
|
read-only (auditor) key 403 | a key confined to named domains provisions those domains and learns nothing of the rest | SCIM 2.0 (RFC 7643, RFC 7644): the accounts as Users and the distribution lists as Groups, listed and filtered (at most 200), made, replaced, changed with PATCH and removed; every answer application/scim+json, every refusal SCIM's error document. 6.3.4 (schema 6096) |
RestApiScim.cpp |
| GET / POST |
/api/v1/discovery/cases; GET /api/v1/discovery/cases/{id}; POST .../{id}/close
|
Basic or Bearer: view_discovery to read (the built-in auditor holds it), manage_discovery to open or close (serveradmin), the administrator password, or a key whose role holds them |
GET yes | a confined credential sees the cases whose every search and hold is inside its domains | eDiscovery: {cases:[{id, name, description, state, created_by, created, closed_by, closed, search_count, live_holds, export_count}]} newest first; POST {name, description} 201; one case whole with its searches, holds and exports (a case a confined credential may not see is the same 404 as none); close releases every live hold, each on the audit trail, and answers released (409 closed already). 6.3.4 (schema 6097) |
RestApiDiscovery.cpp; hmailserver/docs/Discovery.md
|
| POST / GET | POST /api/v1/discovery/cases/{id}/searches; GET .../searches/{search}?offset=&limit=&unsearched_offset=&unsearched_limit=; POST .../searches/{search}/export; GET .../exports/{export}
|
manage_discovery to search and export; view_discovery to read; a search or export that read the archive also view_archive; the download also view_mailbox
|
read yes | no custodian outside the credential's domains, and never *
|
a search - custodians (addresses, domains or *, at most 100), from, to, words, attachment {name, type, min_kb}, scope (mailboxes, archive or both) - queued on the maintenance work queue, 202; it reads folders, deleted items and archived copies (a zero-access or end-to-end-encrypted message by its headers alone) and keeps at most 100,000 hits; the hits paged, with what could not be searched; the export, a zip of every hit as .eml and an mbox per folder with SHA256SUMS and manifest.json (fails past 4 GB; no PST), 202; the download as application/zip (413 past 500 MB), on the audit trail with the zip's SHA-256. 6.3.4
|
RestApiDiscovery.cpp |
| POST / DELETE | POST /api/v1/discovery/cases/{id}/holds; DELETE .../holds/{hold}
|
manage_discovery |
no | a confined credential holds nothing outside its domains (403) | a legal hold: {scope: account, target: address}, {scope: domain, target: domain} or {scope: archive, search: id} (at most 10,000 archived copies); a hold keeps every message from a delete, a rule, Sieve, retention, the recover-deleted sweep, a restore's replace, a full restore, a move to another domain and the account's or domain's deletion; 201, 409 when held already or the case is closed; DELETE releases it, on the audit trail. 6.3.4
|
RestApiDiscovery.cpp |
| GET / POST | /api/v1/inbound-webhooks |
GET view_configuration; POST manage_inbound_webhooks (only serveradmin holds it as shipped; a key whose role holds it) |
GET yes | the routes of the credential's own domains; POST must match the address's domain | mail in to a webhook: each route's address (an address or a whole hosted domain), url (whole only for a credential holding manage_inbound_webhooks, scheme and host for any other reader), secret_set, deliver, on_failure (keep or bounce), format (form - SendGrid's Inbound Parse fields - or json), attachments (include, names or none), include_raw, max_size_kb (default 10240, at most 81920), private_network, enabled and the delivery counts; POST requires address, url (https, or http to this machine only; the server's administrator may name its own network but never a link-local or cloud metadata address, any other credential public addresses only) and secret; 201, 409 when the address has a route. 6.3.4 (schema 6100) |
RestApiInboundWebhooks.cpp; hmailserver/docs/InboundWebhooks.md
|
| GET / PUT / DELETE | /api/v1/inbound-webhooks/{address} |
GET view_configuration; PUT, DELETE manage_inbound_webhooks
|
GET yes | must match | one route; PUT any subset (an empty secret keeps the one set; the URL judged again for the credential making the change); DELETE removes the route, its queue and every message it kept. 6.3.4
|
RestApiInboundWebhooks.cpp |
| GET / DELETE / POST | GET /api/v1/inbound-webhooks/deliveries?route=&state=&limit=; DELETE .../deliveries/{id}; POST .../deliveries/{id}/retry; GET .../deliveries/{id}/message; POST /api/v1/inbound-webhooks/run
|
GET view_configuration (the kept message also view_mailbox); the rest manage_inbound_webhooks; run a credential that reaches every domain |
GET yes | the queue narrowed to the credential's domains; a delivery outside them is 404 | the posting queue newest first (state pending, delivered, kept or bounced); one delivery removed with its message, or tried again at once (409 for one delivered, returned or with no message); the kept message as message/rfc822, on the audit trail; one pass now (delivered, removed, running). 6.3.4
|
RestApiInboundWebhooks.cpp |
| GET / PUT / DELETE |
/api/v1/accounts/{address}/mailbox-encryption (DELETE ?confirm=forfeit) |
Basic or Bearer (write: full) | GET yes | must match; 403 for an account that holds authority the credential does not | mailbox encryption at rest: {enabled, keyed, open, damaged, pending, fingerprint, created, changed, zero_access, domain_recovery, recovery_current}, never a key; PUT {enabled} makes an X25519 key sealed to the domain's recovery key and encrypts every message stored from then on (409, code mailbox_key, without a domain recovery key, for a zero-access mailbox, a directory-linked account or one with TOTP enrolled; a new key ends the account's sessions); off stores new mail as it comes; DELETE forfeits the key and every message still encrypted to it (409 under a legal hold). On the audit trail. 6.3.4 (schema 6099) |
RestApiMailboxKeys.cpp; hmailserver/docs/EncryptionAtRest.md
|
| POST | /api/v1/accounts/{address}/mailbox-encryption/recover |
Basic or full key | no | must match | body {passphrase, password}: the domain's recovery key opened with its passphrase, the account's key with it, and the new password - through the policy, the history and the breach check - wraps it; the only way to set an encrypted mailbox's password while its key is closed. 403 (mailbox_key) for a passphrase that does not open it, which counts towards the auto-ban. 6.3.4
|
RestApiMailboxKeys.cpp |
| GET / PUT / DELETE | /api/v1/domains/{domain}/mailbox-recovery |
Basic or Bearer (write: full); GET view_accounts
|
GET yes | must match | a domain's recovery key: {exists, fingerprint, hint, created, accounts, replacement_unfinished}; PUT {passphrase (12 characters or more), hint, current_passphrase} makes it or replaces it, sealing every account's key to the new one first; DELETE refused (409) while any account of the domain has a key. The passphrase is never stored. 6.3.4
|
RestApiMailboxKeys.cpp |
| GET | /api/v1/me/mailbox-encryption |
Account | - | - |
{enabled, keyed, open} for the signed-in account; read-only. 6.3.4
|
RestApiMailboxKeys.cpp |
| GET / PUT | /api/v1/accounts/{address}/booking-policy |
Basic or Bearer (PUT: full) | GET yes | must match; a confined credential names no group and no delegate outside its domains | a room's or a piece of equipment's booking policy and its delegates: auto_accept (true), window_days (180, 1 to 1080), max_duration_minutes (1440), working_hours_only, work_days, work_start, work_end, time_zone (UTC), allow_conflicts, allow_recurring, bookers (empty: everyone in this server's own domains), capacity, delegates; stored false while the defaults answer. PUT any subset; 409 while the account is a person - set kind to room or equipment on PUT /api/v1/accounts/{address} first. 6.3.4 (schema 6101) |
RestApiResourceBooking.cpp; hmailserver/docs/RoomsAndEquipment.md
|
| GET / POST |
/api/v1/store; POST /api/v1/store/measure
|
Basic or Bearer; measure a full key (manage_server) |
GET yes | REFUSED 403 | the message store's last measurement: {measured, measuring, messages, logical_bytes, stored_bytes, compressed_files, blobs, blob_bytes, blob_references, saved_bytes, saved_percent, compression, dedup_min_kb}; a request starts the next one when the last is a quarter of an hour old and the store is in use, so a server that never turned compression on reads measured 0 until measure, which answers 202. 6.3.4 (schema 6098) |
RestApiServer.cpp (HandleOpenApi_), RestApiStore.cpp; hmailserver/docs/MessageStore.md
|
| POST | /api/v1/me/folders/{id}/bulk |
Account (a browser session also X-Requested-With: hMailServer) |
- | - | one action over many messages of a folder - the webmail's select-all: action (flags, move or delete) over ids (1 to 1000) or all with max_uid, narrowed by tab for an inbox shown in tabs; at most 1000 messages a call (500 for move and delete), newest first, stopping after about two and a half seconds; call again with before_uid = next_before_uid until complete. 6.3.4
|
RestApiServer.cpp |
| POST | /api/v1/scheduled/run |
Basic or Bearer (full) | no | role decides | run what the webmail put off that is due now. 6.3.3 | RestApiServer.cpp |
Routes that were there before 92b8f9f63's last batches and answer differently in 6.3.4, each read from its handler or its OpenAPI text at 92b8f9f63:
-
PUT /api/v1/accounts/{address}takeskind-person,roomorequipment- which makes an account a resource that answers invitations by its booking policy and cannot be signed into (RestApiRoutes.cpp; schema 6101). -
POST /api/v1/migrationstakeskindpstbesideimap,googleandmicrosoft365: each job'ssource_usernames a file in<pst_import_directory>/<the target's domain>/, read by a reader process of its own (hMailServer.exe --pst-read,hmailserver --pst-read); withpst_import_directoryempty, as shipped, no PST job is taken (RestApiMigrations.cpp;hmailserver/docs/Migration.md). -
POST /api/v1/addresses/eraseanswersheldbesideremoved: a legal hold outranks the erasure, which now also reaches messages held for review, messages that expire, password-protected messages, the remembered recipients, the out-of-office memory and correspondents' S/MIME certificates and OpenPGP keys, each comma-joined list matched whole (RestApiOperator.cpp). -
GET /api/v1/me/searchanswerscomplete: falsewithnext_before_idwhenever an answer is not the end, andbefore_idcontinues from it; hits are newest first across every folder by message id (hmailserver/docs/Performance.md). -
GET /api/v1/statusanswersbuild.script_languages-["VBScript","JScript"]on Windows,["JavaScript"]on Linux - andPUT /api/v1/settings/scriptingtakeslanguageJavaScript(RestApiServer.cpp;hmailserver/docs/EventScripts.md). -
GETandPUT /api/v1/settingscarry themessage_store_*,mailbox_key_idle_minutes,pst_import_directoryandsnmp_*keys, twenty-seven in all (Settings Reference section 17); thesnmp_*keys apply at a restart.
| Code | Produced by | Body | Cause |
|---|---|---|---|
| 100 | HttpConnection::WriteContinue_ |
none | The client sent Expect: 100-continue and the declared length passed the cap |
| 200 | a handler | route-specific | — |
| 201 |
HandleCreateAccount_, HandleCreateApiKey_, HandleCreateIpRange_, HandleCreateList_, HandleMeMessageSend_, HandleMeDraftSave_, HandleSessionCreate_
|
the created object | — |
| 202 | HandleBackupStart_ |
{"started":true} |
Queued on the maintenance queue |
| 400 |
HttpServer error responder, or a handler |
{"error":"malformed request"} or a field-specific message |
NUL byte in head or body, request line not three tokens, a header with no colon, Content-Length not 1-9 digits; or a missing/invalid JSON field |
| 401 | BuildUnauthorizedResponse_ |
{"error":"authentication failed"} (+ "second_factor":"required") |
No credential, wrong password, unknown/expired/out-of-network key — all indistinguishable; also any API key touching /api/v1/apikeys* or /api/v1/settings/ini*, and since 6.3.4 /api/v1/audit*, /api/v1/alerts*, /api/v1/webhooks*, /api/v1/review* and an account's review, /api/v1/settings/changed, /api/v1/server/settings/pending, an account's export, /api/v1/addresses/erase and the second factor's enrolment (IsApiKeyRoute_); and any credential but a key on /scim/v2/, where the 401 is in SCIM's shape with WWW-Authenticate: Bearer
|
| 403 | BuildForbiddenResponse_ |
{"error":"<one of the fixed reasons>"} |
Read-only key on a mutating route, domain-restricted key out of scope, wrong credential class for a self-service route, missing CSRF header, POST /api/v1/session presented with a session |
| 404 |
ProcessRequest_ fall-through, or a handler |
{"error":"not found"} or e.g. {"error":"account not found"}
|
Unknown route (including a malformed id), or a named object that is not there |
| 405 | recognised by HttpServer::Serialize but not produced by this listener |
— | An unsupported verb on a known path is 404 here, not 405 |
| 409 | handlers | {"error":"..."} |
account already exists, a list with that address exists, a backup already running, a directory-linked password, a recently used password |
| 411 |
HttpServer error responder |
{"error":"length required"} |
The request carried Transfer-Encoding: no declared size to hold against the cap |
| 413 |
HttpServer error responder |
{"error":"request too large"} |
Head over 64 KB, or head + declared body over the effective cap; also the message is too large to read here (over 32 MB) from the attachment route |
| 415 | recognised, not produced | — | — |
| 429 | BuildTooManyRequestsResponse_ |
{"error":"too many requests"} + Retry-After: 10
|
Over 200 requests in the current 10-second window for this credential |
| 500 |
BuildResponse_, or the error responder when a handler throws |
{"error":"internal error"} or a specific message |
Also: any status a handler tried to set that HttpServer does not know is rewritten to 500 |
| 501 / 503 | handlers | {"error":"..."} |
503 for the backup manager is not running
|
- The known set is 200, 201, 202, 204, 301, 302, 304, 400, 401, 403, 404, 405, 409, 411, 413, 415, 429, 500, 501, 503 (source: HttpServer.cpp
Serialize). - Every API response carries
Content-Type: application/jsonandCache-Control: no-store; the exceptions are/,/index.html,/portal,/portal.jsand the attachment download, which set their own media type and are alsono-store(source:BuildResponse_,HandleWebAdminPage_,HandlePortalPage_,HandlePortalScript_,HandleMeMessageAttachment_).
The forms below are what the regression fixtures use: the listener on loopback over plain
HTTP with an administrator password. RestApiApiKeys runs on port 9104 with password
testar; RestApiSelfService runs on 9530 (source: hmailserver/test/RegressionTests/API/RestApiApiKeys.cs:58,
RestApiSelfService.cs:29). Response bodies are the shapes the Format calls in
RestApiServer.cpp produce, wrapped here for reading; the server writes them on one line.
Status and inventory — RouteStatus, RouteDomainList, RouteAccountList.
curl -u Administrator:testar http://127.0.0.1:9104/api/v1/status
curl -u Administrator:testar http://127.0.0.1:9104/api/v1/domains
curl -u Administrator:testar http://127.0.0.1:9104/api/v1/domains/example.com/accounts{"version":"6.2.28","state":3,"processedMessages":10412,"spamMessages":318,
"virusesRemoved":4,"sessions":{"smtp":2,"imap":11,"pop3":0}}
[{"name":"example.com","active":true}]
[{"address":"alice@example.com","active":true}]Accounts — RouteAccountCreate reads only address and password; active and
maxSizeMB in the OpenAPI schema are an overclaim (see section 11).
curl -u Administrator:testar -H "Content-Type: application/json" \
-d '{"address":"bob@example.com","password":"S0me-Long-Passphrase"}' \
http://127.0.0.1:9104/api/v1/domains/example.com/accounts
curl -u Administrator:testar -X DELETE \
http://127.0.0.1:9104/api/v1/accounts/bob@example.com{"address":"bob@example.com","created":true}
{"deleted":true}API keys — administrator password only, on every verb, including verbs that do not exist.
curl -u Administrator:testar -H "Content-Type: application/json" \
-d '{"label":"monitoring","scope":"readonly","domains":"example.com","allowed_from":"10.0.0.0/24","expires":"2027-01-01 00:00:00"}' \
http://127.0.0.1:9104/api/v1/apikeys
curl -u Administrator:testar http://127.0.0.1:9104/api/v1/apikeys
curl -u Administrator:testar -X DELETE http://127.0.0.1:9104/api/v1/apikeys/3f9a1c4b5d6e7f80{"id":"3f9a1c4b5d6e7f80","label":"monitoring","scope":"readonly","domains":"example.com",
"expires":"2027-01-01 00:00:00","allowed_from":"10.0.0.0/24","key":"hmapi_<64 hex>"}
{"count":1,"keys":[{"id":"3f9a1c4b5d6e7f80","label":"monitoring","scope":"readonly",
"domains":"example.com","expires":"2027-01-01 00:00:00","allowed_from":"10.0.0.0/24","expired":false}]}
{"revoked":true}The store this writes is hMailServerApiKeys.ini beside hMailServer.ini:
[Key.3f9a1c4b5d6e7f80]
Label=monitoring
Expires=2027-01-01 00:00:00
AllowedFrom=10.0.0.0/24
Scope=readonly
Domains=example.com
Hash=<64 lower-case hex characters, written last>Deleting that section revokes the key on the next request; deleting the file revokes
every key. Hash is written last on purpose, so a half-written section is never usable.
The delivery queue — refused outright for a domain-restricted key.
curl -u Administrator:testar http://127.0.0.1:9104/api/v1/queue
curl -u Administrator:testar -X POST http://127.0.0.1:9104/api/v1/queue/81234/retry
curl -u Administrator:testar -X DELETE http://127.0.0.1:9104/api/v1/queue/81234{"count":1,"messages":[{"id":81234,"created":"2026-09-08 09:12:44","from":"sales@example.com",
"recipients":"buyer@slow.example","next_try":"2026-09-08 10:12:44","locked":false,"tries":3}]}
{"retried":true}
{"deleted":true}With a domain-restricted key, all three answer:
{"error":"this api key is restricted to named domains, and the delivery queue is server-wide"}Publishable DNS — RouteTlsa and RouteSrv; /srv filters to the key's domains.
curl -u Administrator:testar http://127.0.0.1:9104/api/v1/tlsa
curl -u Administrator:testar http://127.0.0.1:9104/api/v1/srv{"host":"mail.example.com","count":1,"records":[{"certificate":"C:\\certs\\fullchain.pem",
"spki_sha256":"<64 hex>","record":"_25._tcp.mail.example.com. IN TLSA 3 1 1 <64 hex>"}]}
{"target":"mail.example.com","count":2,
"services":[{"service":"_imaps._tcp","priority":0,"weight":1,"port":993},
{"service":"_submissions._tcp","priority":0,"weight":1,"port":465}],
"records":[{"domain":"example.com","service":"_imaps._tcp",
"record":"_imaps._tcp.example.com. IN SRV 0 1 993 mail.example.com."},
{"domain":"example.com","service":"_submissions._tcp",
"record":"_submissions._tcp.example.com. IN SRV 0 1 465 mail.example.com."}]}Logs, backup and settings — all server-wide, all refused for a domain-restricted key.
curl -u Administrator:testar http://127.0.0.1:9104/api/v1/logs
curl -u Administrator:testar "http://127.0.0.1:9104/api/v1/logs/hmailserver_2026-09-08.log?lines=50"
curl -u Administrator:testar -X POST http://127.0.0.1:9104/api/v1/backup
curl -u Administrator:testar http://127.0.0.1:9104/api/v1/backup
curl -u Administrator:testar http://127.0.0.1:9104/api/v1/settingsThe archive index — a domain-restricted key must name one of its own domains.
curl -u Administrator:testar \
"http://127.0.0.1:9104/api/v1/archive?domain=example.com&sender=alice@example.com&since=2026-09-01&limit=50"
curl -u Administrator:testar -X POST http://127.0.0.1:9104/api/v1/archive/4471/hold
curl -u Administrator:testar -X DELETE http://127.0.0.1:9104/api/v1/archive/4471/holdA domain-restricted key that names no domain gets
{"error":"this api key is restricted to named domains; name one of them in the domain parameter"}.
The account's own routes — Basic with the mailbox address, or the session cookie.
curl -u self@example.com:secret http://127.0.0.1:9530/api/v1/me
curl -u self@example.com:secret http://127.0.0.1:9530/api/v1/me/folders
curl -u self@example.com:secret \
"http://127.0.0.1:9530/api/v1/me/folders/17/messages?limit=25&q=invoice"
curl -u self@example.com:secret http://127.0.0.1:9530/api/v1/me/messages/90210
curl -u self@example.com:secret -X PUT -H "Content-Type: application/json" \
-d '{"seen":true,"flagged":true}' \
http://127.0.0.1:9530/api/v1/me/messages/90210/flags
curl -u self@example.com:secret -X POST -H "Content-Type: application/json" \
-d '{"folder_id":19}' http://127.0.0.1:9530/api/v1/me/messages/90210/move
curl -u self@example.com:secret -X DELETE \
"http://127.0.0.1:9530/api/v1/me/messages/90210?permanent=1"
curl -u self@example.com:secret -OJ \
http://127.0.0.1:9530/api/v1/me/messages/90210/attachments/0{"address":"self@example.com","domain":"example.com","active":true,
"quota":{"limit_mb":25,"used_bytes":48213004},
"vacation":{"enabled":false,"active":false,"subject":"","message":"",
"expires":false,"expires_date":""},
"password_changed":"2026-06-02 08:31:10","second_factor":false,"directory_linked":false}Sending, with the 16 MB / 300 s allowance:
curl -u self@example.com:secret -X POST -H "Content-Type: application/json" \
-d '{"to":"someone@example.net","subject":"Quarterly figures",
"text":"Attached.",
"attachments":[{"name":"q3.csv","type":"text/csv","data":"<base64>"}]}' \
http://127.0.0.1:9530/api/v1/me/messages{"queued":true,"recipients":1,"sent_id":90788}Browser sessions — the credential the portal page actually uses.
curl -c jar.txt -u self@example.com:secret -X POST http://127.0.0.1:9530/api/v1/session
curl -b jar.txt http://127.0.0.1:9530/api/v1/me
curl -b jar.txt -X PUT -H "X-Requested-With: hMailServer" -H "Content-Type: application/json" \
-d '{"enabled":true,"subject":"Away","message":"Back Monday.","expires":false}' \
http://127.0.0.1:9530/api/v1/me/vacation
curl -b jar.txt -X DELETE -H "X-Requested-With: hMailServer" http://127.0.0.1:9530/api/v1/sessionOmit X-Requested-With on the PUT and the answer is 403 with the CSRF sentence in
section 3d.
- The attachment index is zero-based: it is the
indexfield of the entry in the message's ownattachmentsarray, and/attachments/0is the first attachment (source:HandleMeMessage_writes"index":ifrom a zero-based loop;HandleMeMessageAttachment_refusesindex < 0 || index >= GetCount()). -
countin the/api/v1/srvanswer is the number of records, which is the number of services multiplied by the number of active domains the caller may see, not the length of theservicesarray (source:HandleSrv_).
The live update — all four refused for a domain-restricted key, the last three refused for a read-only key.
curl -u Administrator:testar http://127.0.0.1:9104/api/v1/update
curl -u Administrator:testar -X POST http://127.0.0.1:9104/api/v1/update/check
curl -u Administrator:testar -X POST http://127.0.0.1:9104/api/v1/update/download
curl -u Administrator:testar -X POST http://127.0.0.1:9104/api/v1/update/installstateDiagram-v2
[*] --> S0
S0: 0 no check has completed since the service started
S1: 1 the running version is the latest on the channel
S2: 2 a newer release exists
S3: 3 its installer is downloaded and verified
S4: 4 handed to hMailServer.Updater.exe
S5: 5 the last check failed, lastError says why
S0 --> S1: check finds nothing newer
S0 --> S2: check finds a newer release
S0 --> S5: the feed could not be read
S1 --> S2: a later check
S2 --> S3: POST /api/v1/update/download, Sigstore bundle verified
S2 --> S5: download or verification failed, the file is deleted
S3 --> S4: POST /api/v1/update/install
S4 --> [*]: the service stops and starts, the helper rolls back if it does not return
S5 --> S2: a later check succeeds
- Source:
Common/Util/UpdateChecker.hstate enumerators; the same numbers are COMStatus.UpdateState(section 8).
-
GET /andGET /index.htmlare served without authentication, like the webmail's/portaland its scripts; the server serves<ProgramDirectory>\WebAdmin\index.htmlas the bytes on disk, with the portal's headers (a Content-Security-Policy,nosniff,no-referrer), and a short "not installed" page when the file is absent. 6.3.4,GET /deck-lang/{code}.jsonserves its catalogues the same way fromWebAdmin/languages- twenty languages beside English - so the sign-in card is in the reader's language before there is a session. - The installer ships
WebAdmin\index.htmlinto{app}\WebAdminwith the server component, andpost-build.batcopies it beside a built server so the regression suite is served the real page. - One static HTML/JS file, no external resources. Since 9 September 2026 it signs in for a session:
POST /api/v1/sessionwith HTTP Basic and the administrator password (and the one-time code, when one is enrolled - the page reveals that field when the server answerssecond_factor: "required"), then thehmailsessioncookie carries every later request. Nothing secret is kept insessionStorageorlocalStorage. Every request that changes something carriesX-Requested-With: hMailServer, which is what the server requires of a session write; signing out isDELETE /api/v1/session. -
Views, and they write. 6.3.3 has fifteen: Dashboard, Domains, IP ranges, Fetch accounts, Lists, Delivery queue, DANE / TLSA, Settings - the groups drawn from the OpenAPI document itself, with their types, enumerations, read-only and write-only markings and the document's own "takes effect on restart" wording, saving only what changed - Backup, Server messages, Rules, Routes, Certificates, Ports and Logs. The tree after 6.3.3 had nineteen at commit
3233b0cc4, adding Groups, Remote domains, Reports and Audit trail, and thirty-eight at master540347500; 6.3.4 has forty (thedata-viewbuttons of the navigation inWebAdmin/index.htmlat92b8f9f63): Dashboard, Checklist, Domains, Groups, IP ranges, Fetch accounts, Migrations, Lists, Spam traps, Journaling, Delivery queue, DANE / TLSA, Settings, Changed from stock, Backup, Server messages, Rules, Routes, Remote domains, Certificates, Ports, Roles, Devices, Logs (with a connection followed live), Reputation, Reports, Audit trail, Webhooks, Inbound webhooks (view_configuration), Quarantine, Review, Archive, Discovery (view_discovery), Folders, DNS records, Diagnostics, Metrics, Update, Passkeys and API keys. The object store's saving is on the dashboard, SNMP's settings and the message store's in the Settings view, a room's booking policy and mailbox encryption at rest in the account editor, and a PST job in Migrations. -
The look, since 6.3.4. The page takes the webmail's design tokens value for value - its surfaces, text greys, accent and status colours, in a light and a dark theme - and the desktop Control Panel's spacing, radii and type ramp; the theme button cycles System, Light and Dark and remembers the choice in
localStorageunderhmsTheme. A command bar holds the view's name, a search box, the connection as a status pill (colour, mark and word), the theme, the shortcut list and signing out; below 900 pixels the navigation becomes a rail of glyphs. The keyboard: a skip link,/to search (it hides rows that do not match),?for the shortcut list, the arrow keys to move through a table's rows andEnterto open one (never a row's delete button), and a visible focus ring on everything that takes focus. -
A first-run tour, since 6.3.4: the Show me around button (◎) in the command bar walks a domain, a mailbox, the listeners, the certificates and the dashboard, opening each view as it reaches it; a step points and explains and never presses anything, and where the walk got to is kept in
localStorageunderhmsTours. - Every write re-reads the resource from the server rather than patching the page's own state, and every refusal is shown next to the control that caused it, in the server's own sentence.
- A 401 from any call returns the page to its sign-in card. The server leaves
WWW-Authenticateout of a 401 answering a page's own fetch (it is sent for a navigation and for a plain script), so the browser no longer opens its own credential box in front of the page. - What no view reaches yet, by
hmailserver/docs/DeckParity.mdat3233b0cc4: 365 properties the desktop program writes, all 365 writable over REST, 359 reachable from a Deck view - the six left are a folder permission's four fields and an application password's name and active flag. The alert rules and events have no view either; they are REST-only. At master540347500the census read 464 properties the desktop program writes, 456 writable over REST and all 456 reachable from a Deck view; in 6.3.4 (DeckParity.mdat92b8f9f63) it reads 466, 458 and 458, over 69 desktop pages, 586 REST path-and-method routes by its own count (258 of them writes) and 40 Deck views; the eight it lists as missing are still an account's directory fields, whichPUT /api/v1/accounts/{address}/directorycarries under names the census does not match - read its What the heuristic gets wrong before calling them a gap.
- Started only when
WebServicesHttpPort > 0orWebServicesHttpsPort > 0; both default to 0 (source: Application.cpp:535-547; WebServicesServer.cpp:573-592). - HTTPS: uses
WebServicesCertificateFile/WebServicesPrivateKeyFile, falling back to the ACMEfullchain.pem/privkey.pemwhen either is empty; with no certificate at all the HTTPS listener is silently kept DOWN and the HTTP one still runs ("No TLS certificate available yet. The HTTPS listener is disabled until a certificate exists (enable ACME or set WebServicesCertificateFile).") (source: WebServicesServer.cpp:594-679). - TLS context is the shared
SslContextInitializerone with a TLS 1.2 floor (source: WebServicesServer.cpp:617-660). - At every service start (even with both ports 0) the server logs which enabled features are unreachable: "WebServices: these features are enabled but unreachable, because no web services listener is configured: ... Nothing answers those URLs until WebServicesHttpPort and/or WebServicesHttpsPort is set..." plus a note that MTA-STS needs the HTTPS port specifically; a plain-HTTP-only listener with MTA-STS hosting on logs a second warning (source: Application.cpp:516-533; WebServicesServer.cpp:373-531).
- Requests are unauthenticated; the listener runs on
HttpServer(new in 6.2.28): 64 KB cap on head + body (413), an absolute 15 s deadline per request and 120 s per connection (the connection is closed when either passes), 128 concurrent connections, 4 worker threads, keep-alive; every request wrapped in an OTel span (source: WebServicesServer.cpp:45-49, 501-523, 604-606; HttpServer.cpp:221-242, 337-352, 872). - Every response is
Cache-Control: no-storeunless the handler says otherwise:security.txtand the Thunderbird autoconfig XML arepublic, max-age=3600, the CalDAV/CardDAV 301spublic, max-age=86400; the MTA-STS policy, ACME challenges, the Apple profile, autodiscover and every refusal areno-store(source: WebServicesServer.cpp:695-722, 1058, 1362; new in 6.2.28). - Status codes the HTTP layer knows: 200, 201, 202, 204, 301, 302, 304, 400, 401, 403, 404, 405, 409, 411, 413, 415, 429, 500, 501, 503; anything else becomes 500 (source: HttpServer.cpp:968-995).
| Method | Path | Gate | Behaviour | Source (WebServicesServer.cpp) |
|---|---|---|---|---|
| GET | /.well-known/acme-challenge/<token> |
always | returns the key authorisation from AcmeChallengeStore (text/plain) or 404; token must have no / and be <= 256 chars |
880-884, 1010-1023 |
| GET | /.well-known/mta-sts.txt |
MtaStsHostingEnabled=1 (default 1) |
Host header must be mta-sts.<domain>; domain must be a local ACTIVE domain; body version: STSv1, mode: <MtaStsPolicyMode>, one mx: per host, max_age: <MtaStsPolicyMaxAge>; else 404 |
886-889, 1105-1152 |
| GET |
/.well-known/security.txt and /security.txt
|
always (no setting) | served when the Host (or its parent after dropping one label) is an active local domain whose Postmaster is a real address; body Contact: mailto:<postmaster>, Expires: now+90 days (RFC 3339 UTC), Policy: https://gitlab.com/hmailserver/hmailserver/-/blob/master/SECURITY.md, Preferred-Languages: en; else 404. The Policy URL is compiled into the server: since 6.3.4 it names the root SECURITY.md on GitLab (commit 9660893f1; SecurityTxtPolicyUrl, WebServicesServer.cpp:88-89 at 92b8f9f63). Up to 6.3.3 it named https://github.com/Progressiverobot/hmailserver/blob/master/.github/SECURITY.md, which has answered 404 since GitHub suspended the project's account on 18 September 2026 |
the route at WebServicesServer.cpp:716-717, HandleSecurityTxt_ (1245), the constants SecurityTxtExpirySeconds and SecurityTxtPolicyUrl (85-89), at the tag 51e2dbda0
|
| ANY |
/.well-known/caldav, /.well-known/carddav
|
always | 301 to CalDavRedirectUrl / CardDavRedirectUrl when one is set; an unusable value (not http(s)://, >512 chars, non-printable) is reported once per start as error 5780 and answers 404, never falling back to this server. Unset, 6.2.28 answered 404 for both; 6.3.3, which serves CardDAV, points /.well-known/carddav at its own /dav/ and still answers 404 for /.well-known/caldav; 6.3.4, which serves CalDAV too, points both at its own /dav/
|
897-903, 1245-1347 (6.2.28); HandleWellKnownDavRedirect_
|
| ANY |
/dav/... (CardDAV, RFC 6352) and /dav/calendars/... (CalDAV, RFC 4791) |
over HTTPS | the account's address books (6.3.3; several books and shared ones, and the organisation's directory as a collection, since 6.3.4) and calendars (since 6.3.4, with shared calendars and free-busy-query), authenticated as the account over HTTP Basic, with app passwords, lockout and auto-ban as an IMAP logon |
CardDavServer, CalDavServer
|
| GET | /.well-known/openpgpkey/... |
OpenPGPWebKeyDirectoryEnabled, and the domain's and the account's publishing switches (all off by default) |
the OpenPGP Web Key Directory, both methods (the advanced one under openpgpkey.<domain>); any other method, or a key that is not there, the same 404. 6.3.4 (schema 6050) |
HandleWebKeyDirectory_ |
| GET | /smime-ca/... |
a domain with an S/MIME authority | the authority's root and its CRL, anonymously and over either listener, for a client following an issued certificate's distribution point. 6.3.4 (schema 6095) | HandleSmimeAuthority_ |
| GET |
/mail/config-v1.1.xml, /.well-known/autoconfig/mail/config-v1.1.xml
|
AutoconfigEnabled=1 (default 1) |
Thunderbird clientConfig XML; domain from autoconfig.<domain> Host, else emailaddress= query, else client host; authentication is password-cleartext, username %EMAILADDRESS%
|
905-911, 1473-1543 |
| GET |
/email.mobileconfig, /mail/config.mobileconfig
|
AutoconfigEnabled=1 |
Apple com.apple.mail.managed profile (application/x-apple-aspen-config), IMAP preferred over POP3, needs both an incoming and an SMTP port else 404; stable name-based UUIDs; address pre-filled only from a plausible emailaddress= query; served over HTTPS only since 6 Sep 2026 - X-Forwarded-Proto: https from a TLS-terminating proxy counts, otherwise 301 to the WebServicesHttpsPort listener when configured and 403 with the reason when not |
913-918, 1658-1802 |
| POST or GET |
/autodiscover/autodiscover.xml (case-insensitive) |
AutoconfigEnabled=1 |
Outlook POX Autodiscover response with IMAP/POP3/SMTP <Protocol> blocks; LoginName from the POX <EMailAddress>. 6.3.4, a request that signs in over HTTPS as the address it asks about is also answered one AlternativeMailbox per account that opened a folder to it - which Outlook reads for an Exchange account and not for an IMAP one |
920-925, 1545-1622; HandleAutodiscover_
|
| any | other | - | 404 not found (text/plain) |
927 |
-
CalDavRedirectUrlandCardDavRedirectUrlare read throughIniFileSettings(accessors added with the HTTP foundation, new in 6.2.28; IniFileSettings.cpp:492-493) and cached for 60 s by the listener (source: WebServicesServer.cpp:67, 1062-1090, 1119-1140). -
MtaStsPolicyMode(defaultenforce; acceptedenforce|testing|none, anything else -> enforce),MtaStsPolicyMaxAge(default 604800; clamped to 86400..31557600),MtaStsPolicyMx(comma list overriding the mx lines; default empty = the domain's live MX records, cached 1 h, falling back to the server host name) (source: IniFileSettings.cpp:608-610; WebServicesServer.cpp:44, 1025-1103, 1128-1137). -
MtaStsEnabled(default 1) is the OUTBOUND "honour recipient policies" switch and is unrelated to hosting; hosting isMtaStsHostingEnabled(source: IniFileSettings.cpp:402, 607). -
AutoconfigClientHost(default empty =Settings.HostName) is the host name written into autoconfig/autodiscover/mobileconfig and used as the SRV target; ports per protocol are chosen from the configured TCP/IP ports, ranked implicit TLS (100) > STARTTLS required (90 on 587, else 80) > STARTTLS optional (70/60) > plain (10) (source: IniFileSettings.cpp:612; WebServicesServer.cpp:1349-1442; RestApiServer.cpp:3218-3225). - Autoconfig reads the configured port table, not the running listeners, so a port added since the last restart is advertised early (source: RestApiServer.cpp:3210-3217 comment; WebServicesServer.cpp:1394).
- DNS the README requires per domain:
mta-sts.<domain>,autoconfig.<domain>,autodiscover.<domain>pointed at the server and included inAcmeDomains(source: README.md:358).
- ACME settings:
AcmeEnabled(default 0),AcmeDirectoryUrl(defaulthttps://acme-v02.api.letsencrypt.org/directory),AcmeContactEmail(empty),AcmeDomains(empty),AcmeCertificateDirectory(empty =<DataDirectory>\ACME),AcmeHttpPort(default 80),AcmeReuseKey(default 1) (source: IniFileSettings.cpp:595-601; Server/Common/Util/AcmeClient.cpp:248-256). - Certificates are written as
fullchain.pem/privkey.pem(plusaccount.key) in that directory (source: AcmeClient.h:4-8, 79-80). - http-01 challenges: if the web-services HTTP listener owns
AcmeHttpPort, it serves them via/.well-known/acme-challenge/; otherwiseAcmeClientstarts a transientAcmeChallengeServeronAcmeHttpPortfor the duration of the issuance (logged at startup whenAcmeEnabled=1and the ports differ) (source: AcmeClient.cpp:1066-1079; WebServicesServer.cpp:361-364, 435-441). - The REST listener and the web-services HTTPS listener both fall back to the ACME certificate when no certificate of their own is configured (source: Application.cpp:513-527; WebServicesServer.cpp:599-610).
- After an issuance or renewal writes the files,
AcmeClientlogs "ACME: Restarting servers to load the new certificate." and reinitialises the application (StopServers/InitInstance/StartServers), which restarts the mail listeners and the metrics, REST and web-services listeners; the REST and web-services HTTPS listeners pick up the newfullchain.pem/privkey.pemat that restart, so a REST listener that refused to start withRestApiBindAddressoff loopback and no certificate yet is started once ACME has issued one (source: AcmeClient.cpp:1655-1657; Reinitializator.cpp:63; Application.cpp:501-575, 913-935, 981-1004).
- Enabled by
MetricsServerPort > 0; bindMetricsServerBindAddress(default127.0.0.1) (source: Application.cpp:495-500; IniFileSettings.cpp:426-427). - Only GET is recognised; paths are matched whole and case-insensitively; query strings stripped (source: MetricsServer.cpp:1224-1264, 913-928).
| Path | Auth | Returns | Source (MetricsServer.cpp) |
|---|---|---|---|
/livez |
never | 200 alive - no dependency checks |
930-936 |
/readyz |
never | 200 ready when ServerStatus is Running AND the DB pool is populated AND the last DB probe succeeded AND it succeeded within 20 s; else 503 not ready: <reason>
|
937-944, 2304-2364; constants 99, 108 |
/healthz |
never | JSON {"status","state"(running/starting/stopping/stopped/unknown),"database","uptime_seconds"}; 200 when running and DB answering, else 503; session counts deliberately NOT included |
945-950, 2367-2440 (document at 2408-2413) |
/metrics |
see rules | Prometheus text exposition text/plain; version=0.0.4
|
951-976 |
| other | - | 404, empty body | 978-981 |
- The DB probe is a real
select * from hm_dbversionevery 5 s on a refresher thread; nothing on the request path touches the database or the file system (source: MetricsServer.cpp:99, 1824-1986; MetricsServer.h rules 5-6). One exception since 6.3.4:hmailserver_store_bytesandhmailserver_store_messagescome fromReports::CachedStoreTotals, an aggregate overhm_messagescached for 3,600 s. The metric-history sampler reads the same cache (MetricsHistoryTaskcallsCachedStoreTotals, not the query) and nothing refreshes it in the background, so whichever caller finds it older than an hour runs the aggregate on its own thread: once an hour, with or without the history, that caller can be a scrape on the listener thread (source: MetricsServer.cppBuildMetricsBody_; MetricsHistoryTask.cppCachedStoreTotals; Reports.cppCachedStoreTotals,StoreCacheSeconds). -
/metricsaccess rules: on a loopback bind (all of 127.0.0.0/8 and::1) it is open unless a credential is configured; on a non-loopback bind with NO credential it answers 503 with a body namingMetricsServerAuthToken/MetricsServerAuthUsername+MetricsServerAuthPassword(the listener still starts and the probes still answer) (source: MetricsServer.cpp:301-470, 632-675, 1415-1460). - Credentials:
Authorization: Bearer <MetricsServerAuthToken>and/orAuthorization: Basic base64(<MetricsServerAuthUsername>:<MetricsServerAuthPassword>); when both are configured either satisfies; token and user name are trimmed, the password is not; setting only one half of the Basic pair disables Basic and logs a warning (source: MetricsServer.cpp:344-365, 1311-1392). - A wrong credential is answered 401 and counted in
hmailserver_metrics_unauthorized_requests_total; it does NOT feed auto-ban (source: MetricsServer.cpp:962-972, 1394-1413; MetricsServer.h "What is deliberately NOT here"). - TLS on this port is opt-in: both
MetricsServerCertificateFileandMetricsServerPrivateKeyFilemust be set; then the WHOLE port is HTTPS, probes included; if TLS is configured but cannot be prepared, the probes are served over plain HTTP and/metricsanswers 503 (source: MetricsServer.cpp:367-390, 462; MetricsServer.h rule 3). - Non-loopback bind with a credential but no TLS logs a clear-text warning but still starts (source: MetricsServer.cpp:404-413).
- Startup log line states the access shape, e.g. "MetricsServer: Listening on 127.0.0.1:8080 over plain HTTP. /metrics is open, loopback bind only. /livez, /readyz and /healthz are always served without authentication." (source: MetricsServer.cpp:510-530).
- Metric families served (names as in code):
hmailserver_auth_failures_total,hmailserver_auth_success_total,hmailserver_build_info,hmailserver_command_processing_seconds(histogram),hmailserver_database_connected,hmailserver_database_probe_age_seconds,hmailserver_database_probe_success_timestamp_seconds,hmailserver_db_connections{state=busy|available},hmailserver_db_query_seconds(histogram),hmailserver_db_slow_queries_total,hmailserver_delivery_queue_messages,hmailserver_delivery_queue_oldest_message_age_seconds,hmailserver_domain_messages_received_totalandhmailserver_domain_messages_sent_total(only withMetricsPerDomainEnabled=1),hmailserver_messages_bounced_total,hmailserver_messages_deferred_total,hmailserver_messages_delivered_total,hmailserver_messagestore_missing_files,hmailserver_metrics_unauthorized_requests_total,hmailserver_processed_messages_total,hmailserver_sessions,hmailserver_spam_messages_total,hmailserver_start_time_seconds,hmailserver_state{state=...},hmailserver_store_bytesandhmailserver_store_messages(gauges, since 6.3.4),hmailserver_tls_certificate_expiry_seconds,hmailserver_tls_handshake_failures_total,hmailserver_tls_handshakes_total,hmailserver_viruses_removed_total,hmailserver_workqueue_blocking_tasks_waiting,hmailserver_workqueue_depth(source: grep ofline.Format("hmailserver_in MetricsServer.cpp; histograms at 1769, 1774; per-domain gate at 1598). There is nohmailserver_database_upfamily - the name appears only in a comment and as a local variable in the/healthzhandler. - Queue depth/age and certificate expiry are read from caches refreshed on demand by the refresher thread, so an unscraped listener costs nothing (source: MetricsServer.cpp:1988-2216).
flowchart TD
A["GET /metrics arrives"] --> B{"Was the bind address a loopback address?"}
B -- yes --> C{"Is a credential configured?"}
C -- no --> OK1["200 - open, because only this machine can connect"]
C -- yes --> D{"Does the Authorization header match?"}
B -- no --> E{"Is a credential configured?"}
E -- no --> F["503, and the body names MetricsServerAuthToken and MetricsServerAuthUsername plus Password. The listener still runs and the probes still answer."]
E -- yes --> D
D -- yes --> G{"Is TLS configured but broken?"}
D -- no --> H["401, counted in hmailserver_metrics_unauthorized_requests_total. Does NOT feed auto-ban."]
G -- yes --> I["503 - serving the exposition in the clear on a listener asked to encrypt would publish it, and any credential sent with it"]
G -- no --> OK2["200 text/plain version=0.0.4"]
-
/livez,/readyzand/healthzare outside this decision entirely: they are never authenticated, on any bind, in any TLS state (source: MetricsServer.cpp:930-950; MetricsServer.h "What is deliberately NOT here"). -
Loopback here means the whole of 127.0.0.0/8 and
::1, not the three exact literals the REST listener's TLS exemption uses (source:IsLoopbackAddress_).MetricsServerBindAddress=localhostis not a loopback bind and not a bind at all: this listener parses its address withinet_pton, which takes literals only, so the name is refused as "Invalid bind address" - unlikeRestApiBindAddress, whereHttpServer::Listentranslateslocalhostto127.0.0.1(source:IsLoopbackAddress_comment;MetricsServer::Start;HttpServer::Listen). -
Half a Basic credential (only the user name, or only the password) disables Basic and logs "MetricsServerAuthUsername and MetricsServerAuthPassword must both be set for HTTP Basic authentication. Only one of them is set, so Basic authentication is NOT enabled." Half a TLS configuration logs the equivalent line and leaves the port in the clear — both messages exist because the silent alternative is a listener whose operator believes it is protected (source: MetricsServer::Start).
-
The bind address is validated before any of this, so a typo is reported as "Invalid bind address" rather than as "not loopback, and no credential is configured" (source: MetricsServer::Start comment).
-
Related OTel settings (same counters pushed over OTLP/HTTP):
OtelEndpoint(traces, empty = off),OtelMetricsEndpoint,OtelLogsEndpoint,OtelServiceName(defaulthmailserver),OtelMetricsInterval(default 60 s) (source: IniFileSettings.cpp:448-452; README.md:87). -
A Grafana dashboard for these metrics ships at
hmailserver/docs/grafana-dashboard.json(source:ls hmailserver/docs).
- Type library
hMailServer(uuid DB241B59-A1B1-4C59-98FC-8D101A2995F2, version 1.0, helpstring "hMailServer Type Library"); 94 coclasses in 6.3.3 and 100 in 6.3.4, with 1,375HRESULTdeclarations then, 1,877 at master540347500and 1,884 in 6.3.4; the root coclass isApplication(CLSID D6567EF8-0A6C-48E7-9288-A2463123C2F3, default interfaceIInterfaceApplication) (source: hMailServer.idl:3218-3223, 3446-3453;grep -c coclass= 94 at v6.3.3 and 100 at540347500). - ProgID
hMailServer.Applicationis what scripts and the Control Panel instantiate (CreateObject("hMailServer.Application")in VBScript,New-Object -ComObject 'hMailServer.Application'in PowerShell,Type.GetTypeFromProgID("hMailServer.Application"[, host])in the Control Panel) (source: Addons/Utilities/StartBackup.vbs:15; Tools/ControlPanel/Services/ServerSession.cs:431-432; legacy doc ch. 23). - All interfaces are
dualIDispatch interfaces, so late binding (VBScript, PowerShell,dynamic) works without registering the type library; early-bound clients depend on vtable order, which is why new members are always appended (source: hMailServer.idl:1678-1686 comment; README.md:146). - The installer component
admintoolsregisters the type library "so scripts can administer a remote instance"; the Control Panel does not need it (binds late) (source: README.md:146). - Every session starts with
Application.Authenticate(Username, Password)returning anAccount(null on failure); the administrator logs in asAdministratorwith[Security] AdministratorPassword;AuthenticateWithCode(Username, Password, Code)is the TOTP variant (source: hMailServer.idl:1672, 1687; ServerSession.cs:441). -
IInterfaceApplicationmembers:Start,Stop,Settings,Domains,ServerState(eServerState: 0 Unknown, 1 Stopped, 2 Starting, 3 Running, 4 Stopping),Database,Utilities,SubmitEMail,Status,Version,Connect,InitializationFile,Reinitialize,Rules(global),BackupManager,GlobalObjects,Authenticate,Links,Diagnostics,VersionArchitecture,AuthenticateWithCode,AdministratorTOTPEnabled(true while the administrator credential has a second factor enrolled; readable before authenticating; 6.2.27) (source: hMailServer.idl:1688-1721; enum at 153-168). - Object tree (collections -> items):
-
Application.Domains->Domain(Name,Active,Postmaster,MaxSize,MaxMessageSize, plus-addressing, greylisting,Save,Delete) ->Accounts,Aliases,DistributionLists,DomainAliases(source: hMailServer.idl:800-919, 1698-1717). -
DomainsandAccountscollections offerItem,Count,Add,Refresh,ItemByDBID,ItemByName/ItemByAddress,DeleteByDBID(source: hMailServer.idl:919-938, 1698-1717). -
Accountmembers includeAddress,Password,Active,MaxSize,Size,QuotaUsed,AdminLevel,Messages,IMAPFolders,Rules,FetchAccounts, vacation/forwarding/signature properties,SieveScript,AppPasswords,TOTPEnabled,EnrolTOTP,DisableTOTP,SpamMarkThreshold,SpamDeleteThreshold,ExportMessages,ValidatePassword,UnlockMailbox,Save,Delete(source: hMailServer.idl:939-1044; 1010-1014). -
Application.Settingsis the root for server-wide settings and sub-objects:AntiSpam(which ownsQuarantine),AntiVirus,Backup,Cache,Directories,Groups,IncomingRelays,Logging,MessageIndexing,PublicFolders,Routes,Scripting,SecurityRanges,ServerMessages,SSLCertificates,TCPIPPorts, plus ~150 scalar properties (HostName, MaxMessageSize, SMTPRelayer*, TlsVersion1xEnabled, IMAP*, etc.) andSetAdministratorPassword,GetIniSetting/SetIniSetting/DeleteIniSetting/IniSettingNames(source: hMailServer.idl:550-800; Quarantine at 2595). -
Application.GlobalObjects->DeliveryQueue(Clear,ResetDeliveryTime,StartDelivery,Remove),Languages,MessageTrace(source: hMailServer.idl:2351-2386). -
Application.Status->UndeliveredMessages(tab-separated text),StartTime,ProcessedMessages,RemovedViruses,RemovedSpamMessages,SessionCount(eSessionType),ThreadID, and - new in 6.2.28 - the live update:UpdateState(0 no check since the service started, 1 latest release, 2 a newer release is available, 3 its installer is downloaded and verified, 4 installing, 5 the last check failed; since 6.3.6 also 6 while the service prepares an administrator's install and 7 when it is ready),AvailableVersion,AvailableVersionPublished(ISO 8601 UTC),AvailableVersionUrl,UpdateLastChecked,UpdateLastError,CheckForUpdate()(reads the feed now, no opt-in needed),DownloadUpdate()(fetches the installer and its Sigstore bundle into<DataDirectory>\Updatesand verifies it; nothing is run),UpdateInstallerPath,UpdateSignerIdentity,InstallUpdate()(hands the verified installer tohMailServer.Updater.exe, which runs it, waits for the service and rolls back if it does not return),UpdateApplyOutcome(<status> <version>: <detail>, status ok/failed/rolled-back/rollback-failed/no-rollback) (source: hMailServer.idl:532-551; eSessionType: 1 SMTP, 2 SMTPClient, 3 POP3, 4 POP3Client, 5 IMAP at 362-376). -
Application.Utilities-> DNS/validation helpers (GetMailServer,IsValidEmailAddress,IsValidDomainName,IsValidIPAddress,ResolveMXRecords), hashing (MD5,SHA256, Blowfish),MakeDependent,ImportMessageFromFile[ToIMAPFolder],EmailAllAccounts,PerformMaintenance,CheckSieveSyntax,EvaluateSieveScript,SendTlsRptReports,SendDmarcReports,EraseAddressTraces(source: hMailServer.idl:1168-1211). Since 6.3.6BlowfishEncrypt,BlowfishDecrypt,ResolveMXRecords,GetMailServer,IsLocalHost,CheckSieveSyntax,EvaluateSieveScriptandCriteriaMatchanswer only a caller that has calledAuthenticate(an account of any level or the administrator), or an event script in the service, with "You do not have access to this property / method" otherwise - throughApplication.Utilitiesor ahMailServer.Utilitiescreated directly, which cannot sign in.MD5,SHA256,GenerateGUID,IsStrongPassword,IsValidEmailAddress,IsValidIPAddressandIsValidDomainNamestay open. A server with no administrator password and no second factor answers every caller (item 1 of 6.3.6's "What changes on upgrade"). Since 6.3.6 an IPv4 address is four decimal parts and an IPv6 address is written as RFC 4291 writes one, with a zone after a%, and nothing else, on Windows as on Linux:192.168.1,3232235777,0x7f.1, a part with a leading zero (010.0.0.1), an address with a port and an IPv6 address in brackets are refused where they are entered (REST 400; COM, and so the Control Panel's IP ranges, incoming relays and white list, with the reason - where COM stored 0.0.0.0 for a text it could not read) (item 42). -
Application.Database->ExecuteSQL,ExecuteSQLWithReturn, transactions,RequiresUpgrade,CreateInternalDatabase/CreateExternalDatabase,EnsurePrerequisites,IsConnected,DatabaseType(eDBtype: 1 MySQL, 2 MSSQL, 3 PostgreSQL, 4 MSSQLCE) (source: hMailServer.idl:1045-1078, 107-126). -
Application.BackupManager->StartBackup,LoadBackup(source: hMailServer.idl:2289-2302). -
Application.Links->Domain(DBID),Account(DBID),Alias(DBID),DistributionList(DBID)(source: hMailServer.idl:3100-3106). -
Application.Diagnostics->DiagnosticResults->DiagnosticResult(source: hMailServer.idl:3178-3220).
-
Every node below is a coclass or property named in hMailServer.idl; the tree is the one
the bullets above list, in the shape a script walks it.
flowchart LR
APP["Application - ProgID hMailServer.Application"]
APP --> AUTH["Authenticate / AuthenticateWithCode"]
APP --> DOMS["Domains"]
APP --> SET["Settings"]
APP --> GLOB["GlobalObjects"]
APP --> STAT["Status"]
APP --> UTIL["Utilities"]
APP --> DB["Database"]
APP --> BK["BackupManager"]
APP --> LNK["Links"]
APP --> DIAG["Diagnostics"]
APP --> RUL["Rules - global"]
DOMS --> DOM["Domain"]
DOM --> ACCS["Accounts"]
DOM --> ALS["Aliases"]
DOM --> DLS["DistributionLists"]
DOM --> DALS["DomainAliases"]
ACCS --> ACC["Account"]
ACC --> MSGS["Messages"]
ACC --> FOLD["IMAPFolders"]
ACC --> ARUL["Rules"]
ACC --> FETCH["FetchAccounts"]
ACC --> APW["AppPasswords"]
SET --> AS["AntiSpam"]
AS --> QUAR["Quarantine"]
SET --> AV["AntiVirus"]
SET --> SBK["Backup"]
SET --> CACHE["Cache"]
SET --> DIRS["Directories"]
SET --> GRPS["Groups"]
SET --> IREL["IncomingRelays"]
SET --> LOGG["Logging"]
SET --> MIDX["MessageIndexing"]
SET --> PF["PublicFolders"]
SET --> ROUTES["Routes"]
SET --> SCR["Scripting"]
SET --> SRNG["SecurityRanges"]
SET --> SMSG["ServerMessages"]
SET --> SSL["SSLCertificates"]
SET --> PORTS["TCPIPPorts"]
SET --> INI["GetIniSetting / SetIniSetting / DeleteIniSetting / IniSettingNames"]
GLOB --> DQ["DeliveryQueue"]
GLOB --> LANG["Languages"]
GLOB --> MT["MessageTrace"]
STAT --> UND["UndeliveredMessages"]
STAT --> SESS["SessionCount by eSessionType"]
STAT --> UPD["Update - state, versions, CheckForUpdate, DownloadUpdate, InstallUpdate"]
DIAG --> DRES["DiagnosticResults"]
DRES --> DR["DiagnosticResult"]
-
Collections (
Domains,Accounts,Aliases,DistributionLists, …) share the same members:Item,Count,Add,Refresh,ItemByDBID,ItemByNameorItemByAddress,DeleteByDBID(source: hMailServer.idl:919-938, 1698-1717). -
Enumerations a script will meet:
eServerState0 Unknown, 1 Stopped, 2 Starting, 3 Running, 4 Stopping (idl:153-168), and since 6.3.4 5 Draining (hStateDraining);eSessionType1 SMTP, 2 SMTPClient, 3 POP3, 4 POP3Client, 5 IMAP (idl:362-376);eDBtype1 MySQL, 2 MSSQL, 3 PostgreSQL, 4 MSSQLCE (idl:107-126);Status.UpdateState0-5 as in section 4b, and since 6.3.6 also 6 (an administrator's install being prepared) and 7 (ready). -
The same information reaches a non-Windows client over REST:
StatusisGET /api/v1/status,GlobalObjects.DeliveryQueueis/api/v1/queue,Settings(a redacted subset) is/api/v1/settings,Domains/Accountsare the domain and account routes, andStatus.Update*are the four update routes. -
COM rights: methods that require server-administrator rights return an error (since 6.2.10 they no longer return S_OK on refusal) (source: legacy doc ch. 23 "Authorization note"; unverified against code by this reader - see Unconfirmed).
-
Members added in 6.2.25-6.2.27 (all appended, vtable order kept):
Settings.CreateDefaultSpecialUseFoldersEnabled(create Drafts/Sent/Trash/Junk designated for RFC 6154 at account creation; off by default),Settings.EnrolAdministratorTOTP()(returns theotpauth://URI; enforced at once on COM and REST) andSettings.DisableAdministratorTOTP();Domain.MessageRetentionDays(0 = no policy) andAccount.MessageRetentionDays(0 = the domain's policy, -1 = keep forever);Utilities.RunMessageRetention()(runs the sweep now, returns the count removed);FetchAccount.ServerType1 = IMAP andFetchAccount.MirrorFolders(IMAP only: every remote folder into a local folder of the same name, a migration);AntiSpam.TarpitDelay/TarpitCountnow carry the recipient tarpit (SmtpTarpitDelaySeconds/SmtpTarpitCountin hMailServer.ini) instead of being obsolete;Diagnostics.AssertionsEnabled,Diagnostics.TriggerAssertion(),Diagnostics.DnssecChainStatus(Name, Type)(0 secure, 1 insecure, 2 bogus). New in 6.2.28:Diagnostics.AcmeRenewalTime(notBefore, notAfter)(source: hMailServer.idl:808-811, 854-855, 988-989, 1239, 1854-1855, 1889-1890, 2576-2579, 3221-3224). -
Live update (new in 6.2.28):
UpdateCheckEnabled(default 0),UpdateChannel(defaultstable;prereleasereads the last ten releases),UpdateFeedUrl(empty =https://updates.progressiverobot.com/releases/latestsince 6.3.4, which GitLab Pages serves from the project's releases; up to 6.3.3https://api.github.com/repos/Progressiverobot/hmailserver/releases/latest, which has answered 404 since GitHub suspended the project's account on 18 September 2026),UpdateCheckHours(default 24, floor 1),UpdateWindow(empty = no unattended apply),UpdateAutoDownload(default 0),UpdateBackupBeforeApply(default 1; no backup destination = no unattended apply),UpdateTrustRootsFile,UpdateLogPublicKeyFile,UpdateSigningIdentity,UpdateSigningIssuer,UpdateSourceRepository,UpdateRequireAuthenticode(default 0),UpdateServiceWaitSeconds(default 180, floor 5) (source: IniFileSettings.cpp:489-509). A scheduled task runs once at start and every 15 minutes, does nothing untilUpdateCheckEnabled=1, reads the feed everyUpdateCheckHours, and applies insideUpdateWindowafter a backup when asked to (source: Application.cpp:695-717; Common/Application/UpdateCheckTask.cpp:139-186; Common/Util/UpdateChecker.cpp:26-27, 331). Downloads land in<DataDirectory>\Updates(rollback image inUpdates\rollback); the apply is run byhMailServer.Updater.exe; the database upgrade authenticates with a single-use token revoked at every service start (source: UpdateInstaller.cpp:26, 52, 61; Application.cpp:702-707). Every web request the feature makes - the feed and the installer and bundle downloads - goes through the forward proxy named by[Settings] HttpProxywhen one is set (host:port, or[ipv6]:port; empty = direct; a value without a port is an error, and there are no proxy credentials): CONNECT for an https target with the TLS handshake and the usual certificate verification inside the tunnel, the absolute URL in the request line for plain http, and a refusal reported asThe proxy <proxy> refused CONNECT to <host:port>: <its status line>. The same setting carries the JWKS fetch and token introspection, which share the client (source: HttpsClient.cpp:86-186, 321-328, 523-529; IniFileSettings.cpp:492; new in 6.2.28). Surfaces:GET /api/v1/update,POST /api/v1/update/check|download|install(section 4) andStatus.Update*(above). Every key is in the Settings Reference section 9.15. -
Members added in 6.3.4, read from
hMailServer.idlat master540347500and again at92b8f9f63against v6.3.3 (every one appended; The COM API and Scripting section 23.3a has the same table):
| Interface | Members added in 6.3.4 (appended, so the vtable order of 6.3.3's members is kept) |
|---|---|
Application |
Roles, CurrentRole, CurrentPermissions, RoleAssignments, AssignRole, ResetPassword, UnlockAccount (administrator roles) |
Settings |
RemoteDomainPolicies; the breach check (BreachCheckEnabled, BreachCheckUrl, BreachCheckMinimumCount, BreachCheckUnreachableAction, TestBreachCheckEndpoint); the quarantine digest (QuarantineDigestBaseUrl, QuarantineDigestLinkHours); bounces (BounceProcessingEnabled, BounceHardBounceThreshold, BounceMailboxFullIsHard, BounceSuppressionAction, BounceSuppressionExpiryDays, BounceReadDeliveryReports, BounceCountRetentionDays, BounceReportWindowDays); Web Push (WebPushEnabled, WebPushSubject, WebPushVapidPublicKey, GenerateWebPushKeys); QuotaGracePercent, QuotaSendStopPercent; OpenPGP publishing (OpenPGPWebKeyDirectoryEnabled, OpenPGPKeyMaximumBytes, OpenPGPKeysPerAccount, OpenPGPRequireAddressInUserId); webhooks (WebhooksEnabled, WebhookMaxAttempts, WebhookMaxPerMinute, WebhookRetentionDays); country blocking (GeoIpDatabaseFile, GeoIpDatabaseMaxAgeDays, CountryBlockingExemptRanges, CountryPolicy); session policy (SessionIdleSeconds, SessionLifetimeSeconds, SessionOnePerDevice, SessionBindToAddress); complaints (ComplaintProcessingEnabled, ComplaintMailbox, ComplaintSuppressionThreshold, ComplaintRetentionDays); GetChangedFromDefault, GetSettingDefault; the reputation watch (ReputationWatchEnabled, ReputationWatchLists, ReputationWatchAddresses); spam traps (SpamTrapBanMinutes, SpamTrapBlockDays, SpamTrapHitRetentionDays, SpamTrapTeachSpamAssassin); HelpWikiBaseUrl; outbound shaping (OutboundBackoffMinutes, OutboundBackoffMaxMinutes, MaxOutboundConnectionsPerDestination, MaxOutboundConnectionsPerTenant); a save that applies (IniSettingApplies, IniSettingAppliesAll, PendingIniSettings); profiles (GetProfiles, GetProfile, ApplyProfile, RevertProfile, DiscardProfileSnapshot, AppliedProfile, DeclineProfiles); the attachment sandbox (SandboxEnabled, SandboxSubmitUrl, SandboxVerdictUrl, SetSandboxToken, SandboxTimeoutMinutes, SandboxTimeoutAction, SandboxMaxSizeKB, SandboxTypes, SandboxPollSeconds, SandboxScoreThreshold, SandboxCacheHours, SandboxInternalMail, SandboxMaxFiles); storage compression (MessageStoreCompression, MessageStoreDedupMinKB, and the read-only MessageStoreStatus, the JSON GET /api/v1/store answers) |
Domain |
Rules (a domain's own rules); the out-of-office window VacationDays; the quota policy (QuotaWarningPercent, QuotaWarningSubject, QuotaWarningText, QuotaGracePercent, QuotaSendStopPercent); the domain transforms (ExternalTagSubject, ExternalTagHeader, ExternalTagText, FirstContactTip, DisclaimerEnabled, DisclaimerPlainText, DisclaimerHTML); SenderList, QuarantineDigest, QuarantineDigestHour; DKIM rotation (DKIMRotationEnabled, DKIMRotationIntervalDays, DKIMRotationPropagationHours, DKIMRotationRetentionDays, DKIMRotationKeyBits, DKIMRotationStatus, DKIMRotationStart, DKIMRotationCheck, DKIMRotationSwitch, DKIMRotationRetire, DKIMRotationCancel); CountryPolicy; OpenPGPPublishKeys, OpenPGPPublishNewAccounts; WebhooksEnabled, WebhookSubjects; ReportMailbox; RestrictedDomains; impersonation protection (ImpersonationAction, ProtectedPersons, ProtectedPartners); SpamTraps, AddSpamTrap, RemoveSpamTrap; levels (SettingOverride, EffectiveSetting, EffectiveSettingSource) |
Account |
mailbox move (MoveTo, InUseReason, LastMove); Web Push (PushSubscriptionCount, RemovePushSubscriptions); the out-of-office policy (VacationMessageDays, VacationInternalSubject, VacationInternalMessage, VacationMessageInternalOnly); ExportArchive; devices (GetDevices, RevokeDevice); its role (RoleName, RoleDomains, RolePermissions); OpenPGPPublish, OpenPGPKeys; recovery (RecoveryAddress, which answers the address masked, RecoveryAddressVerified, RecoveryCodesUnused, ClearRecoveryAddress, ClearRecoveryCodes); the directory (DirectoryTitle, DirectoryDepartment, DirectoryPhone, DirectoryMobile, DirectoryOffice, DirectoryOptOut, DirectoryNote, DirectoryPhoto, DirectoryPhotoType); ZeroAccess; RestrictedDelivery; levels (SettingOverride, EffectiveSetting, EffectiveSettingSource); ExternalId (the identity provider's id for an account SCIM made); Kind (dispid 88: 0 a person, 1 a meeting room, 2 a piece of equipment, saved by Save; the booking policy itself is REST only) |
Utilities |
RunReputationCheck, GetReputationReport; GetSpamTrapHits, ClearSpamTrapHits; GetJournalRules, SaveJournalRule, DeleteJournalRule; GetSessionPolicy, SetDomainSessionPolicy; LookupCountry, GeoIpDatabaseStatus; RunDkimRotation; GetWebhookSubscriptions, SaveWebhookSubscription; GetSuppressionList, AddSuppression, RemoveSuppression, GetBounceCounts, ClassifyBounce, RunBounceMaintenance; GetComplaints, GetComplaintRates; ImportOpenPGPKey, RemoveOpenPGPKey; GenerateWebPushKeyPair, WebPushDecrypt; GetAuditTrail, VerifyAuditTrail, RevertAuditedChange; EraseAddressLogLines; GetSetupChecklist; GetSampleDomain, CreateSampleDomain, RemoveSampleDomain; MoveMailbox, GetMailboxMoves; BackupArchiveIsEncrypted
|
AntiSpam |
SenderList, SendQuarantineDigests, SendQuarantineDigestsAtHour; greylisting (GreyListingBypassEntries, GreyListingSlideOnEarlyRetry, GreyListingAbuseThreshold, GreyListingAbuseLockMinutes); SMTP policy (UseSPFSoftFailScore, UseSPFTempErrorScore, UseSPFPermErrorScore, SPFTempErrorDefer, RejectMultiRcptNullSender, BanMultiRcptNullSender, BanMultiRcptNullSenderHours, DomainPolicyList, AddDomainPolicyListEntry, RemoveDomainPolicyListEntry) |
Logging |
syslog (SyslogEnabled, SyslogHost, SyslogPort, SyslogTransport, SyslogFacility, SyslogMinimumSeverity, SyslogLogSMTP, SyslogLogPOP3, SyslogLogIMAP, SyslogLogApplication, SyslogLogTCPIP, SyslogLogDebug) |
DistributionList |
Membership (a list that defines itself); the list manager (Managed, SubscribePolicy, Archive, ArchiveDays, Digest, DigestHour, ReplyToList, SubjectPrefix, Footer, Verp, Owner, SendDigest) |
DistributionListRecipient |
Delivery, HardBounces, SoftBounces, Confirmed, ResetBounces
|
BackupManager |
LoadEncryptedBackup; restoring one mailbox or one message (RestoreMailbox, RestoreMessage, DescribeMailbox, MailboxRestoreStatus) |
BackupSettings |
EncryptionPassphrase (write-only), EncryptionPassphraseSet
|
RemoteDomainPolicies, RemoteDomainPolicy (new) |
the collection (Item, Count, Add, ItemByName, ItemByDBID, DeleteByDBID, Refresh, PolicyForDomain, ClearVerificationCache, VerificationCacheSize) and one policy (ID, DomainName, Description, Active, OutboundTls, RequireInboundTls, MaxMessageSizeKB, MaxConnections, MaxMessagesPerMinute, AllowAutomaticReplies, AllowForwarding, the six Callout* properties, Save, Delete, and gateway encryption's Encrypt, EncryptKey, EncryptFallback, EncryptKeyFingerprint, EncryptKeyExpires, EncryptKeySummary) |
SenderListEntries, SenderListEntry (new) |
the collection (Item, Count, Add, ItemByName, ItemByDBID, DeleteByDBID, Refresh, Clear) and one entry (ID, Value, Blocked, Reason, Expires, ExpiresTime, Scope, Save, Delete) |
GreyListingBypassEntries, GreyListingBypassEntry (new) |
the collection (Item, Count, Add, ItemByName, ItemByDBID, DeleteByDBID, Refresh) and one entry (ID, Pattern, MatchOn, RequireSPF, RequireDKIM, RequireDMARC, Note, Save, Delete) |
Enumerations gain hStateDraining (5) in eServerState; the rule criteria eFTAttachmentName (9), eFTSensitiveContent (10), eFTAttachmentType (11), eFTExternalRecipient (12) and eFTContent (13); the rule actions eRAAddDisclaimer (11), eRAReject (12), eRARequireTLS (13), eRAQuarantine (14) and eRAWarnSender (15); and two new enumerations for remote domain policies, the outbound TLS level (eRTNone 0, eRTEncrypted 1, eRTVerified 2, eRTDane 3) and gateway encryption (eREEncryptNone 0, eREEncryptOpenPgp 1, eREEncryptSmime 2). Several features have no COM surface at all and are REST only: SCIM provisioning, SAML and OpenID Connect sign-in, passkeys, the S/MIME authority, the send API, migrations, IP warm-up, the attachment sandbox's held messages, outbound shaping's state and the list of settings with levels, and - added in 6.3.4's last days - eDiscovery, mail in to a webhook, mailbox encryption at rest, a room's booking policy, PST import, the SNMP agent's settings and the object store's settings; the API key store has never been COM-visible. A COM change to a shared configuration object takes effect at Save() since 6.3.4 (commit 15cfff25d): for routes and their addresses, incoming relays, DNS black lists, SURBL servers, remote domain policies, server messages, blocked attachments, SSL certificates and groups, a put_* writes a copy the COM object holds, Save() writes it and reads the collection again (error 7390 when that read fails after a successful save), and a put_* never saved changes nothing - The COM API and Scripting 23.3.
-
Members added in 6.3.6 (read from
hMailServer.idlat the tag v6.3.6; every one appended, so no existing member or dispid moves):Application.CreateMessage(id 30);TCPIPPort.SMTPAuthentication(id 12; 0 On, 1 Off, 2 Honeypot; only On on a POP3 or IMAP port);Status.UpdateNeedsAdministrator,UpdateInstallAdvice,PrepareUpdateForAdministrator,UpdatePreparation,BeginUpdateForAdministrator,CancelUpdateForAdministrator(ids 20-25);Settings.AutoBanNeverBanNames(206),AddAutoBanNeverBanName(207),RemoveAutoBanNeverBanName(208),CheckAutoBanNeverBanName(209),AutoBanWidenByReverseName(210);AntiSpam.RefuseHeloNotFqdn(80),RefuseMailFromDomainNotFqdn(81) andSurblTooManyDomainsScore(82).Diagnostics.DnssecChainStatus(6) now answers 3, indeterminate, for MX and TLSA. Changed:Message.Save(9),Copy(27),Attachments.Add(3),Attachment.SaveAs(3) andUtilities.RevertAuditedChange(63) - who may call them, section 8'sApplication.Utilitiesentry and The COM API and Scripting, Send a message from a script.
- VBScript backup (ships in the repo; replace the password):
Set oApp = CreateObject("hMailServer.Application")/Call oApp.Authenticate("Administrator", sAdminPassword)/Call oApp.BackupManager.StartBackup()(source: Addons/Utilities/StartBackup.vbs:12-20;StartBackupat hMailServer.idl:2289-2302). - VBScript service dependency:
oApp.Utilities.MakeDependent(sName)afterAuthenticate(source: Addons/Utilities/MakeServiceDependent.vbs:4-19;MakeDependentat hMailServer.idl:1175). - Other shipped utilities:
DecryptBlowfish.vbs,EncryptAllPasswords.vbs,LoadAllMessages.vbs,RemoveUnusedAccounts.vbs,ChangeMySQLCollation.sql(source:ls Addons/Utilities). - The PowerShell example in legacy doc ch. 23 uses members that exist (
Domains.Add(),$domain.Name/.Active/.Save(),Accounts.Add(),$account.Address/.Password/.Active/.MaxSize/.Save(),$a.Size) (source: hMailServer.idl:824-870, 965-1030, 1732-1741), but its report line is wrong:Account.Sizeis already in megabytes (bytes / 1,048,576, rounded to three decimals; source: COM/InterfaceAccount.cpp:580-592), so$a.Size / 1MBprints ~0 for every mailbox. Use'{0,-40} {1,8} MB used' -f $a.Address, [math]::Round($a.Size, 1). - Event-script example in legacy doc ch. 23 uses
oMessage.HeaderValue("X-...") = valueandoMessage.Save- both exist onIInterfaceMessage(propputHeaderValue,Save) (source: hMailServer.idl:1079-1130).
- Enabled via
Settings.Scripting.Enabled, languageSettings.Scripting.Language(VBScript or JScript on Windows; since 6.3.4JavaScripton Linux, in the QuickJS engine vendored underServer/quickjs- each build refuses at load a language it does not run, HM5016 or HM5710), script file<events dir>\EventHandlers.<vbs|js>, withReload,CheckSyntax,Directory,CurrentScriptFileonIInterfaceScripting(source: hMailServer.idl:1992-2004; Server/Common/Scripting/ScriptServer.cpp:170, 299). - Hook functions the server looks for, by name:
OnClientConnect,OnAcceptMessage,OnDeliverMessage,OnBackupCompleted,OnBackupFailed,OnDeliveryStart,OnError,OnDeliveryFailed,OnExternalAccountDownload,OnSMTPData,OnHELO,OnClientLogon,OnClientValidatePassword,OnRecipientUnknown,OnTooManyInvalidCommands(source: ScriptServer.cpp:235-249). -
OnAcceptMessageis invoked asOnAcceptMessage(HMAILSERVER_CLIENT, HMAILSERVER_MESSAGE)(source: Server/SMTP/SMTPConnection.cpp:2234). -
Since 6.3.4 (commit
5a3de9001; CVE-2026-100741), every value written into a JScript call is escaped byScriptServer::EscapeForJScriptLiteral- the backslash first, then both quotes, CR, LF, U+2028, U+2029 and NUL. Up to 6.3.3 only the apostrophe was, so a password handed toOnClientValidatePassword, a remote UID toOnExternalAccountDownload, or an error text or recipient toOnDeliveryFailedcould end its string with a backslash and run JScript in the server - a password before the client had authenticated, a UID or an error text as a remote POP3 or mail server answered it;OnBackupFailed's reason is now escaped in both languages (EscapeForVBScriptLiteral). A rule's Run script function name must be a plain identifier (ASCII letters, digits,_,$, not starting with a digit), else HM5018 each time the rule matches and nothing runs. On JavaScript the values are passed to the handler as values (JS_Call), never as source text. The COM API and Scripting 23.6 has what an administrator should do, andhmailserver/docs/EventScripts.mdthe JavaScript contract.
-
OpenAPI document path for API keys is wrong.Resolved:GET /api/v1/openapi.jsonnow documents/api/v1/apikeysand/api/v1/apikeys/{id}, matching the router (RestApiServer.cpp:7627-7630 vs 1554-1556). Items 2 and 3 still stand. -
OpenAPI create-account schema overclaims. It lists
activeandmaxSizeMBproperties;HandleCreateAccount_reads onlyaddressandpasswordand always sets Active=true (source: RestApiServer.cpp:3110 vs 2753-2784). -
OpenAPI
/api/v1/statussays "Status, state and uptime"; the handler returns no uptime field (source: RestApiServer.cpp:3105 vs 2642-2662). - Legacy doc ch. 24 (and line 1448) describe the REST API as "HTTP Basic auth, administrator password" only - Bearer API keys with scope/domain/source/expiry restrictions have existed since Aug 2026 (RestApiServer.h header; RestApiServer.cpp:783-806). The README is now correct (README.md:127, 447).
-
Legacy doc ch. 24 table omits
/api/v1/srv,/api/v1/quarantine[/<id>[/release]],/api/v1/domains/<name>/aliases,/api/v1/openapi.jsonand/api/v1/apikeys; the README's list (README.md:644) now carries all of them,/api/v1/openapi.jsonand the update routes included. -
Legacy doc ch. 24 says "TLS is required unless bound to 127.0.0.1"; the code also exempts
localhostand::1(RestApiServer.cpp:699-701). README.md:364 has the same narrower wording. -
Legacy doc ch. 23 event table lists
OnErrorLog; the hook the server looks for isOnError(ScriptServer.cpp:241). The doc's table also omitsOnBackupCompleted,OnBackupFailed,OnSMTPData,OnHELO,OnClientLogon,OnClientValidatePassword,OnRecipientUnknown,OnTooManyInvalidCommands. - Legacy doc 7.2 says autoconfig is enabled under "Settings > Network > API & monitoring"; in the current Control Panel the web-services settings are on "Settings > Network > Web services & autoconfiguration" (NavigationMap.cs:266, 584; SettingsSearchIndex.g.cs:351-359). Legacy doc ch. 20 (line 1353) likewise lists "web services" under API & monitoring.
-
Legacy doc 16.x says
/readyzis "200 when running with a connected database"; the current check is a real round-trip probe that must have succeeded within the last 20 s (MetricsServer.cpp:108, 2304-2353). README.md:470 is correct on this. -
README.md:120 "Thunderbird autoconfig and Outlook autodiscover are served for every local domain" is true only once
WebServicesHttpPort/WebServicesHttpsPortis non-zero; both default to 0, so the feature is inert by default (Application.cpp:516-533 comment; WebServicesServer.cpp:454-491). Same for MTA-STS hosting (README.md:43), which additionally needs the HTTPS port. -
The WebAdmin page's
renderDomainstolerates either an array or{domains:[...]}; the server returns a bare array (WebAdmin/index.html:249 vs RestApiServer.cpp:2664-2707) - harmless, but the page should not be cited as evidence of the response shape. -
The OpenAPI text for
/api/v1/settings/iniand/api/v1/settings/ini/{name}described the file as the store up to 6.3.3 ("Write one [Settings] key of hMailServer.ini ... Written to the file as Settings.SetIniSetting writes it"; a 500 "The file could not be written"). Since 6.3.4 the handler stores the value inhm_inisettingsfirst and answers 500 only when the database write fails, as its refusal sentences say - forPasswordPepper, kept in the file only, when the file's does - and the OpenAPI text says so too; it still says, without the condition that the service may write the file, that the file's copy is written and an edited line put back (RestApiSettings.cppIniAndLogonFailurePathsvsIniPut,IniWriteRefusal; The Service Account). The descriptions of/api/v1/settings/ini/{name}also still say "Server-wide; refused for domain-restricted keys" - "domain-restricted and read-only keys" for PUT and DELETE - where every API key is refused with 401 (IsApiKeyRoute_), as the list's description says. -
build/check-cli.py's recorded server answeredGET /api/v1/settings/iniwith{"settings":[{"name","value"}]}up to 6.3.3, while the real route answered{"names":[...]}without values, so the check passed whilehmctl configandExport-HmConfigurationproduced an emptyinisection against a real server (build/check-cli.py:88 vs RestApiSettings.cppIniList) - see Command Line. Since 6.3.4 the route answerssettingstoo,{name, value, secret}with a secret's value as(set), the shape the two clients read, and the recorded server answers that shape, held toIniListbycheck_ini_route_shape.
- No document explains the API key store file format, the fail-closed rules, or that keys are manageable from the Control Panel; the only prose is in code comments and the store's own preamble (RestApiServer.cpp:2017-2055, 2493-2521; ApiKeyStore.cs:74-96).
- No document lists the REST rate limit (200/10 s per credential, 429 + Retry-After) or the 5-minute refused-address window after auto-ban (RestApiServer.cpp:158-171, 230-232).
- No document describes
/api/v1/srv(ready-to-publish RFC 6186/8314 SRV records) or/api/v1/openapi.json. - No document describes the security.txt endpoint, the CalDAV/CardDAV redirect settings (
CalDavRedirectUrl,CardDavRedirectUrl), or the Apple.mobileconfigendpoint - all three are code-only (WebServicesServer.cpp at the tag51e2dbda0:HandleSecurityTxt_at 1245,HandleWellKnownDavRedirect_at 1521,HandleAppleProfile_at 2058). - No document states that the metrics port's health probes are never authenticated while
/metricsanswers 503 on a non-loopback bind without a credential, except README.md:367-373 (correct) - the legacy doc predates it. - The legacy doc has no COM object-model reference beyond one PowerShell sample; the IDL is the only complete reference (hMailServer.idl, 3996 lines, 94 coclasses).
-
WhetherConfirmed: megabytes, rounded to three decimals (COM/InterfaceAccount.cpp:580-592); see section 9.Account.Sizeis in bytes or MB -
The legacy doc's claim that fifteen COM methods returned S_OK on refusal before 6.2.10 and now return errors - plausible from the doc, not checked against
Server/COM/*.cpphere. -
Exact JSON field names ofConfirmed:/healthz{"status","state","database","uptime_seconds"}(MetricsServer.cpp:2408-2413). -
Whether
hmailserver/test/WebAdmin test cases/Web form tests.txtreflects the current WebAdmin page (not read). -
Behaviour of the REST listener whenConfirmed from code: it refuses to start at service start, and is started by the reinitialisationRestApiBindAddressis0.0.0.0with only the ACME certificate present but not yet issued at startupAcmeClienttriggers after issuance, which rerunsStartServersand its ACME-certificate substitution (AcmeClient.cpp:1655-1657; Application.cpp:521-538, 981-1004); see 6d. -
WhetherConfirmed: it bindshmailserver/docs/HighAvailabilityRunbook.mdsection 3's metrics example has been updated for the 503-on-non-loopback rule0.0.0.0withMetricsServerAuthTokenset and explains the 503 rule (HighAvailabilityRunbook.md:89-107). -
REST (5 Sep 2026):
GET /api/v1/metrics/history?metric=<name>&range=24h|7d|30dreturns{metric, known, enabled, retention_days, minutes_back, bucket_minutes, samples:[{time, value}]}- the samples of one metric averaged per minute / ten minutes / hour; 400 with the list of metric names for an unknown metric, 400 for an unknown range; in the OpenAPI document. Metric names: sessions_smtp, sessions_imap, sessions_pop3, processed_messages_total, messages_delivered_total, messages_deferred_total, messages_bounced_total, spam_messages_total, viruses_removed_total, auth_success_total, auth_failures_total, tls_handshakes_total, tls_handshake_failures_total, messagestore_missing_files. -
COM (5 Sep 2026):
Utilities.SampleMetricsNow()(long: rows written, -1 when MetricsHistoryDays is 0; also prunes) andUtilities.GetMetricHistory(Metric, MinutesBack, BucketMinutes)(BSTR JSON as above; BucketMinutes 0 = every sample). Server administrators only. -
REST (5 Sep 2026, wave 88): the surfaces that were COM-only.
GET|POST /api/v1/ipranges,DELETE /api/v1/ipranges/{id}(name, lower, upper, priority and the permission booleans as JSON);GET|POST /api/v1/domains/{domain}/lists(address, members[], require_auth) andDELETE /api/v1/lists/{address};GET /api/v1/certificates(id, name, files - never the private key password);GET /api/v1/domains/{domain}/dkim(enabled, selector, sign_aliases, private_key_file);GET /api/v1/rules(the global rules from the delivery cache, criteria as field/header/match/value, actions as type/value);GET /api/v1/logs(name, size, created) andGET /api/v1/logs/{name}?lines=N(the tail, N up to 2000, names without any path only);GET /api/v1/backup(the manager's status text) andPOST /api/v1/backup(202, or 409 with the status when one is running or nothing is configured);GET /api/v1/settings(host name, default domain, size and connection limits, relay host and port, the conversation-logging switches). Authorisation: ranges, certificates, rules, logs, backup and settings are server-wide and refused for domain-restricted keys; lists and DKIM are scoped to the key's domains; the creating and deleting routes are refused for read-only keys. All in the OpenAPI document;API/RestApiCoverage.cs(10 tests) proves each against COM and the disk (source: RestApiServer.cpp ParseRoute_/Authorize_/IsMutatingRoute_). -
Archive index (5 Sep 2026, schema 6029,
hm_archiveindex): every archived copy gets a row (time, domain, mailbox, direction sent/received/inbound, sender, recipients, subject, message-id, path, size, hold). COM:Utilities.SearchArchive(Domain, Mailbox, Sender, Recipient, Subject, Since, Until, HoldOnly, MaxRows)returns a JSON array;Utilities.SetArchiveHold(ArchiveID, Hold)returns False for an unknown row; both server administrators only. REST:GET /api/v1/archive?domain=&mailbox=&sender=&recipient=&subject=&since=&until=&hold=1&limit=(newest first; a domain-restricted key must name one of its domains),GET /api/v1/archive/{id},POST|DELETE /api/v1/archive/{id}/hold(mutating; refused for read-only keys; an Inbound copy belongs to no domain). The retention sweep asks the index before deleting (a held copy stays) and removes the row of a file it deletes; the address eraser removes the rows naming the address except held ones (source: PersistentArchiveIndex.cpp, ArchiveRetentionTask.cpp, AddressTraceEraser.cpp, RestApiServer.cpp; tests Infrastructure/ArchiveIndexTests.cs, API/RestApiArchive.cs). -
External accounts over IMAP (6 Sep 2026):
FetchAccount.ServerType1 = IMAP (0 = POP3).IMAPClientConnection(onExternalFetchClientBase, the message handling shared with POP3) logs in with LOGIN or AUTHENTICATE XOAUTH2 (theFetchOAuth2Hostsrule), STARTTLS per ConnectionSecurity, SELECT INBOX, UID SEARCH ALL, UID FETCH BODY.PEEK[] per message not yet recorded (UIDs kept as<UIDVALIDITY>:<UID>in hm_fetchaccounts_uids), DaysToKeepMessages -1 = STORE \Deleted + EXPUNGE once collected, 0 = left on the server, N = deleted N days after collection - the one meaning both fetchers read since 6.3.6 (FetchAccount::DeletesOnCollection,RemoteCopyDue; item 22 of 6.3.6's "What changes on upgrade"); from 6.2.25 to 6.3.5 the IMAP fetcher read 0 as delete and -1 as keep, and schema 6106 translated each IMAP account's stored value so that it kept doing what it did. Records forgotten when the message leaves the server. INBOX only; other folders are not mirrored. Control Panel: the external-account editor's Server type field.
hMailServer 6.3.7, the latest release (17 October 2026, schema 6108) · AGPL-3.0-or-later · Repository · Mirror on GitHub · Report a documentation error (a GitLab issue naming the page)
Home — full index
Start here
1. Install and run
- Before You Install
- Installing hMailServer
- Installing on Linux
- Running in a Container
- Profiles and Environment
- The Control Panel
- The Control Deck
- Your First Domain and Mailbox
- Connecting a Mail Client
- The Webmail
- DNS for Your Domain
- Migration
2. Secure it
- Encryption and Certificates
- Database TLS
- Encryption for People
- Stopping Spam
- Stopping Viruses
- Security Hardening
- Secure Defaults
- The Service Account
- Secrets at Rest
- Sign-in and Identity
- Security Policy
3. Operate it
- Monitoring and Health
- The SNMP Agent
- Backup and Restore
- Message Storage
- Compliance and eDiscovery
- Troubleshooting
- Diagnosing Stalled Mail
- Relocating an Installation
- Upgrading hMailServer
- Upgrading Guide
- The Update Feed
- Migrating the Database Backend
- High Availability Runbook
- Warm Standby
- Runbooks Digest
4. Extend it
- Rules and Sieve
- Aliases Lists and Public Folders
- Rooms and Equipment
- Mailing Lists
- Routes and Relays
- Mail In to a Webhook
- Outbound Shaping
- The COM API and Scripting
- Event Scripts
- The REST API
- Command Line
- APIs Reference
5. Contribute to it
- Project Handbook
- Architecture
- Contributing
- Release Process
- Governance
- Assurance Case
- Regression Test Environment
- Fuzzing
- Regulatory Scope
- Third-Party Binaries
Look it up — from any journey
- Settings Reference
- Settings Overview
- Capabilities and Configuration
- Control Panel Reference
- Control Panel Pages
- Ports Reference
- Changes-Since-6210
- Version-History-to-6210
- Glossary
About this wiki
- hMailServer 6.3.7 is the latest release (17 October 2026, schema 6108)
- Pages say since 6.3.4 where a feature is new in it
- AGPL-3.0-or-later
- Repository, the one place the project is worked on
- Its mirror on GitHub, since 18 October 2026, with no Issues
- Questions: a GitLab issue with the Question template, GitHub Discussions Q&A, or the forum
- Report a documentation error: a GitLab issue naming the page