Skip to content

APIs Reference

chrisholloway5 edited this page Sep 10, 2026 · 8 revisions

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.


1. Four HTTP listeners, all off by default

  • 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 (default 127.0.0.1), RestApiCertificateFile (default empty), RestApiPrivateKeyFile (default empty) (source: IniFileSettings.cpp:591-594).
  • Prometheus metrics + health probes: MetricsServerPort (default 0), MetricsServerBindAddress (default 127.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 (default 0.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 (default 127.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 with Connection: close on 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 through HttpServer::Listen, which translates localhost to 127.0.0.1 and parses the address with boost::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 id api); API keys have their own page Monitoring & troubleshooting > REST API keys (id apikeys); the web-services settings live on Connections & protocols > Web services & autoconfiguration (id webservices); ACME on TLS & certificates > Certificates (ACME) (id acme). The older paths Settings > Network > API & monitoring, Settings > Network > Web services & autoconfiguration and Settings > 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).

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
Loading
  • The report at W is 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 above ReportUnreachableFeatures).

2. REST API - listener and TLS rules

  • 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 RestApiBindAddress is exactly 127.0.0.1, localhost or ::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 RestApiCertificateFile and RestApiPrivateKeyFile are non-empty; when the certificate setting is empty, Application.cpp substitutes 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 and TlsKeyExchangeGroups as the mail protocols), then a TLS 1.2 floor is applied on top (source: RestApiServer.cpp:709-770, floor at 762).
  • localhost is translated to 127.0.0.1 before binding (source: RestApiServer.cpp:774).
  • Startup log line: "RestApi: Listening on : (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/messages and POST /api/v1/me/drafts alone 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-continue is 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 (administrator or key:<id>); over budget -> 429 {"error":"too many requests"} with Retry-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::RegisterFailedLogin with 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).
  • Every API response body is JSON (Content-Type: application/json) and carries Cache-Control: no-store; the exceptions are the static pages /, /index.html, /portal and /portal.js and the attachment download GET /api/v1/me/messages/<id>/attachments/<index>, which serve their own media types (all also no-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 for Expect: 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 the X-hMailServer-OTP header is missing or wrong, the 401 adds X-hMailServer-OTP: required and 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 unless OtelEndpoint is 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, \uXXXX with surrogate pairs, decoded to UTF-8), boolean values (GetJsonBoolValue_) and arrays of strings (GetJsonStringArray_) (source: RestApiServer.cpp:4190-4259, 8069-8200).

2a. One connection's life on HttpServer

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 --> [*]
Loading
  • 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/messages and POST /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_).

2b. The listener's ceilings, and what a client feels

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 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: 10 is 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 above MaxRequestsPerWindowPerCredential).
  • 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).

3. REST API - credentials

3a. HTTP Basic with the administrator password

  • Authorization: Basic base64("Administrator:<password>"): the user name administrator (case-insensitive) is validated against [Security] AdministratorPassword via Crypt::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 through AccountLogon::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 fixture RestApiApiKeys runs the listener on loopback port 9104 with admin password testar over plain HTTP (source: hmailserver/test/RegressionTests/API/RestApiApiKeys.cs:58-64).

3b. Bearer API keys

  • Authorization: Bearer hmapi_<64 lower-case hex chars>; a token is hmapi_ + 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.ini in the same directory as hMailServer.ini; one [Key.<id>] section per key with Label, 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 Hash is not 64 lower-case hex is ignored (logged as "RestApi: Ignoring a record ... with no usable Hash value"); a missing/unparseable Expires counts as expired; Scope is read-only unless it is the literal full (case-insensitive); an AllowedFrom that cannot be parsed refuses every request; Domains entries are trimmed/lower-cased, empty list = every domain (source: RestApiServer.cpp:2116-2135, 2189-2232, 2238-2254, 2257-2283).
  • AllowedFrom forms: single address, lower-upper range, or CIDR (IPv4 prefix <= 32, IPv6 <= 128); families must match the peer (source: RestApiServer.cpp:317-431, 2257-2283).
  • 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 Domains list 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 in GET /api/v1/domains and GET /api/v1/srv, must name one of its domains on GET /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).

3c. Managing keys from the Control Panel

  • The Control Panel page "REST API keys" reads, creates and revokes keys by editing hMailServerApiKeys.ini directly (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).

3d. Account and administrator credentials, and browser sessions

  • New in 6.2.28. An account presents Authorization: Basic base64("<address>:<password>") (an app password works too); it is authenticated through AccountLogon::Logon - the IMAP logon path, with the per-name lockout and the auto-ban - and reaches only the routes under /api/v1/me and /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/session with the account's password answers 201 and Set-Cookie: hmailsession=<64 hex>; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200 (plus ; Secure when 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 1000 sessions are held (expired ones go first, then the least recently used); a session cannot be started with another session (403); DELETE /api/v1/session ends 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).

3e. An authenticated call, end to end

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
Loading
  • The dispatch at the bottom of ProcessRequest_ is the only way a handler is reached, and Authorize_ sits above it: "an endpoint that forgets to check something cannot exist if no endpoint does the checking" (source: RestApiServer.h comment above RouteKind).

3f. Authorize_ as a decision

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"]
Loading
  • Target domain by route kind: the path segment for RouteAccountList, RouteAccountCreate, RouteAliasList, RouteListList, RouteListCreate and RouteDkimGet; StringParser::ExtractDomain of the address for RouteAccountDelete and RouteListDelete; nothing for everything else (source: Authorize_).
  • RouteAccountDelete is 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 in Authorize_).
  • 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 above IsMutatingRoute_). Its members are listed in section 4's "RO ok = no" rows.

3g. A portal sign-in, and the session cookie it produces

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, then the least recently used if 1000 are held
        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
Loading
  • 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::Stop clears the refused-address set, the request-rate table and every browser session, so a restarted listener holds none of them (source: RestApiServer::Stop).

4. REST API - complete route table

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.

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 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"}] 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; 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); 200 the entry / {"deleted":true}; 404 RestApiCertificates.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 RestApiSettings.cpp
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
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 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) RestApiSettings.cpp
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); fetches nothing itself. 6.2.28 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 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); shared lists the public folders and the folders other owners shared 1615-1619, 5143-5291
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).
  • Domain/account path segments may not contain /; query strings are ignored everywhere (source: RestApiServer.cpp:1418-1425, 1627, 1651).

4a. Status codes, and which layer produces them

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*
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/json and Cache-Control: no-store; the exceptions are /, /index.html, /portal, /portal.js and the attachment download, which set their own media type and are also no-store (source: BuildResponse_, HandleWebAdminPage_, HandlePortalPage_, HandlePortalScript_, HandleMeMessageAttachment_).

4b. Worked examples, one family at a time

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 inventoryRouteStatus, 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}]

AccountsRouteAccountCreate 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 DNSRouteTlsa 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/settings

The 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/hold

A 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/session

Omit 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 index field of the entry in the message's own attachments array, and /attachments/0 is the first attachment (source: HandleMeMessage_ writes "index":i from a zero-based loop; HandleMeMessageAttachment_ refuses index < 0 || index >= GetCount()).
  • count in the /api/v1/srv answer 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 the services array (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/install
stateDiagram-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
Loading
  • Source: Common/Util/UpdateChecker.h state enumerators; the same numbers are COM Status.UpdateState (section 8).

5. The Control Deck page (/)

  • GET /, GET /index.html and GET /portal, GET /portal.js are the only unauthenticated routes; the server serves <ProgramDirectory>\WebAdmin\index.html as 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.
  • The installer ships WebAdmin\index.html into {app}\WebAdmin with the server component, and post-build.bat copies 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/session with HTTP Basic and the administrator password (and the one-time code, when one is enrolled - the page reveals that field when the server answers second_factor: "required"), then the hmailsession cookie carries every later request. Nothing secret is kept in sessionStorage or localStorage. Every request that changes something carries X-Requested-With: hMailServer, which is what the server requires of a session write; signing out is DELETE /api/v1/session.
  • Ten views, and they write. Dashboard (status and session counts, polled), Domains (accounts, create and delete), Queue, TLSA, Logs, and: Settings - the three groups drawn from the OpenAPI document itself, 113 fields with their types, enumerations, read-only and write-only markings and the document's own "takes effect on restart" wording, saving only what changed; Rules - create, edit whole and delete, with criteria and action rows whose controls follow the vocabulary the document declares; Certificates and Ports - add and delete a certificate, add, edit and delete a listener with its certificate chosen by name, and a button that asks the server to restart its services in place; Routes - create, edit and delete an SMTP route with its address list.
  • 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-Authenticate out 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.

6. Web services listener (WebServicesServer)

6a. Listener behaviour

  • Started only when WebServicesHttpPort > 0 or WebServicesHttpsPort > 0; both default to 0 (source: Application.cpp:535-547; WebServicesServer.cpp:573-592).
  • HTTPS: uses WebServicesCertificateFile/WebServicesPrivateKeyFile, falling back to the ACME fullchain.pem/privkey.pem when 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 SslContextInitializer one 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-store unless the handler says otherwise: security.txt and the Thunderbird autoconfig XML are public, max-age=3600, the CalDAV/CardDAV 301s public, max-age=86400; the MTA-STS policy, ACME challenges, the Apple profile, autodiscover and every refusal are no-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).

6b. Route table (all unauthenticated)

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://github.com/Progressiverobot/hmailserver/blob/master/.github/SECURITY.md, Preferred-Languages: en; else 404 891-895, 1154-1243; constants 50-54
ANY /.well-known/caldav, /.well-known/carddav CalDavRedirectUrl / CardDavRedirectUrl set 301 to the configured absolute URL; 404 when unset; an unusable value (not http(s)://, >512 chars, non-printable) is reported once per start as error 5780 and answers 404; hMailServer implements no CalDAV/CardDAV itself 897-903, 1245-1347
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> 920-925, 1545-1622
any other - 404 not found (text/plain) 927

6c. Settings that shape the answers

  • CalDavRedirectUrl and CardDavRedirectUrl are read through IniFileSettings (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 (default enforce; accepted enforce|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 is MtaStsHostingEnabled (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 in AcmeDomains (source: README.md:358).

6d. ACME

  • ACME settings: AcmeEnabled (default 0), AcmeDirectoryUrl (default https://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 (plus account.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/; otherwise AcmeClient starts a transient AcmeChallengeServer on AcmeHttpPort for the duration of the issuance (logged at startup when AcmeEnabled=1 and 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, AcmeClient logs "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 new fullchain.pem/privkey.pem at that restart, so a REST listener that refused to start with RestApiBindAddress off 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).

7. Metrics and health listener (MetricsServer)

  • Enabled by MetricsServerPort > 0; bind MetricsServerBindAddress (default 127.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_dbversion every 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).
  • /metrics access 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 naming MetricsServerAuthToken / 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/or Authorization: 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 MetricsServerCertificateFile and MetricsServerPrivateKeyFile must 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 /metrics answers 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_total and hmailserver_domain_messages_sent_total (only with MetricsPerDomainEnabled=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_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 of line.Format("hmailserver_ in MetricsServer.cpp; histograms at 1769, 1774; per-domain gate at 1598). There is no hmailserver_database_up family - the name appears only in a comment and as a local variable in the /healthz handler.
  • 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).

7a. Who may read /metrics

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"]
Loading
  • /livez, /readyz and /healthz are 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=localhost is not a loopback bind and not a bind at all: this listener parses its address with inet_pton, which takes literals only, so the name is refused as "Invalid bind address" - unlike RestApiBindAddress, where HttpServer::Listen translates localhost to 127.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 (default hmailserver), 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).


8. COM API - object model overview

  • Type library hMailServer (uuid DB241B59-A1B1-4C59-98FC-8D101A2995F2, version 1.0, helpstring "hMailServer Type Library"); 94 coclasses; the root coclass is Application (CLSID D6567EF8-0A6C-48E7-9288-A2463123C2F3, default interface IInterfaceApplication) (source: hMailServer.idl:3218-3223, 3446-3453; grep -c coclass = 94).
  • ProgID hMailServer.Application is 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 dual IDispatch 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 admintools registers 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 an Account (null on failure); the administrator logs in as Administrator with [Security] AdministratorPassword; AuthenticateWithCode(Username, Password, Code) is the TOTP variant (source: hMailServer.idl:1672, 1687; ServerSession.cs:441).
  • IInterfaceApplication members: 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).
    • Domains and Accounts collections offer Item, Count, Add, Refresh, ItemByDBID, ItemByName/ItemByAddress, DeleteByDBID (source: hMailServer.idl:919-938, 1698-1717).
    • Account members include Address, 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.Settings is the root for server-wide settings and sub-objects: AntiSpam (which owns Quarantine), 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.) and SetAdministratorPassword, 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), 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>\Updates and verifies it; nothing is run), UpdateInstallerPath, UpdateSignerIdentity, InstallUpdate() (hands the verified installer to hMailServer.Updater.exe, which runs it, waits for the service and rolls back if it does not return), UpdateApplyOutcome (" : ", 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).
    • 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).

8a. The object tree, drawn

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"]
Loading
  • Collections (Domains, Accounts, Aliases, DistributionLists, …) share the same members: Item, Count, Add, Refresh, ItemByDBID, ItemByName or ItemByAddress, DeleteByDBID (source: hMailServer.idl:919-938, 1698-1717).

  • Enumerations a script will meet: eServerState 0 Unknown, 1 Stopped, 2 Starting, 3 Running, 4 Stopping (idl:153-168); eSessionType 1 SMTP, 2 SMTPClient, 3 POP3, 4 POP3Client, 5 IMAP (idl:362-376); eDBtype 1 MySQL, 2 MSSQL, 3 PostgreSQL, 4 MSSQLCE (idl:107-126); Status.UpdateState 0-5 as in section 4b.

  • The same information reaches a non-Windows client over REST: Status is GET /api/v1/status, GlobalObjects.DeliveryQueue is /api/v1/queue, Settings (a redacted subset) is /api/v1/settings, Domains/Accounts are the domain and account routes, and Status.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 the otpauth:// URI; enforced at once on COM and REST) and Settings.DisableAdministratorTOTP(); Domain.MessageRetentionDays (0 = no policy) and Account.MessageRetentionDays (0 = the domain's policy, -1 = keep forever); Utilities.RunMessageRetention() (runs the sweep now, returns the count removed); FetchAccount.ServerType 1 = IMAP and FetchAccount.MirrorFolders (IMAP only: every remote folder into a local folder of the same name, a migration); AntiSpam.TarpitDelay/TarpitCount now carry the recipient tarpit (SmtpTarpitDelaySeconds/SmtpTarpitCount in 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 (default stable; prerelease reads the last ten releases), UpdateFeedUrl (empty = https://api.github.com/repos/Progressiverobot/hmailserver/releases/latest), 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 until UpdateCheckEnabled=1, reads the feed every UpdateCheckHours, and applies inside UpdateWindow after 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 in Updates\rollback); the apply is run by hMailServer.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] HttpProxy when 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 as "The proxy refused CONNECT to host:port: ". 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) and Status.Update* (above). Every key is in the Settings Reference section 9.15.

9. COM API - verified script examples

  • 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; StartBackup at hMailServer.idl:2289-2302).
  • VBScript service dependency: oApp.Utilities.MakeDependent(sName) after Authenticate (source: Addons/Utilities/MakeServiceDependent.vbs:4-19; MakeDependent at 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.Size is already in megabytes (bytes / 1,048,576, rounded to three decimals; source: COM/InterfaceAccount.cpp:580-592), so $a.Size / 1MB prints ~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-...") = value and oMessage.Save - both exist on IInterfaceMessage (propput HeaderValue, Save) (source: hMailServer.idl:1079-1130).

10. Event scripts (server-side scripting)

  • Enabled via Settings.Scripting.Enabled, language Settings.Scripting.Language (VBScript/JScript), script file <events dir>\EventHandlers.<vbs|js>, with Reload, CheckSyntax, Directory, CurrentScriptFile on IInterfaceScripting (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).
  • OnAcceptMessage is invoked as OnAcceptMessage(HMAILSERVER_CLIENT, HMAILSERVER_MESSAGE) (source: Server/SMTP/SMTPConnection.cpp:2234).

11. Contradictions found (documents vs code)

  1. OpenAPI document path for API keys is wrong. Resolved: GET /api/v1/openapi.json now documents /api/v1/apikeys and /api/v1/apikeys/{id}, matching the router (RestApiServer.cpp:7627-7630 vs 1554-1556). Items 2 and 3 still stand.
  2. OpenAPI create-account schema overclaims. It lists active and maxSizeMB properties; HandleCreateAccount_ reads only address and password and always sets Active=true (source: RestApiServer.cpp:3110 vs 2753-2784).
  3. OpenAPI /api/v1/status says "Status, state and uptime"; the handler returns no uptime field (source: RestApiServer.cpp:3105 vs 2642-2662).
  4. 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).
  5. Legacy doc ch. 24 table omits /api/v1/srv, /api/v1/quarantine[/<id>[/release]], /api/v1/domains/<name>/aliases, /api/v1/openapi.json and /api/v1/apikeys; the README's list (README.md:644) now carries all of them, /api/v1/openapi.json and the update routes included.
  6. Legacy doc ch. 24 says "TLS is required unless bound to 127.0.0.1"; the code also exempts localhost and ::1 (RestApiServer.cpp:699-701). README.md:364 has the same narrower wording.
  7. Legacy doc ch. 23 event table lists OnErrorLog; the hook the server looks for is OnError (ScriptServer.cpp:241). The doc's table also omits OnBackupCompleted, OnBackupFailed, OnSMTPData, OnHELO, OnClientLogon, OnClientValidatePassword, OnRecipientUnknown, OnTooManyInvalidCommands.
  8. 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.
  9. Legacy doc 16.x says /readyz is "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.
  10. README.md:120 "Thunderbird autoconfig and Outlook autodiscover are served for every local domain" is true only once WebServicesHttpPort/WebServicesHttpsPort is 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.
  11. The WebAdmin page's renderDomains tolerates 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.

12. Gaps (things a page writer will want that no document currently states)

  • 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 .mobileconfig endpoint - all three are code-only (WebServicesServer.cpp:891-918, 1245-1347, 1658-1802).
  • No document states that the metrics port's health probes are never authenticated while /metrics answers 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).

13. Unconfirmed (not verified by this reader; do not publish as fact)

  • Whether Account.Size is in bytes or MB Confirmed: megabytes, rounded to three decimals (COM/InterfaceAccount.cpp:580-592); see section 9.

  • 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/*.cpp here.

  • Exact JSON field names of /healthz Confirmed: {"status","state","database","uptime_seconds"} (MetricsServer.cpp:2408-2413).

  • Whether hmailserver/test/WebAdmin test cases/Web form tests.txt reflects the current WebAdmin page (not read).

  • Behaviour of the REST listener when RestApiBindAddress is 0.0.0.0 with only the ACME certificate present but not yet issued at startup Confirmed from code: it refuses to start at service start, and is started by the reinitialisation AcmeClient triggers after issuance, which reruns StartServers and its ACME-certificate substitution (AcmeClient.cpp:1655-1657; Application.cpp:521-538, 981-1004); see 6d.

  • Whether hmailserver/docs/HighAvailabilityRunbook.md section 3's metrics example has been updated for the 503-on-non-loopback rule Confirmed: it binds 0.0.0.0 with MetricsServerAuthToken set and explains the 503 rule (HighAvailabilityRunbook.md:89-107).

  • REST (5 Sep 2026): GET /api/v1/metrics/history?metric=<name>&range=24h|7d|30d returns {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) and Utilities.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) and DELETE /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) and GET /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) and POST /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.ServerType 1 = IMAP (0 = POP3). IMAPClientConnection (on ExternalFetchClientBase, the message handling shared with POP3) logs in with LOGIN or AUTHENTICATE XOAUTH2 (the FetchOAuth2Hosts rule), STARTTLS per ConnectionSecurity, SELECT INBOX, UID SEARCH ALL, UID FETCH BODY.PEEK[] per message not yet recorded (UIDs kept as ":" in hm_fetchaccounts_uids), DaysToKeepMessages 0 = STORE \Deleted + EXPUNGE after collection, N = after N days, 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.

Clone this wiki locally