Skip to content
hMailServer 6.3.8 is the current release, signed 2 November 2026. Windows and Linux, and an in-place upgrade from 5.3 or later, but for the original hMailServer's 5.7.Download 6.3.8 Documentation FAQ
  • 0 Votes
    1 Posts
    128 Views
    ProgressiverobotP
    Written for 6.3.4. hMailServer 6.3.7 (17 October 2026) regroups the Control Panel's sidebar: the Journaling and dual delivery page is Journaling & dual delivery, in the new Compliance group rather than under Monitoring & troubleshooting as the table below has it, and Ctrl+K takes the old title to it. See the 6.3.7 announcement. A journal rule sends a copy of a domain's mail, or of one account's, to an address. Exchange calls it journaling and sends an envelope report with the message attached; Google calls it dual delivery and sends the message itself. In hMailServer they are one mechanism with two shapes of copy, new in 6.3.4 (27 September 2026, schema 6104; the rules themselves arrived with schema 6075). This topic covers what a rule says, the two shapes, when a rule is evaluated, how it avoids looping, where it is edited, and what it deliberately does not do. The one thing to know first: a journal rule covers mail that comes in through the SMTP session, a fetch account or the send API, and nothing this server queues itself. A compliance archive built on a journal rule therefore has known gaps, and they are listed exactly below. Nothing is journalled until a rule exists. With an empty table the whole of this costs one comparison per accepted message. What a rule says Field Meaning scope domain or account: whose mail the rule is about target The domain name, or the account address. It must be a domain this server hosts, or an account in one direction inbound (a message with a local recipient the rule covers), outbound (a message from an authenticated sender it covers), or both journal_address Where the copy goes. One address, local or anywhere else, checked as an address before the row is written. It may be a mailbox in the very domain the rule journals; it may not be the account an account rule names mode report or copy enabled A rule that is off copies nothing and keeps everything it said note Why the rule exists. At most 255 characters The rules are stored in hm_journalrules. The row holds the domain id and, for an account rule, the account id, so nothing breaks when a domain or an account is renamed under it. A domain's rules go with the domain when it is deleted, and a rule naming an account goes with the account. A rule has no conditions. It covers all of a domain's or an account's mail in the direction it names. There is no "only messages over 10 MB" and no "only from these senders"; a mail flow rule is the place for that. The two shapes report is the Exchange journal shape: a multipart/mixed message whose first part is the envelope report and whose second part is the original message attached as message/rfc822, written through byte for byte. The report is four fixed fields, one per line, in one order: Sender: [email protected] Subject: Quarterly figures Message-Id: <[email protected]> Recipient: [email protected] Recipient: [email protected] The envelope is the point. A message's To and Cc headers are what the sender typed; the Recipient: lines are where the message actually went, blind copies and expanded lists included. A message with no envelope sender - a bounce - writes Sender: <>. copy is the message itself, as it was accepted, with one header line (X-hMailServer-Journal) in front of it and nothing else changed: not the body, not its transfer encoding, not its signature. The file is written byte for byte rather than through the MIME writer. What a migration or a second mail system wants is the mail, not a report about it; that is what copy is for. Both shapes are queued through the ordinary delivery queue, so a journal address somewhere else is delivered, retried and bounced as any other mail is, and a local one lands in a mailbox. The envelope sender of a journal message is the journalled domain's postmaster, not the person who wrote the message. A journal address that stops answering is the administrator's problem, and bouncing it to the sender would tell somebody outside this server that their mail is being copied. When a rule is evaluated Once per accepted message, in the SMTP session, after the row is saved and the message file is settled - the same place, and the same recipient list, the on-disk archive decision is made from. Not at delivery: the deliverer runs again for every retry and for every route a message splits into, and a compliance archive that gained a second copy because a remote host was busy would be worse than useless. One report or copy per matching rule per message, not per recipient. A domain rule that matches a message addressed to three accounts of the domain sends one report, with all three on it. There are three points of entry where rules are evaluated: The SMTP session. The outbound half judges an authenticated sender; this is what a desktop or phone client uses. A fetch account. Mail a mailbox collects from another provider by POP3 or IMAP is journalled once per message, at the same point in that path. A download has no authenticated sender, so it is the inbound half of a rule that speaks for it, and it has no SMTP session, so its lines go to the application log. The send API, POST /api/v1/send, which is new in 6.3.4 and is journalled in it (CHANGELOG lists the fix in its 6.3.4 section). The key's sender counts as the sender an outbound rule judges when that sender is an account here. What a journal rule does not see A message the server queues itself is queued past all three points and is not journalled in either direction, even when it is addressed to a mailbox a rule covers: Not journalled Note A message the webmail queues directly POST /api/v1/me/messages A send-later draft A message written by an event script A mail flow rule's forward, copy or reply An automatic reply A Sieve redirect An account's forwarding A delivery report Two consequences follow, and both matter to anyone relying on this for compliance: The outbound half covers SMTP submission and the send API, and not the browser. Mail a person sends from the webmail is not journalled by an outbound rule. The inbound half covers mail from outside this server, and not what the server sends on to a mailbox of its own. Two further limits of the same kind: A message that was refused is not journalled. A message rejected at RCPT TO or by a rule never existed as far as this is concerned. A message held for review is journalled, because it was accepted. Not looping There is one guard, and it takes nobody's word for anything. Every journal message this server writes carries X-hMailServer-Journal, and its value is the rule's id, a full stop, and a mark: 128 random bits the server process chose when it first needed one. The evaluation reads that header before it does anything else and stops only when the mark is this process's own. A journal message that comes back into this server - relayed by the far end, forwarded by the journal mailbox's owner - is never journalled again, and the guard stops the copy, not the delivery: the mailbox still gets its mail. A message carrying the header with any other value is journalled exactly as it would have been without it, and the attempt is noted in the log. The header's name is in the published document, so a guard that honoured the header as written would let anybody under an outbound compliance rule keep a message out of the archive by typing one line into their mail client. An archive that the people it is about can opt out of is not an archive. Three properties of the mark to understand: It is per process, and nothing is stored for it. A restart forgets the marks it wrote, so one of this server's own reports that returns after a restart is journalled once more, and the report that makes carries a mark the running process does know, so it stops there. A loop cannot run away; the price of keeping no secret on disk is one duplicate. It is not a signature over the message. It travels in the messages it marks, so whoever can read this server's journal mail - the journal address itself, and anything relaying to it - could quote a mark back in a message of their own and keep that one message out of the archive. If that relay is a worry, journal to a local mailbox, or over an authenticated route. There is no second guard, and no rule is skipped for where its journal address points. Journalling a domain into a mailbox of that domain works. example.com to [email protected] sends one report per message into that mailbox and nothing cascades, because a journal message is written straight into the delivery queue and never passes an evaluation point. It used to be refused at run time, silently: the rule matched, the copy was dropped with a line in the log, and every surface went on showing the rule as enabled. That guard went. The one arrangement refused is an account journalled into itself, and it is refused when the rule is written rather than found out later: it would put every message in the mailbox twice and archive nothing anywhere. Where it is edited Journal rules are the server administrator's. A rule says that somebody's mail is copied to an address outside their sight, so a credential confined to named domains is refused even though a rule names one. Surface How Platform REST GET/POST /api/v1/journal-rules, PUT/DELETE /api/v1/journal-rules/{id} Windows and Linux The Control Deck The Journaling view: every rule, a switch on each row, and a form to add one Windows and Linux The Control Panel The Journaling and dual delivery page, under Monitoring & troubleshooting (since 6.3.7 Journaling & dual delivery, in the Compliance group; Ctrl+K takes the old title to it), over three Utilities methods (GetJournalRules, SaveJournalRule, DeleteJournalRule) Windows only hmctl hmctl journalrule list\|create\|update\|delete A client over the REST API PowerShell module Get-HmJournalRule, New-HmJournalRule, Set-HmJournalRule, Remove-HmJournalRule Over the REST API Over REST, reading needs view_configuration and writing needs manage_server, so the auditor role reads the rules and writes none. Every surface goes through one function on the server, Journaling::SaveRule, so the three small vocabularies (domain/account, inbound/outbound/both, report/copy) mean the same thing whichever you use, and a refusal reads the same sentence. Writing, changing or removing a rule is on the audit trail. Setting up a compliance copy for one domain: Decide the journal address. A mailbox in the same domain, such as [email protected], is allowed; an address elsewhere is delivered through the ordinary queue. Create one rule with scope domain, target the domain name, direction both, mode report and the journal_address. Over SMTP - from a mail client signed in to an account, or from outside, and not from the webmail - send one message to two recipients of the domain, one of them as a blind copy, and open what arrives at the journal address: one report for the message, with a Recipient: line for each envelope recipient. Then send one message from the webmail and confirm that it is not journalled, so that whoever owns the archive knows the gap is there. A rule is refused before it is written for an unhosted target, a word outside the three vocabularies, no journal address, one that is not an address, one carrying a line break, and an account journalled into itself. Journaling beside three other features Sealed mail. The journal keeps the bytes verbatim, so a message that was encrypted end to end still decrypts from the copy - but nobody reviewing the journal can read its content. Restricted delivery. A journal rule's copy of a restricted account's mail goes where the rule says, because the copy is the administrator's and not the account's forward. Rules and Sieve. A rule's forward, copy or reply and a Sieve redirect are among the messages the server queues itself, so they are not journalled. What it does not do It is not the archive. ArchiveDir and ArchiveDomains keep files on this server; a journal rule sends mail elsewhere. Neither replaces the other, and turning one on does not turn the other off. It is not eDiscovery. A case searches the mailboxes, their deleted items and the archive on this server, and a journal copy has left it. A journal address that is a mailbox here is searched like any other custodian. It is not an account's forwarding. A forward is the account holder's, and can deliver instead of the mailbox. A journal copy is the administrator's, and never changes what the mailbox gets. It is not a mail flow rule's forward or copy action. Those have conditions, run per delivery attempt, and can be edited by the account. It is not retention. Once a journal message leaves the queue it belongs to whatever receives it. This server keeps no second copy, expires nothing and withdraws nothing; deleting a rule stops the next copy and does not reach the ones already sent. It is not encryption or redaction. A journal address outside your control sees everything the recipient sees. Where to read more The document at the 6.3.4 tag: Journaling.md, with Discovery.md, SendApi.md, SealedMail.md and RestrictedDelivery.md Wiki: Routes and Relays (section 14.2d) and Compliance and eDiscovery On this forum: the 6.3.4 release announcement; Rules and Sieve for the forwards and redirects a journal rule does not see; restricted delivery; the send API; compliance, eDiscovery and legal holds; and the archive
  • 0 Votes
    1 Posts
    107 Views
    ProgressiverobotP
    Written for 6.3.4. hMailServer 6.3.7 (17 October 2026) changes the anti-virus action that removes attachments (Remove all attachments and deliver the text in 6.3.7's Control Panel, Delete infected attachments only in 6.3.6's): an S/MIME message signed as one part, which is opaque but not encrypted, is now replaced by the action's note when the scanner finds something in it, where every earlier version delivered it as it arrived, and a message the server cannot read is deleted, its recipients told; an encrypted message is still delivered as it is (work item 92). See the 6.3.7 announcement. In hMailServer 6.3.8 (2 November 2026) the web services listener refuses an address an auto-ban or an IP range bans (work item 105), so an IP range of your own that allows some addresses none of SMTP, POP3 and IMAP now closes that listener to them, and with it the Web Key Directory: check that the clients that look keys up there do not come from such a range. See the 6.3.8 announcement. This topic is about encrypting the message rather than the connection, so that the person it is for can read it and nobody on the way can, this server included. S/MIME in the webmail has been there since 6.3.2; everything else here is new in 6.3.4. The design rule running through all of it is that this server holds nothing that decrypts a person's mail: keys are made in the browser and kept wrapped under a passphrase or password the server never sees. The one thing to know before you allow any of it is the cost of that rule: nothing on this server can recover mail encrypted to a key that was lost. There is no escrow. Which one you want You want Use S/MIME inside the organisation without buying a certificate per person A domain's S/MIME authority Correspondents who use GnuPG, Thunderbird, Proton or Mailvelope OpenPGP in the webmail, and the Web Key Directory To write privately to somebody who has no key at all A password-protected message A mailbox the administrator cannot read Zero access Everything to one partner organisation encrypted, whatever the sender's client did Gateway encryption, on a remote domain policy A stolen disk or backup to hold nothing readable, while the mailbox works as before Mailbox encryption at rest, which the server can read and so is not in this topic All of the browser cryptography needs a page opened over HTTPS: a browser withholds crypto.subtle from any other, and the page says so instead of offering Sign and Encrypt. S/MIME with your own authority Since 6.3.4 (schema 6095). A domain may have its own S/MIME authority: a root certificate the domain owns, which certifies the keys its accounts make in the webmail, publishes them for colleagues to look up, and says what it has revoked in a signed CRL. Nothing is issued until an administrator creates one. Who holds what. The person's private key never leaves the browser. The webmail makes the key pair, builds a PKCS#10 request, wraps the private key under the account password, and sends only the request. The authority's own key is on the server, because it signs there, and it is protected by the platform's secret store: Where the authority's key opens Windows DPAPI with the machine's key. It opens on this machine and nowhere else, and on this machine to any process that asks Linux The stored-secret key file, <DataFolder>/.hmailserver-secret-key (AES-256-GCM, mode 0600). It opens wherever that file is It never falls back to the legacy scheme. When the store is unavailable the authority is not created (503). Creating it. In the Control Deck, open the domain and find S/MIME authority, or POST /api/v1/domains/{domain}/smime-ca with {"key": "p256"} or "rsa3072". The root is self-signed, CA:TRUE with pathlen:0, valid ten years, with a critical name constraint permitting the domain's own addresses and nothing else. P-256 is the default. The Control Panel shows nothing of it, and there is no COM surface. Enrolment. A person finds Get a certificate from {domain} under Settings → Security → S/MIME. They pick the key type. RSA 2048 is the default and the one to choose. A P-256 certificate from this page signs, and neither this webmail nor a colleague's can encrypt to it. The page makes the key pair and the request, and asks for the account password that wraps the private key before anything is sent. POST /api/v1/me/smime/enroll is made from the account's own sign-in. An app password and an administrator's support session are refused with 403. The server checks the request. It must name exactly one address, and that address must be the account's own or an active alias of its own domain that points at it. The certificate is issued pending. When it arrives in the account's own store it is confirmed, and only then is it answered to colleagues and the address's earlier certificates revoked as superseded. The limits are five enrolments a day (429) and ten valid certificates at once (409). A person with two devices ticks Keep my other certificates for this address on the second. What is issued. Valid one year. With smime_authority_base_url empty, which is the default, a certificate carries no CRL distribution point. What that costs: Outlook and Thunderbird cannot fetch the CRL from the certificate, so a revocation reaches them only when they have been given the CRL another way. This webmail does not use the CRL at all; it reads the authority's own record. Publication and trust. The root and the CRL are served over plain HTTP by the web services listener (WebServicesHttpPort), at /smime-ca/{domain}.pem, .cer and .crl. A signature verifies in Outlook, Thunderbird, Apple Mail or a phone only once the root is installed on the clients, by group policy, a device-management profile or by hand. This webmail trusts the domain's authority by itself. A colleague's certificate is looked up with GET /api/v1/me/directory/{address}/smime, which the webmail asks on its own when Encrypt is ticked. Moving and restoring. The authority is in the configuration backup, but its key opens only where its envelope opens. Restored anywhere else, the Deck shows a warning, enrolment is refused with 503, its CRL is not served, and the authority must be rotated there. Rotation makes a new root and retires the old one without deleting it, and clients must install the new root beside the old. What it is not. Not Active Directory Certificate Services. No OCSP. No key escrow: a forgotten password that the key was wrapped under leaves the mail encrypted to that key unreadable, by anybody, for ever. No elliptic-curve encryption in the webmail. One address per certificate. No automatic renewal: a person enrols again before the year is out. OpenPGP: the half in the directory and the half in the browser The directory The page Where it runs On the server In the person's browser What it holds Public keys only Own keys, the secret half encrypted under a passphrase, and correspondents' public keys What cryptography it does None at all All of it: signing, encrypting, decrypting, verifying, in PGP/MIME Table and schema hm_pgpkeys, schema 6050 hm_pgpring, schema 6080; the trust columns 6081 State as shipped Three switches, all off portal_enabled on In neither half does the server sign, verify, encrypt or decrypt a message. It makes an OpenPGP message itself in only two places further down, a delivery to a zero-access mailbox and gateway encryption to a partner's key; there it encrypts, never signs or decrypts, and verifies only the binding of the subkey it encrypts to. The directory publishes keys at a URL derived from the address, so that anybody who has the address can find the key. <hash> is the local part, lower-cased, SHA-1 hashed and z-base-32 encoded. Both methods are served: Method The key advanced https://openpgpkey.<domain>/.well-known/openpgpkey/<domain>/hu/<hash> direct https://<domain>/.well-known/openpgpkey/hu/<hash> Three switches have to be on before one key is served: The server: OpenPGPWebKeyDirectoryEnabled, set with hmctl settings set openpgp --set web_key_directory_enabled=true or PUT /api/v1/settings/openpgp. The domain: openpgp_publish on the domain. The account: openpgp_publish on the account. This one is the person's own, through PUT /api/v1/me/openpgp/publish. The web services listener has to exist (WebServicesHttpsPort), and the advanced method, which clients try first, needs an address record for openpgpkey.<domain> and a certificate valid for it. Keys are served over HTTPS only. Every refusal is the same 404, whether the domain is unknown, a switch is off, or the account has no key, so that the directory cannot be used to ask whether somebody has an account. Put a key in with hmctl openpgp store [email protected] ann.asc --publish, and check it as a correspondent's client will: gpg --locate-keys --auto-key-locate clear,wkd [email protected] An upload carrying a secret-key packet is refused; so are a version 3 key, a key with no user id or no self-signature, a key past OpenPGPKeyMaximumBytes (65536), and a key whose user ids do not carry the account's own address. An account may hold OpenPGPKeysPerAccount keys (8). A signature on an uploaded key is not verified cryptographically: what makes a key in this directory the right one is that its owner or an administrator put it there. The desktop Control Panel has nothing for the directory yet. The page is /portal-openpgp.js, written on the Web Crypto API with no library vendored. Keys. Curve25519 (the default) or RSA 3072 or 4096, made in the browser. The secret key is stored as OpenPGP writes one, under the passphrase, so the bytes the server holds are bytes it cannot open, and the same bytes import into GnuPG. A revocation certificate is made at the same moment and saved to the device. Autocrypt Level 1, both ways. Finding a key. GET /api/v1/me/openpgp/lookup?address= asks the correspondent's own Web Key Directory (lookup_wkd, on) and then a keyserver (lookup_keyserver, off by default; keyserver_url is https://keys.openpgp.org). Nothing is stored by the lookup. Trust made visible. A different key arriving for an address the ring already holds a key for is stored as a change. Until the person accepts it, the page does not encrypt to it. A key can be marked verified in person, and the mark is never inherited by a key that replaces it. Who may write. Every write to the ring is refused to an app password and to an administrator's support session. Not implemented, and refused by name: version 5 and 6 keys and AEAD; DSA and ElGamal keys and the NIST and Brainpool curves; smartcards and hardware tokens; the web of trust; a draft or a scheduled send that is to be sealed. Curve25519 needs Chrome 137, Firefox 130 or Safari 17 or later. Both directions are proved against GnuPG 2.4 by build/check-portal-openpgp.js, which GitLab CI runs on every push and every merge request. A password-protected message (schema 6082) is for the correspondent with no key. The page encrypts the text and attachments to a password as one OpenPGP message, and what goes out by SMTP is a short notice with a link to /portal/protected/<token>, where the recipient's browser decrypts it. The link is the whole access control. It needs portal_enabled on and a public address for the links (the confidential_base_url chain the messages that expire use), and is offered only over HTTPS. A message lives protected_message_days (30 by default). The ceilings are 25 MB a message and 250 MB an account. It does not promise that the recipient cannot copy what they read. Zero-access mailboxes Since 6.3.4 (schema 6083). A mailbox this server holds and cannot read. An administrator allows it server-wide once (zero_access_allowed, under /api/v1/settings/openpgp, off by default); the person then switches it on for their own account on the webmail's Security page. From then on every message delivered to the account is encrypted to the account's own OpenPGP key - after the spam and virus filters, the rules and the Sieve script have had the text - and stored as PGP/MIME with the headers in the clear. An administrator sees the switch read-only everywhere. What it costs. The webmail refuses to switch until the recovery key has been typed back and the sentence about a lost password has been ticked. Cost What it means The administrator cannot read the mail The message source and the account export answer 403 Neither the administrator nor the server can search it The index and IMAP SEARCH read the headers, never the text. A word in the subject is found; a word in the body is not Nobody can recover it A forgotten password without the recovery key is the mail lost, for ever IMAP and POP see ciphertext Valid PGP/MIME that a client holding the key opens. Until then the account is a webmail account The plaintext was on the disk From acceptance until the seal. The account's copy is overwritten with zeros; whether the bytes are gone is the filesystem's decision Only what is delivered is sealed, and the Sent copy A draft, and anything a client uploads with IMAP APPEND, is stored as it is A journal rule sees the plaintext Journaling runs at acceptance, before delivery The filters have read it A forward goes on in the clear. A rule that files into a public folder files the plaintext there. Quarantined mail is held in the clear Switching off decrypts nothing Mail that arrived while it was on stays encrypted to the key The headers the server kept. From, To, Cc, Date, Subject, Message-ID, the Received trace and the rest stay in the clear, so that the listing, the threading and the spam folder work without the key. The server adds X-hMailServer-Zero-Access: sealed on delivery. The key, in two sealed copies. hm_zeroaccess holds two copies of the secret key, both sealed in the browser: one under a passphrase the page derives from the account password with Argon2id, three passes over 64 MiB in four lanes. It was measured at 1.6 seconds in Node 24 on the development machine; a phone is expected to take several seconds, and it was not measured on one; one under the recovery key: 30 random octets shown once as eight groups of six base32 characters. A key that carries an expiry is refused at the switch-on. When a seal fails, the delivery is refused rather than stored in the clear: the sender is told with 4.3.0 and the error log carries HM7070. Passwords. A change through the webmail carries the key sealed again under the new password, and the server refuses a change without it (409, zero_access_rewrap). A password an administrator sets cannot re-wrap; at the next sign-in the page asks for the previous password or the recovery key. It is not encryption at rest. Mailbox encryption at rest stores mail under a key the server holds while the person is signed in. The two are never on one mailbox: each switch is refused where the other is on. Not built: a local bridge for IMAP or POP clients; search over the decrypted text in the browser; encryption of mail already in the mailbox when the switch is thrown. What the server does with a sealed message A sealed message is one signed or encrypted end to end, which must leave this server byte for byte as it arrived. An opaque one is a sealed message the server cannot read at all. A multipart/signed message is sealed but not opaque: its text is in the clear. Opaque is decided from the body, never from the label, or every check that stands aside for an encrypted message would be one header away from being switched off by anyone sending mail. The body must be S/MIME whose one part begins a CMS object (enveloped-data, authenticated-enveloped-data or signed-data), or PGP/MIME of exactly two parts: Version: 1, then one armoured OpenPGP message. None of what follows is a setting. Part of the server What it does with an opaque message Blocked-attachment list Never rewrites it. One application log line says the list was not applied. In 6.3.3 and earlier, blocking *.asc or *.p7m destroyed the ciphertext without a log line Virus scanner Scans it, and logs that its content COULD NOT BE EXAMINED for viruses. It is not treated as a scanner failure, so AVFailAction=1 does not hold it. If mail the server cannot scan must not be delivered, use a rule or a Sieve script on body :raw Delete attachments action (Remove all attachments and deliver the text since 6.3.7) Never strips an encrypted message. Since 6.3.7 an S/MIME message signed as one part - application/pkcs7-mime holding signed-data, opaque but not encrypted - is replaced by the action's note when the scanner finds something in it, where it was delivered as it arrived (work item 92). Set the action to delete the message if such mail must not be delivered Rules A body criterion does not match, whichever way it is phrased, with one log line per rule per message: under AND the rule does not fire; under OR its other criteria still count. To catch encrypted mail, test Content-Type for multipart/encrypted or application/pkcs7-mime with a custom-header criterion Sieve The evaluation is unchanged, as RFC 5173 requires; a log line is new. Use body :raw to recognise such a message Search and the index Only the header is indexed Content spam scoring Sees nothing in it, so encrypted spam is scored more leniently. SPF, DKIM, DMARC and DNSBL are unaffected A message only labelled encrypted, whose parts are readable, is blocked, stripped, filtered and searched like any other. In SealedMail.md's words: a message that arrives encrypted is not scanned, and your mail client, and you, are the last check. Gateway encryption, briefly Since 6.3.4 (schema 6094). On a remote domain policy, Encrypt mail to this domain at this server encrypts every message to one partner, here, to the partner's own gateway key. It encrypts at the server, as a delivery to a zero-access mailbox does, and the server holds no private key for it. The Subject is not hidden. When a message cannot be encrypted the default is to refuse it with 5.7.10. It does not sign, look keys up, or decrypt replies. Where to read more Operator documents at the v6.3.4 tag: SmimeAuthority.md, OpenPGP.md, ZeroAccess.md, SealedMail.md and RemoteDomains.md. Wiki: Encryption for People and Encryption and Certificates. Public documentation: 36.6 The portal: what a user gets. On this forum: The webmail portal at /portal, The Control Deck, Moving to new hardware before you carry an authority's key to another machine, and the rest of this category.
  • 0 Votes
    1 Posts
    132 Views
    ProgressiverobotP
    Written for 6.3.4. hMailServer 6.3.7 (17 October 2026) signs an authenticated SMTP submission as another domain hosted here only when its account may send as the address its From names - the list the From picker below draws on: its own address, an alias pointing at it, a mailbox that granted it the post right, an address it proved or a masked address of its own; otherwise the mail is still accepted and sent, unsigned as that domain. The webmail's own send already holds From to the account, so mail sent from the page is signed as it was. 6.3.7 also carries the first step of the Control Deck's and the webmail's rebuild planned for 6.4 - the framework decision, the threat model of the web surface and the authorisation matrix with its check - with no product code. See the 6.3.7 announcement. This topic goes deeper into the webmail that hMailServer serves at /portal on the REST listener; what the page is and how it is turned on is in The webmail portal at /portal. The one thing to know first: most of what follows is the reader's own choice, bounded by a setting the administrator holds. Search with operators, the keyboard map, the rich-text editor, the From picker over aliases and granted mailboxes, plain signatures and the offline inbox listing were in 6.3.3; the three stars, the importance marker and its keys, and the rest of what follows are since 6.3.4. What the administrator bounds Each is a setting on PUT /api/v1/settings and the Control Deck's Settings view; since 6.3.4 many can also be set per domain or per account. Setting What it bounds Default recover_deleted_days How long deleted mail can be put back 0, off; at most 365 reader_offline_days_max The most days a reader may keep offline 30; at most 365; 0 offers none reader_masked_addresses_max Masked addresses per person 20; at most 200; 0 takes the page away reader_sender_addresses_max Send-as addresses per person, proved or not 5; at most 20; 0 takes the section away brand_logos_enabled Brand logos at all On; needs AuthenticationResultsEnabled and DMARC confidential_base_url The address the webmail is reached at from outside, which links are written against; falls back to quarantine_digest_base_url, then import_oauth_redirect_base_url Empty Search operators and keyboard shortcuts The search box searches the folder shown; the options panel beside it builds the same syntax from fields and can search every folder the account may read. Operator What it matches word, "quoted phrase" The word in the subject or the sender, failing those anywhere in the message; the words together, in that order -term, a OR b What follows the minus must not match; one of the two must match. OR in capitals - or is a word from:, to:, cc:, bcc:, subject: The sender's address or name; a recipient's, in To or Cc; an address or name in Cc, or in Bcc (only in the sender's own copy); the subject has:attachment, has:link, filename: An attachment; an http or https link; an attachment whose file name contains the text larger:, smaller: By size, as in larger:10M before:YYYY-MM-DD, after:YYYY-MM-DD By date older_than:, newer_than: By age: 7d, 2w, 3m, 1y in:folder Messages in that folder; a whole path may be given is:unread, is:read, is:flagged, is:unflagged, is:answered, is:muted, is:pinned By the flag or keyword label:name An IMAP keyword; label:$Important is the sidebar's Important view category: The inbox tab: primary, social, promotions, updates or forums in_reply_to: Messages whose In-Reply-To or References names the message-id Since 6.3.4 a search request opens at most 2,000 message files and stops after about two and a half seconds, saying where to go on from; Search older messages carries on. The keyboard map (? lists it) acts on the message list and an open message, and nothing is captured while the cursor is in a text field. j and k move, Enter or o opens, u or Esc goes back, x ticks a row, e archives, ! files as junk (or, in Junk, as not junk), # deletes to Trash, l labels, m mutes, c writes, r a f reply, reply to all and forward, 1 to 9 run a quick step, / searches and Ctrl+K opens the palette. s gives the next of three stars; the second and third are the $Star2 and $Star3 keywords on top of the IMAP flag, so another client sees a flagged message. Offline mail The webmail has always kept the inbox listing and the last opened messages - thirty-one of them according to OfflineMail.md and EvidencePack.md, thirty according to README.md and the wiki - and an outbox of sends that failed for want of a connection. Since 6.3.4 it can also keep the text, formatting and attachments of the last days in the browser's own storage, and nothing is kept until the reader chooses. Settings, Offline in this browser, offers Nothing beyond the inbox listing and then the last 7, 14, 30, 60, 90, 180 or 365 days, up to the server's ceiling. The choice is the account's offline_days preference, so it follows the person between browsers; what is kept does not. How it fills. The store is IndexedDB, database hmPortalOffline. A message goes in when it is opened and the window covers it, and in the background, newest first. An attachment above 8 MB is not kept, and a message's parts stop at 32 MB together. When the window is bigger than the browser allows the site, the card says so and the newest are kept. Without a connection a message opens from the store, a folder lists what the store holds, and a search runs over the kept words with the operators the store can answer. has:link and in_reply_to: let a message through rather than hide it, OR is ignored, and a date operator is measured on the browser's clock. What it is not. It is not a backup and it is not synchronised back: a flag set offline is not sent when the connection returns. It is not encrypted beyond what the browser does with its own storage, so anyone who can open this browser profile can read what is kept. It does not survive sign-out: the database is deleted. Clear what is kept deletes everything now and sets the choice to nothing. Important, and sections of the inbox Gmail's importance marker, decided in the browser: no model is trained anywhere, and nothing about whom this person reads or writes to is sent to the server to decide it. The page counts in localStorage and forgets the whole of it at sign-out. What it decides is one IMAP keyword: $Important on a message judged important, $NotImportant on one the reader says is not, so Thunderbird, a phone and hmctl all see them. Each inbox message is judged once, the first time this session lists it; two points make it important. Reason Points You marked mail from this sender important, or not important +4, or -4 You write to this sender +3 You answered this sender +2 You open mail from this sender (two or more opened here) +1 You starred mail from this sender +1 It was sent to you by name (a To of five or fewer) +1 It is in one of the other tabs: Social, Promotions, Updates or Forums -2 A message that already carries either keyword is never judged again. The marker is changed from the arrow on the row, with an Undo, or with the keys + or = and -. Sent and Drafts have no marker. In Settings, Importance marker offers the page deciding (the default), marking by hand only, or no marker; Inbox sections can draw Important, Starred, Everything else (off by default). It will be wrong, which is why every mark can be undone in one press. It does not sort, hide or delay anything, and what it has learned stays in this browser; the keywords on the mail travel. A message that expires Gmail's confidential mode: the message is kept on this server, and what crosses the internet is a short notice with a link. It is not end-to-end encryption. A message this server can delete on a timer is a message this server can read, and anyone who administers the server, or a backup of it, can read a kept message while it exists. Nor does it stop a recipient photographing the screen. Sending. On the compose form, Expires (never, in an hour, a day, a week, a month) and an optional Passcode. Over the API, expires_in (seconds, 1 to 7,776,000 - ninety days) and passcode (4 to 64 characters) on POST /api/v1/me/messages. The send is refused when the server has no public address to write the link against (403, confidential_unavailable), and for a message the page signed or encrypted. The kept copy is a file under <data directory>/Confidential/<account id>/ with a row in hm_confidential (schema 6069); it counts against the mailbox's quota (413, quota_exceeded), and an account has at most 1,000 outstanding at once (400, confidential_ceiling). Each recipient is sent the notice; the sender's Sent folder gets the full message. Reading. The page at /confidential/<token> takes no credentials. The reader proves they are a recipient in one of three ways: signed in to this server's webmail as a recipient address; a six-digit code mailed to a recipient address, good for ten minutes and five tries; or the passcode, where ten wrong answers pause it for a quarter of an hour. After thirty wrong answers in all, codes stop opening the message. The page answers the same way whether or not the address typed is a recipient's. It shows the text, names other attachments without offering them, and runs no script. After the expiry or a withdrawal it answers 410. The sender's side. Messages that expire, on the Sent folder's menu, lists what was sent and whether and by whom it was opened, with a Withdraw button. The kept file is deleted when the expiry passes. The copies are not in the backup, but an archive taken by a 6.3.4 build made before the release holds them, in the clear: treat it as the mail itself, or delete it. Send-as and masked addresses The webmail's From picker offers four kinds of address: Kind Given by The account's own address Always An alias that points at it The administrator Another mailbox on this server That mailbox's owner, with the post (p) right on their INBOX An address at another provider The person, proved with a code mailed to it With SmtpAuthenticatedSenderCheck on, an authenticated SMTP session's MAIL FROM is held to the same list: see who may send as whom. Since 6.3.7 the DKIM signature follows the list too: an authenticated submission whose From names another domain hosted here is signed as that domain only when the account may send as that address, and is otherwise accepted and sent unsigned as that domain; the webmail's own send already holds From to the account. Proving an address elsewhere (since 6.3.4). Under Settings, Other accounts, Send mail as the person types the address and presses Send the code. The server mails a six-digit code to it from the postmaster of the account's domain, and the person types it back. The code lives in the server's memory for an hour and is never stored; five wrong tries end it. An address of a domain on this server is refused. What this cannot change: mail this server sends as [email protected] is not signed by Gmail's DKIM key and not sent from an address Gmail's SPF record names, so a receiver that honours the domain's DMARC policy will quarantine or refuse it when that policy says so. The page says so beside an address whose domain publishes quarantine or reject. For such an address, send from its own provider. Masked addresses. Under Settings, Masked addresses the person says what the address is for and presses Make an address; the server makes one in the account's own domain, such as [email protected]. While it is on, mail to it is delivered to the account, it is in the From picker (kind: masked), and a reply to mail that came through it goes out from it. Switched off, mail to it is refused at RCPT exactly as mail to an address that never existed is - not handed to the domain's catch-all. An account, alias or list made later with the same address takes it over. Both kinds of address are carried by the backup with the account. Recovering deleted mail It is off by default: recover_deleted_days (0 to 365) turns it on for the whole server, and a domain or an account may keep its copies for fewer days than the level above, never for more. What is kept is a message a person deletes: IMAP EXPUNGE, UID EXPUNGE and CLOSE on a folder with messages marked \Deleted; POP3 DELE; the webmail's delete and its emptying of a folder; and the retention the person chose, or the administrator's retention policy. What is removed for any other reason is not kept: an IMAP MOVE, a scheduled draft that has been sent, an account being deleted. Putting it back. Recover deleted items, on the Trash folder's menu and in the command palette, lists what was deleted within the window. Put back returns a message to its folder with its flags - or to a folder of the same path, or otherwise the INBOX - as a new message with a new UID. Delete for good removes it now. The copies are files under <data directory>/Recoverable/<account id>/; they count against no quota and are not in the backup. The retention task, six-hourly, ends the copies older than the window. Signatures and formatted messages A logo in the signature. Add image in the signature's toolbar (Settings > Name and signature) takes a PNG, JPEG, GIF or WebP file, judged by its first bytes. The whole formatted signature is at most 64 KB - 64,000 bytes, its images included, which leaves room for a logo of up to about 45 KB. A file that does not fit is drawn smaller, starting at 600 by 200 pixels. The limit is the account's signature through the webmail: the Control Panel, COM and the administrator's REST route do not check it. What the recipient receives. Gmail and Outlook do not display a data: image, so the server moves each one into a part of its own with a Content-ID and points the HTML at it with a cid: URL - multipart/related inside the alternative. At most 64 different images in one message are moved. Not every mail client displays WebP; a logo that has to show everywhere is safer as a PNG. The formatted editor rebuilds what is written from an allowed subset of HTML and drops everything else while keeping the words: a style, a colspan, a <script>, a remote <img src="https://...">. Since 6.3.4 it takes three more things: Emoji: a picker of about a thousand characters in eight groups, kept in the page itself. The names are English; there are no skin tones and no flags. Pictures, pasted, dropped or chosen: PNG, JPEG, GIF and WebP by their first bytes, with SVG refused; drawn down to 1,200 pixels on the longer side; at most 64 in a message. Tables, up to 50 by 20, written with border="1" cellpadding="6" cellspacing="0" as attributes, because Outlook renders mail with Word's engine and ignores most of a stylesheet. Tab moves to the next cell and, in the last cell, adds a row. There are no merged cells, no column widths or colours of the reader's choosing, and no resizing a picture once it is in. A second account in the same page Two mailboxes side by side: the account the person signed in as, and one more, added under Settings, Other accounts with its address and password. One press switches the whole page to the other. Nothing is collected or copied; this is not the Other accounts feature beside it, which collects mail from another mailbox into this one. The second account's session lives in the page, not in the browser. A cookie is one per origin, so its session is a token the page holds in memory (POST /api/v1/session?bearer=1), written nowhere - so a reload forgets it, and the person adds the account again. Everything the browser keeps between visits stays the first account's: the offline store, the outbox, push, the recent searches and the Important tally. What is thinner. One second account, not several. Remote images are not loaded in its messages, its S/MIME keys are unlocked by hand, and the one-session-per-device policy counts it. The form is never minted for the administrator (403), and a bearer session is the mailbox and nothing more: the server drops any administrative role from every request made on one. Brand logos The list shows a sender's BIMI logo as its avatar only when the claim is checked. Three things have to be on: AuthenticationResultsEnabled=1, the DMARC check, and BrandLogosEnabled (on by default). GET /api/v1/me/brand-logo?domain= answers the SVG only when all of these hold: default._bimi.<domain>, or the same name at its organizational domain, is a v=BIMI1 record with an l= location. The domain's DMARC policy is quarantine or reject with pct=100. A domain at p=none gets no logo, even for mail that passed. The location is https, reached only at a public address, within ten seconds and 32 KB, with no redirect followed. The file is SVG Tiny Portable/Secure. Every answer is kept a day per domain. A refusal is 404 with an outcome - no_record, declined, weak_policy, not_https, fetch_failed, not_tiny_ps or switched_off - and the webmail shows the initials. A Verified Mark Certificate (a=) is not validated. What it does not do No ActiveSync and no Exchange Web Services: a phone uses IMAP and SMTP, and CalDAV and CardDAV. Recall reaches this server's own mailboxes only, and only unread copies. A folder of a hundred thousand messages is paged, searched and selected whole without the page copying it: built, and not yet measured. Where to read more Wiki: The Webmail. Documents: WebmailShortcuts.md, OfflineMail.md, ImportantMail.md, ConfidentialMessages.md, SendAs.md, MaskedAddresses.md, RecoverDeleted.md, Signatures.md, FormattedMessages.md, SecondAccount.md, BrandLogos.md. The public documentation: 36.6 The portal: what a user gets. On this forum: The webmail portal at /portal, The Control Deck, Signing in for passkeys and account recovery, and the 6.3.4 announcement.
  • 0 Votes
    1 Posts
    120 Views
    ProgressiverobotP
    Written for 6.3.4. hMailServer 6.3.7 (17 October 2026) calls the anti-virus action that strips attachments Remove all attachments and deliver the text (Delete infected attachments only in 6.3.6's Control Panel), and under it a message whose one part is an attachment, and an S/MIME message signed as one part, now arrive as the action's note alone, where every version to 6.3.6 delivered them as they arrived with what the scanner had found in them; a message the server cannot read is deleted and each of its recipients sent the "message deleted" notice whatever the two notify settings say, while an encrypted message, and one that is one text part, are delivered as they are. In the Control Panel the sidebar has a Virus scanning group, and Ctrl+K takes every old title to its page. See the 6.3.7 announcement. hMailServer 6.3.8 (2 November 2026) lets the Linux server reach clamd over its Unix socket: ClamAVHost takes the absolute path of clamd's LocalSocket, such as /var/run/clamav/clamd.ctl for Debian's and Ubuntu's clamav-daemon, which listens on nothing else by default, and ClamAVPort is then not used; no connection test is offered for it on Linux, so a test message and the log show the scan (ClamAV.md). See the 6.3.8 announcement. hMailServer does not scan for viruses itself. It hands each message to an engine you install (ClamAV, ClamWin or any command-line scanner) and acts on the answer, and nothing described here is enabled on a fresh install. This topic covers the three scanners, what happens when one finds something or cannot run, the blocked-attachment list, and what 6.3.4 added: the sandbox, and the handling of mail sealed end to end. The one thing to know first is the failure mode: a configuration that looks enabled and is inert. A scanner switched on with a wrong host or a missing executable does not refuse mail and does not disable itself; when every scanner has errored the verdict is NoVirusFound, which from the message's point of view is identical to having been examined and found clean. 6.3.4 fixed one such case in the custom scanner, described below. What runs, and when Scanning happens on the delivery side, not during the SMTP conversation: the message has already been accepted and spooled by the time any scanner sees it. Which messages are scanned. For SMTP, a message is flagged when the connecting IP's security range has the virus-protection option; both default ranges ("Internet" and "My computer") have it. For mail this server fetches over POP3 or IMAP, the flag is the external account's own "use anti-virus" setting. A flagged message larger than AntiVirus.MaximumMessageSize is not scanned at all, silently; the default is 0, no limit. Two passes. The whole spool file is scanned first; then every MIME attachment is written to a temporary file and scanned separately, which is what matters for a scanner that does not decode MIME itself. If the message cannot be parsed for the second pass, error HM6001 is reported and only the whole file was scanned; if one attachment cannot be written to the temp directory, HM6002 is reported and that attachment was not examined. Concurrency. At most 10 scans run at once (MaxRunningScanners). A thread that cannot get a slot waits up to 60 seconds and then scans anyway, writing Scanning message without an available virus scanner slot. to the debug log. The read-only overview. Spam & virus filtering → Virus scanning overview in the Control Panel (Windows only) answers the question the settings page cannot: will an infected message be caught here. The three scanners All three can be enabled at once. They run in the order ClamWin, custom, ClamAV, stopping at the first that reports a virus. A scanner that errors does not stop the others. ClamAV (clamd) ClamWin (clamscan.exe) Custom scanner Switch AntiVirus.ClamAVEnabled AntiVirus.ClamWinEnabled AntiVirus.CustomScannerEnabled Default off off off Reached by TCP socket, INSTREAM one process per file one process per file Settings AntiVirus.ClamAVHost (localhost), AntiVirus.ClamAVPort (3310) AntiVirus.ClamWinExecutable, AntiVirus.ClamWinDBFolder AntiVirus.CustomScannerExecutable "Infected" is a reply matching stream…: <name> FOUND exit code 1 exit code equal to AntiVirus.CustomScannerReturnValue Timeout ClamMinTimeout 15 s to ClamMaxTimeout 90 s ExternalProcessTimeout, 300 s ExternalProcessTimeout, 300 s Virus name reported the name clamd returned always Unknown always Unknown ClamAV. Install ClamAV, run freshclam to download signatures, run clamd listening on TCP 3310, set the host and port, and press Test ClamAV connection. The button does three things in order: It sends nPING and requires PONG, so "nothing is listening" is told apart from "something else is listening". It sends nVERSION and reports what answered. It streams a harmless sample and then the EICAR test string, both from memory, and requires the first to come back clean and the second FOUND. A success looks like ClamAV 1.4.2/27700/Mon Sep 8 09:12:11 2026. Result: stream: Eicar-Signature FOUND; a failure names the step, for example Unable to connect to ClamAV server at localhost:3310. The custom scanner. Any program that exits with a distinguishable code will do. Put %FILE% where the file to scan belongs; without it, the file name is appended as the single quoted argument. The Control Panel ships presets, all of them Windows programs: Preset Command line "Infected" exit code Microsoft Defender "C:\Program Files\Windows Defender\MpCmdRun.exe" -Scan -ScanType 3 -File "%FILE%" -DisableRemediation 2 Sophos "C:\Program Files\Sophos\Sophos Anti-Virus\savscan.exe" -ss -archive "%FILE%" 3 ESET "C:\Program Files\ESET\ESET Security\ecls.exe" "%FILE%" 50 Bitdefender "C:\Program Files\Bitdefender\Endpoint Security\bdscan.exe" "%FILE%" 1 Kaspersky "C:\Program Files (x86)\Kaspersky Lab\Kaspersky Endpoint Security\avp.com" SCAN "%FILE%" 2 The 6.3.4 fix: a bare %FILE%. CHANGELOG records that a custom scanner whose command line holds a bare, unquoted %FILE% was handed the message file's path cut at its first space, and the default data directory, under C:\Program Files, has one. The scanner therefore scanned no file, answered with an exit code other than the virus one, and the message was delivered as clean. Since 6.3.4 a %FILE% outside double quotes is replaced by the path in quotes. One already inside them, "%FILE%" as the documented examples above write it, was not affected and is replaced as before. If you run an earlier release with a command line of your own, check it for an unquoted %FILE%. The same fix removes a trailing backslash from ClamWin's database and temporary folders, which escaped the closing quote of --database="..." and took the file to scan with it. The service account must be able to run the scanner. A new 6.3.4 installation on Windows runs the service as NT SERVICE\hMailServer by default; an upgrade keeps the account it had, LocalSystem unless the installer's page is answered otherwise (docs/ServiceAccount.md; the wiki's "LocalSystem by default" describes earlier releases and upgraded servers). Under the virtual account a custom scanner or ClamWin runs as that account: it reads the message in the temp folder, which is granted, and its own program and signatures, which the Users group can normally read; a scanner that needs more has to be granted it (Installing on Windows covers the account and its grants). Whichever account runs yours, the executable, its database directory and the temp directory must all be reachable by it. The symptom of getting it wrong is Unable to launch the virus scanner… reported as HM5406, once per message. For an engine with no command-line interface, the wiki points to an OnAcceptMessage event script or the HTTP filter hook. If you edit hm_settings by hand, note that the custom scanner's return value is stored under the misspelled name customviursscannerreturnvalue. Testing it end to end The test buttons check the scanner. To check the pipeline, send yourself a message with the EICAR test file attached and watch the application log: Virus found in message from [email protected]. Taking actions SMTPDeliverer - Message 4711: Message will be deleted (contained virus Eicar-Signature). Two things spoil that test on Windows. The host antivirus eats the EICAR file before hMailServer sees it, so exclude the mail store and the temp directory from real-time scanning. And your own outbound mail is not scanned by this server if it never comes back in through SMTP from an unauthenticated range, so send the test from an external address. What happens when a virus is found AntiVirus.Action What happens Delete entire e-mail (0, the default) The message is delivered to nobody. Logged as Message will be deleted (contained virus X). Delete infected attachments only (1; Remove all attachments and deliver the text in 6.3.7's Control Panel) The message is rebuilt, not edited: the first text part is kept, everything else is discarded, the VIRUS_ATTACHMENT_REMOVED server message is prepended to the body and the subject is prefixed with the VIRUS_FOUND message. Logged as Message attachments stripped (contained virus X). Since 6.3.7 a message whose one part is an attachment arrives as the note alone, where every version to 6.3.6 delivered it as it arrived, and one the server cannot read is deleted, each recipient sent the "message deleted" notice whatever the two notify settings say Understand the rebuild before you choose it: an HTML alternative, inline images and every clean attachment go with the infected one. AntiVirus.NotifySender and AntiVirus.NotifyReceiver are both off by default, and the wiki's advice is to leave them off: the sender address on a virus is virtually always forged, so a notification is backscatter. When a scanner cannot run Setting Default Meaning AVFailAction 0 0 delivers the message anyway. 1 never delivers unscanned mail AVFailRetryMinutes 15 Minutes between scan attempts while a message is held AVFailMaxHolds 16 Holds before the message is returned to its sender. 0 tells the sender immediately They are on Anti-virus settings → General, card When a scanner cannot run. The wiki describes them as stored in hMailServer.INI; since 6.3.4 the database is the settings store and a [Settings] line edited in the file is put back, so change them in the Control Panel, over REST (PUT /api/v1/settings/ini/{name}) or, on Linux, with hmailserver --set-setting NAME=VALUE. At the defaults, AVFailAction 1 gives a message about four hours to be scanned before its sender is told. The hold counter is the policy's own, not the message's try counter, because incrementing that would make the pass that finally scans the message skip DKIM signing. It lives in memory, so a restart gives every held message a fresh budget, and it is bounded at 100,000 tracked messages; beyond that messages are still held, never delivered unscanned, and HM6195 is reported. Every scanner error is reported as HM5406 with the failing scanner's own text. Blocked attachments Spam & virus filtering → Blocked attachments refuses files by name pattern. The master switch is AntiVirus.EnableAttachmentBlocking, default off. The wiki's starting list: *.exe *.scr *.pif *.bat *.cmd *.com *.vbs *.js *.jse *.wsf *.wsh *.hta *.jar *.msi *.msp *.cpl *.lnk *.reg *.ps1 *.iso *.img The attachment is not deleted. Its file name has .txt appended and its content is replaced by the ATTACHMENT_REMOVED server message, which names the original file through the %MACRO_FILE% macro, so the recipient receives invoice.exe.txt containing an explanation. Matching is a case-insensitive wildcard over the declared file name. It does not look inside archives and cannot see the real type of a file whose extension lies: it is a policy control, not a scanner. Since 6.3.4 the rewrite is logged. A rewritten message produces VirusScanner - Message 1234 from [email protected]: the attachment(s) invoice.exe matched the blocked-attachment list. Their content has been replaced with a notice and the message rewritten. Before this the rewrite left no record anywhere but inside the message itself. Sealed mail: what the list and the scanner do with it Since 6.3.4. A message encrypted end to end, S/MIME or PGP/MIME, is opaque to the server, which decides that from the body, never from the Content-Type label, so a message only labelled encrypted is blocked, stripped and scanned like any other. What opaque means exactly, and what the rest of the server does with such a message, is in the topic on end-to-end encryption and sealed mail. For the list and the scanner, none of it is a setting: The blocked-attachment list never rewrites an opaque message. In 6.3.3 and earlier blocking *.asc, *.p7m, *.p7s or *.* replaced the content of every sealed message with the notice, with no log line, and the plaintext existed nowhere else. It is now delivered as it arrived, and the application log says it was not checked against the list. A signed message is checked like any other, with its detached signature left alone. The scanner still scans it, and what it examines is ciphertext, so a clean result is not a verdict on the message; the log says its content COULD NOT BE EXAMINED for viruses. It is deliberately not treated as a scanner failure, so a server with AVFailAction 1 does not hold every encrypted message it receives; a policy that unscannable mail must not be delivered belongs in a rule or a Sieve script, not the scanner. The "delete attachments" action never strips an encrypted message. When the scanner objects to one, the message is left as it arrived and the log ends Set the anti-virus action to delete the message if such a message must not be delivered. That is not a quiet pass: the message still carries what the scanner objected to. To 6.3.6 this held for every opaque message; since 6.3.7 an S/MIME message signed as one part (application/pkcs7-mime holding signed-data, which is opaque but not encrypted) is replaced by the action's note, as any other single-part attachment is. The sandbox Since 6.3.4 (schema 6091). ClamAV says whether a file is known to be bad. What an unknown file does when it is opened takes a sandbox, a service outside this server which you run or subscribe to. It does nothing until you name one: SandboxEnabled is off, and cannot be saved on while SandboxSubmitUrl or SandboxVerdictUrl is empty. Both addresses must be https, or plain http to this machine. After the anti-virus scan and before the rules, a message that no signed-in user of this server submitted, with an attachment whose extension or media type is in SandboxTypes, is held: in no mailbox, not in the quarantine, and in nobody's digest. The default types are: exe,dll,scr,js,jse,vbs,wsf,hta,msi,bat,cmd,ps1,jar,lnk,iso,img,docm,xlsm,pptm, doc,xls,ppt,rtf,pdf,zip,7z,rar The sandbox says What happens every file clean delivered, with an X-hMailServer-Sandbox header reading clean (<n> file(s), <seconds>s) any file malicious, or suspicious at or above SandboxScoreThreshold (70) quarantined with the verdict as the reason; returned to its sender with 5.7.1 while QuarantineEnabled is off an error, once every file has answered, or no answer within SandboxTimeoutMinutes (15) SandboxTimeoutAction: deliver (0, the default) delivers with a header saying so; bounce (1) returns it to its sender The contract. One POST per file to SandboxSubmitUrl as multipart/form-data with four fields (file, filename, sha256, message_id), then a GET to SandboxVerdictUrl every SandboxPollSeconds (30), answered with JSON such as {"state":"done","verdict":"clean","score":3,"detail":"opened, nothing happened"}. With SandboxToken set, every request carries Authorization: Bearer <token>; the token is not in the backup archive. No vendor adapter is shipped. The document notes that a service which shares what it is sent, as VirusTotal shares uploads with its subscribers, is a place the attachments of the mail you receive go. What is exempt. What your own signed-in users submit (authenticated SMTP, the webmail, the send API) is not uploaded unless SandboxInternalMail is on. The document states the threat model of that default: a stolen password used to send attachments to colleagues is not examined by the sandbox, though ClamAV, the blocked-attachment list and compromised-account detection still apply. Limits. An attachment larger than SandboxMaxSizeKB (20480) is not submitted. A message with more files to submit than SandboxMaxFiles (10) is not held, and SandboxTimeoutAction decides it at once. Archives are submitted whole; links are not opened anywhere. A clean or malicious verdict is remembered by the file's SHA-256 for SandboxCacheHours (24), server-wide. What the administrator sees. GET /api/v1/sandbox/held lists what is held; POST /api/v1/sandbox/held/{queue_id}/release delivers without the verdict and DELETE /api/v1/sandbox/held/{queue_id} discards, both on the audit trail. The Control Deck's queue page has a Held for a sandbox box, and hmctl queue list has a held column. The settings are on the Control Panel's Anti-virus page, Sandbox tab (Windows only), and on PUT /api/v1/settings. Rules, and the message store Since 6.3.4 the mail flow rules can match the attachment's name and, in the outbound content policy, its type read from its bytes, so a rule can hold or refuse what the blocked-attachment list would only rename; that is in the topic on mail flow rules. The message store options added in 6.3.4 change nothing for a scanner: a message is scanned as plain RFC 822 in the queue and only placed in the mailbox in its stored form afterwards. A scanner pointed at the data directory from outside the server reads a compressed or encrypted container, not a message. Error codes Code Meaning HM5406 An enabled scanner errored; the detail names the scanner and the failure HM6001 The message could not be parsed, so only the whole file was scanned HM6002 An attachment could not be written to the temp directory for scanning HM6195 100,000 messages are already held for want of a scan; further holds are not counted HM7272 The sandbox called a message malicious and the quarantine could not store it; it is held again, never delivered Where to read more Wiki: https://gitlab.com/hmailserver/hmailserver/-/wikis/Stopping-Viruses https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/Sandbox.md https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/SealedMail.md Public documentation: https://www.progressiverobot.com/hmailserver-documentation/#11-stopping-viruses, https://www.progressiverobot.com/hmailserver-documentation/#111-clamav and https://www.progressiverobot.com/hmailserver-documentation/#112-blocked-attachments Upgrading to 6.3.4 and the 6.3.4 security announcement
  • 0 Votes
    1 Posts
    145 Views
    ProgressiverobotP
    Written at 6.3.4, when no image was published. hMailServer 6.3.6 (6 October 2026) publishes the first: registry.gitlab.com/hmailserver/hmailserver:6.3.6, also tagged 6.3 and latest, one index over linux/amd64 and linux/arm64, signed with cosign as the release's pipeline; the Helm chart is a file of the release, and the packaging Compose file now runs PostgreSQL 18. See the 6.3.6 announcement and ContainerImage.md at the v6.3.6 tag. hMailServer 6.3.7 (17 October 2026), a security release, is registry.gitlab.com/hmailserver/hmailserver:6.3.7, for linux/amd64 and linux/arm64; in the container the update check names the image to pull and installs nothing, and the schema stays at 6108, so a container recreated on it from 6.3.6 changes no table. Its IMAP and POP3 listeners take the client's address from a PROXY protocol header, as the SMTP listener does, each from the peers on a list of its own, both off by default. See the 6.3.7 announcement. hMailServer 6.3.8 (2 November 2026), a security release, is registry.gitlab.com/hmailserver/hmailserver:6.3.8, and 6.3 and latest now name it; the schema stays at 6108. Its image carries MariaDB Connector/C, so HM_DB_TYPE=MySQL reaches a MariaDB server, where the images of 6.3.7 and earlier stopped at the first start, and its entrypoint secures a new installation at its first start by itself, whatever the container's command, so the run by hand below is left for a database its --create-database made more than a day before. The Helm charts of 6.3.6 and 6.3.7 never started a pod; 6.3.8's runs it as 999:999, fills new volumes in an init container and leaves database.port to the type's own port. Move a release of the older chart with helm repo update, helm upgrade <release> hmailserver/hmailserver --reset-then-reuse-values (Helm 3.14 or later) and then kubectl delete pod <release>-hmailserver-0, since the StatefulSet never replaces the stuck pod by itself (--reuse-values would keep the old chart's fsGroup 1000 and database.port 5432); where a cluster's policy refuses every sysctl, set unprivilegedPortsSysctl: false. For a Compose deployment made from an earlier Compose file, keep its HM_REST_CERTIFICATE_FILE, HM_REST_PRIVATE_KEY_FILE and ./certs mount in the new file, the key readable by 999, or its REST API does not start. See the 6.3.8 announcement. The Linux server is also a container image: the x86-64 .deb installed on Ubuntu 24.04 and run as the hmailserver user, with the database connection, the REST listener, the administrator password and, since 6.3.4, every other setting taken from the environment at start. This topic describes the image, the Compose file and the Helm chart as they are in the tree at the v6.3.4 tag, and the HM_* variables the entrypoint reads. The one thing to know first, from the 6.3.4 changelog: no container image of 6.3.4 is published. The images published on ghcr.io/progressiverobot/hmailserver before GitHub's suspension answer 404, and registry.gitlab.com/progressiverobot/hmailserver, which the Compose file and the chart now name, holds nothing. Meanwhile, build the image from the release's .deb and use your own tag wherever the files say registry.gitlab.com/.... What a Linux server is and is not is in hMailServer on Linux. Building the image yourself Use the docker directory of the same tag as the package: 6.3.4's entrypoint runs verbs a 6.3.3 binary does not have. git clone https://gitlab.com/hmailserver/hmailserver.git cd hmailserver git checkout v6.3.4 cd hmailserver/source/Server/platform/packaging/docker mkdir packages curl -L -o packages/hmailserver_6.3.4_amd64.deb \ https://gitlab.com/hmailserver/hmailserver/-/releases/v6.3.4/downloads/hmailserver_amd64.deb docker build -t hmailserver:6.3.4 . The Dockerfile copies packages/hmailserver_*_amd64.deb (the HM_DEB build argument names another path) and pins its Ubuntu base by digest. 6.3.4's package carries no Sigstore bundle to check it against, as 6.3.3's did. The image is x86-64 only: the Dockerfile takes the amd64 package. What is in the image, and what is not In: /usr/bin/hmailserver, the packaged hMailServer.ini at /etc/hmailserver/, the database scripts, the Control Deck, the fail2ban filter and jail the package carries, ca-certificates for the TLS the server does as a client, and an entrypoint. About 40 MB. The process runs as hmailserver; ports below 1024 are bound through cap_net_bind_service on the binary, not through root. Since 6.3.4 the Dockerfile installs the package's two Recommends, 7zip and python3: without them a backup failed with "Could not add the backup settings to the archive", and hmctl had no Python 3. Not in: the database, and it never will be. Point the image at a PostgreSQL or MariaDB server, in another container or anywhere else; on first start the entrypoint creates the schema there, and on every later start upgrades it if it is behind. Also not in: curl, an editor, or a shell of any consequence beyond bash. There is no Control Panel and no COM; the REST API is the administration. The variables the entrypoint reads itself Sixteen of these are older than 6.3.4 and keep their names and meanings. Variable Writes Notes HM_DB_TYPE [Database] Type PostgreSQL or MySQL (MariaDB is MySQL). Setting this is what makes the section be written at all HM_DB_HOST, HM_DB_PORT Server, Port Defaults db, and the type's port (5432 or 3306) HM_DB_NAME, HM_DB_USER, HM_DB_PASSWORD Database, Username, Password Defaults hmailserver, hmailserver, empty. The user needs the right to create the database on first start HM_DB_TLS_MODE, HM_DB_TLS_CA TlsMode, TlsCaFile Since 6.3.4. disable, prefer (empty, the default), require or verify-full, and the path of a PEM file of CA certificates mounted into the container HM_ADMIN_PASSWORD [Security] AdministratorPassword Through the server's own --set-admin-password: the file holds a PBKDF2 hash, never the password. Without one the REST API refuses to start HM_REST_PORT RestApiPort The packaged file has 0, which is off; 8045 is the conventional port HM_REST_BIND_ADDRESS RestApiBindAddress The packaged file has 127.0.0.1, which inside a container is the container's own loopback HM_REST_CERTIFICATE_FILE, HM_REST_PRIVATE_KEY_FILE RestApiCertificateFile, RestApiPrivateKeyFile Paths inside the container, readable by the hmailserver user. Paths the server reads, not _FILE forms HM_DB_PASSWORD_FILE, HM_ADMIN_PASSWORD_FILE the same two secrets Read from a file: a Docker or Compose secret. Setting both a variable and its _FILE is refused HM_CREATE_DATABASE, HM_UPGRADE_DATABASE nothing 0 skips --create-database or --upgrade-database at start. Both are no-ops when there is nothing to do HM_INI which file is written Default /etc/hmailserver/hMailServer.ini HM_PROFILE, HM_PROFILE_CONFIRM nothing Since 6.3.4: the setting profile applied once, and its confirmation On 6.3.3 and earlier the entrypoint writes these into hMailServer.ini on every start, and every other setting is the packaged file in the /etc/hmailserver volume. Since 6.3.4 the database is the settings store, so the four HM_REST_* variables are aliases of HM_SETTING_RestApiPort and its three neighbours, stored like any other setting; [Database] is still written into the file at every start, because it says where the database is. The image's /etc/hmailserver is owned by the hmailserver user, so there, unlike on a package installation, the server keeps its copy of each stored value in the file, and a --create-database that creates the schema also makes the stored-secrets key. Every setting from the environment (since 6.3.4) After --create-database and --upgrade-database, and before the server, the entrypoint runs the server's own verb, hmailserver --apply-environment, whenever there is anything for it to do. HM_SETTING_<key> sets a [Settings] key and HM_PROPERTY_<name> a database setting (HM_SETTING_MaxSubmissionsPerIPPerMinute=30, HM_PROPERTY_maxmessagesize=20480), and either with _FILE reads the value from a file, a Compose secret or a Kubernetes Secret mounted as one. The environment wins, at every start. An administrator cannot change a setting the environment names until the variable is unset and the container restarted, and a ConfigMap change, too, is applied by a restart and not live. Everything is checked before anything is stored. When anything is refused, nothing is stored and the container does not start; docker compose logs or kubectl logs says which variable and why. The naming rule, every refusal, what the REST API and the Control Deck show for a setting the environment manages, and how a value from a file stays secret are in configuration from the environment. HM_PROFILE names one of the four setting profiles, office, gateway, relay or sender, which the entrypoint applies once, with --apply-profile NAME --once, after the environment's settings and before the server starts. HM_PROFILE=none records that none is wanted. A profile that closes a listener, trusts an address range, turns a filter or a check off or weakens it, or shortens a window of days needs HM_PROFILE_CONFIRM=yes, and without it the container does not start. A profile skips a setting the environment manages, naming its variable. Not yet run in a container. The verb has been run as hMailServer.exe --apply-environment on Windows, and its POSIX half compiles; the entrypoint as a whole, the POSIX verb against a database, the chart and the Compose file have been read and not executed. HM_PROFILE is in the same state. Try it on a scratch database first. Reaching the API The server requires TLS on a REST listener that is not bound to loopback. A listener left at the packaged bind address answers only processes inside the container, which is none, since the image carries no client. Two ways out; either will do: Bind it and give it a certificate. HM_REST_BIND_ADDRESS=0.0.0.0, with HM_REST_CERTIFICATE_FILE and HM_REST_PRIVATE_KEY_FILE pointing at a certificate and key mounted into the container, and the port published. Publish it on the host's loopback (127.0.0.1:8045:8045) unless the certificate is one clients will trust. Host networking (--network host). The container's 127.0.0.1 is the host's, the packaged bind address is right as it is, no certificate is needed, and the API is at http://127.0.0.1:8045 from the host and through an SSH tunnel from elsewhere. The mail ports are then the host's ports directly. First start, with Compose The file to use is in the repository, at hmailserver/source/Server/platform/packaging/docker/docker-compose.yml. It reads the two passwords as Compose secrets from files, mounts the REST certificate and key in, and carries the HM_SETTING_ and HM_PROPERTY_ examples commented out. A workflow resolved it with docker compose config on every change until 18 September 2026, so it is known to parse. Below is the mail service of the wiki's shorter example, which puts it under services: beside a postgres:16 service named db and declares the named volumes. It names the image built above, not the empty registry: mail: image: hmailserver:6.3.4 depends_on: - db environment: HM_DB_TYPE: PostgreSQL HM_DB_HOST: db HM_DB_NAME: hmailserver HM_DB_USER: hmailserver HM_DB_PASSWORD: change-me HM_ADMIN_PASSWORD: change-me-too HM_REST_PORT: 8045 HM_REST_BIND_ADDRESS: 0.0.0.0 HM_REST_CERTIFICATE_FILE: /certs/rest.pem HM_REST_PRIVATE_KEY_FILE: /certs/rest.key volumes: - ./certs:/certs:ro - hm-data:/var/lib/hmailserver - hm-logs:/var/log/hmailserver - hm-etc:/etc/hmailserver ports: - "25:25" - "587:587" - "993:993" - "127.0.0.1:8045:8045" restart: unless-stopped restart: unless-stopped matters on the first start: depends_on starts the database container first but does not wait for it to accept connections, so the entrypoint's --create-database fails and the container exits if the database is not there yet; the restart tries again a moment later. The first start logs the [Database] section written, the administrator password hashed and the database created at schema 6104, and also what --create-database did about the stored-secrets key and one line per HM_SETTING_ or HM_PROPERTY_ variable stored. The first domain is then one request away (First install on Linux). Passwords in a Compose file are passwords in a file: for anything beyond a first look, use HM_DB_PASSWORD_FILE and HM_ADMIN_PASSWORD_FILE with Compose secrets. The secure defaults of a new installation are not applied in a container by themselves. On a package installation the systemd unit's first start gives a database --create-database made a self-signed certificate, STARTTLS and TLS required for authentication on the Internet IP range. The image has no unit, so that step is run by hand, as the service's user, with the server stopped. SecureDefaults.md gives the verb and no container command for it, and lists its refusal of a running service (exit 4) for Windows only: hmailserver --apply-new-install-defaults A run by hand has no one-day deadline, so an image first booted days after its --create-database is secured this way. What it changes, its exit codes and the checklist item that says what is left are in Hardening a public mail server. Kubernetes, with the chart Since 6.3.4 the chart is at hmailserver/source/Server/platform/packaging/helm/hmailserver. It is not published to a chart repository; install it from a checkout. It renders a StatefulSet of one, because a message store is not something two pods may share: the three volumes the image declares become claim templates, and the database is pointed at, never bundled. The chart refuses to render without database.host. Its image.repository names the empty registry, so set it to the image you built, pushed to a registry the cluster's nodes can pull from: helm install mail ./hmailserver \ --set image.repository=<your registry>/hmailserver \ --set database.host=postgres.databases.svc.cluster.local \ --set database.password=... \ --set administrator.password=... \ --set rest.tlsSecretName=mail-tls Value What it is image.repository registry.gitlab.com/progressiverobot/hmailserver as shipped, which holds no image: set it to where you pushed the image you built database.host, .type, .name, .user The PostgreSQL or MariaDB the server uses; required. database.create and database.upgrade make the schema on first start and move it forward on an upgrade database.password, administrator.password Go into a Secret the chart makes. Point existingSecret at a Secret you made and the chart writes none; its two keys are named by existingSecretKeys rest.enabled, .port, .tlsSecretName The one listener that serves the REST API, the Control Deck and the webmail. Off loopback the server requires TLS, so without a kubernetes.io/tls Secret named here the listener will not start; the chart's notes say so on install settings, properties, settingsFrom Since 6.3.4: any [Settings] key (rendered as HM_SETTING_<key>), any database setting (HM_PROPERTY_<name>), and a key of a Secret you made, projected into /run/secrets/hmailserver-settings/<n> and named through _FILE, so a secret value is in no environment variable, rendered manifest or log line service.type, .ports, .externalTrafficPolicy A LoadBalancer with the mail ports, and the REST port when the listener is on. Local keeps the client's address, which auto-ban, greylisting and the Received header all need persistence.* The message store, the logs and the configuration, one claim each Probes Port 25, as the image's own health check is, or the metrics listener's /livez and /readyz when that listener is on settings, properties and settingsFrom look like this in a values file; a settingsFrom entry says setting: in place of property: for a [Settings] key: settings: MaxSubmissionsPerIPPerMinute: "30" properties: maxmessagesize: "20480" settingsFrom: - secretName: mail-relay key: password property: smtprelayerpassword A switch is 1 or 0 for a [Settings] key; true, false, yes, no, on and off are taken as well for a setting whose REST settings row is a switch, and only there: maxmessagesize=off is refused rather than stored as 0, which is "no limit". Volumes, ports and the health check Path What lives there /var/lib/hmailserver The mail store, and .hmailserver-secret-key, the key under which this machine's stored secrets are protected. The key travels with this directory: a database restored beside a fresh data volume cannot open the secrets it holds /var/log/hmailserver The logs. Bind-mount it to a host path if fail2ban on the host is to read it /etc/hmailserver hMailServer.ini; since 6.3.4 the only home of PasswordPepper and [Security] StoredSecretsKey, which no database and no backup holds The image exposes 25, 110, 143, 465, 587, 993, 995, 4190 (ManageSieve) and 8045; publish the ones you serve. The health check opens a connection to the container's own port 25 every 30 seconds after a 60-second start period, and three failures in a row mark the container unhealthy. Upgrades, backups and auto-ban Upgrading is building the image of the new tag, as no 6.3.4 image is published to pull, and recreating the container: the entrypoint runs --upgrade-database on every start, so a schema that is behind is brought forward before the server opens a port. On MySQL and MariaDB the 6102 step rebuilds every table as utf8mb4, which takes time on a large database and needs the database server version that Databases on Linux gives. An upgraded database gets no stored-secrets key until one is made with hmailserver --create-stored-secrets-key, and never the new installation's defaults. Take the backup first (Upgrading to 6.3.4). Backups are three things together: the database, the /var/lib/hmailserver volume, and /etc/hmailserver. Two things are only in /etc/hmailserver/hMailServer.ini: a PasswordPepper, if one is set, without which a restored database refuses the password of every account hashed with Argon2id or scrypt, and the stored-secrets key, without which a restored database opens no account's second factor and no [Settings] secret. Export the key once, and keep the file with the database backups, apart from them: docker exec <container> hmailserver --export-stored-secrets-key /var/lib/hmailserver/stored-secrets.key docker cp <container>:/var/lib/hmailserver/stored-secrets.key ./stored-secrets.key A replacement container takes it back with --import-stored-secrets-key FILE. Auto-ban works as everywhere: an address that fails to log on repeatedly is refused by the listeners. The hook that adds the address to the host's firewall cannot run from inside a container, so run fail2ban on the host instead: bind-mount /var/log/hmailserver, copy the filter and jail out of the image, point the jail's logpath at the mounted directory, and enable it. What was proven, and what has not been Until 18 September 2026 the image job of the GitHub workflow built the image on every release tag, pushed it, and smoke-tested the pushed image as the example above runs it: the container healthy, the SMTP banner and the API answering, and the administrator password in no file in clear. Nothing has run it since. GitLab CI's linux-image job is written to build the image from the .deb with kaniko and inspect it; it neither pushes nor runs the image, so the smoke test has no replacement yet. The chart-and-compose job is written to lint and render the chart and resolve the Compose file. Neither had run when ContinuousIntegration.md was written, on 25 September 2026. The changelog's 6.3.4 section records the first full pipeline the day after: it finished failed, the image job among those that passed, and no pipeline has run on the fixed tree since. 6.3.4 was made by hand. Where to read more Running in a Container and Profiles and Environment on the wiki. EnvironmentConfiguration.md for the naming rule and every refusal, Profiles.md for every entry of the four profiles, SecureDefaults.md for the verb run by hand, and DatabaseTls.md for HM_DB_TLS_MODE. The 6.3.4 section of CHANGELOG.md for the state of the image. On this forum: the 6.3.4 announcement, hMailServer on Linux, First install on Linux and Upgrading to 6.3.4; configuration from the environment for HM_SETTING_ and HM_PROPERTY_ in full, and the hmailserver command line on Linux for the verbs the entrypoint runs.
  • 0 Votes
    1 Posts
    121 Views
    ProgressiverobotP
    Written for 6.3.4. hMailServer 6.3.7 (17 October 2026) changes three things this guide touches. On MySQL and MariaDB a lost database connection is reconnected and the statement run again, so the statement after a database restart, a failover or wait_timeout no longer fails, and an insert that forgives a duplicate key forgives that alone, so an error log that was quiet may now name failures that were hidden. On Windows a stop or a reinitialise no longer waits past a work queue's ten seconds for a thread held by a host or a database that has stopped answering, and setup, the live update's silent install and the uninstaller wait ShutdownDrainSeconds plus 90 seconds for the stop, where they waited plus 30. The ACME HTTP-01 challenge responder, which served one connection at a time, serves up to 64 at once. See the 6.3.7 announcement. This topic describes a running hMailServer 6.3.4 service: its parts and pools, the connection ceilings in front of them, the scheduled work behind them, what the machine needs, and what a folder of a hundred thousand messages costs the webmail. The one thing to know first: nothing in the server is unbounded. There is no thread per connection and no pool that grows. Every piece of work runs on a pool of a set size, and a dependency that stops answering uses up a pool long before it uses up the processor, the memory or the disk. What it cannot give you is a throughput figure: none has been measured yet. The parts of the running service Everything runs in one process: the Windows service hMailServer, or the systemd unit hmailserver on Linux. Part What it is Notes Mail listeners SMTP, POP3 and IMAP on IOService + TCPServer Boost.Asio HttpServer The engine under two listeners: REST (/api/v1, the webmail at /portal, the Control Deck) and web services (MTA-STS policy, autoconfig, ACME http-01) Its own io_context, so a request storm cannot starve SMTP accept. Since 6.2.28 Metrics listener /metrics and the health probes Raw sockets, one worker thread ManageSieve Sieve script management Raw sockets, a thread of its own SNMP agent Read-only, UDP 161 Since 6.3.4; off, and answering nobody until SnmpAllowedAddresses names a manager Background Scheduler, SMTPDeliveryManager, ExternalFetchManager Long-lived tasks on the main server queue State The database (accounts, domains, message metadata and settings, since 6.3.4 including the [Settings] keys), one file per message, and hMailServer.ini as the bootstrap The database stays small next to the store The handover that matters for capacity: an SMTP session never delivers anything. The final 250 is sent from a worker on the asynchronous task queue once the message is written, and delivery happens later on a delivery thread, so the accepting side and the delivering side run out separately. Diagnosing stalled mail takes each half in turn; How email works follows one message through both. The pools, their sizes, and what a full one looks like Seven work queues are created at startup, and the two HTTP listeners have workers of their own. The first column is the queue label of hmailserver_workqueue_depth{queue} on the metrics endpoint, which counts tasks accepted and not yet started; the last row is not a work queue and has no label. Queue Threads Sized by What runs on it When it is full IOCPQueue 15 tcpipthreads Every socket read, write and TLS handshake for SMTP, POP3 and IMAP New connections are accepted but nothing progresses; clients time out mid-command Asynchronous task queue 15 MaxNumberOfAsynchronousTasks Message finalization, which sends the final 250 Senders sit after CRLF.CRLF with no reply Name lookup queue 15 the same setting Blocking reverse-DNS lookups for live sessions Reverse names become "Unknown", so a dead reverse zone cannot stall acknowledgements SMTP delivery queue 10 maxdelivertythreads The delivery of one queued message The queue grows and mail is late; nothing is lost, since the sender already has its 250 External fetch queue 15 MaxNumberOfExternalFetchThreads One run per fetch account Fetch accounts fall behind their schedule Maintenance queue 5, fixed nothing One-shot scheduled tasks; a backup started over REST A slow sweep delays the other one-shot sweeps Server queue 4, fixed nothing Scheduler, IOService, SMTPDeliveryManager, ExternalFetchManager Cannot fill in normal operation: each task holds one slot for the life of the server HttpServer workers 4 per listener, fixed nothing One REST or web-services request each Requests queue behind the four; SMTP is unaffected Where the four sizes are set. On Windows, the Control Panel's Maintenance → Performance page, Threads tab, holds the COM Settings properties TCPIPThreads, MaxAsynchronousThreads and MaxDeliveryThreads, and the [Settings] key MaxNumberOfExternalFetchThreads. On either platform the first three are in the server settings group: the Control Deck's Settings view, PUT /api/v1/settings with tcpip_threads, max_asynchronous_threads and max_delivery_threads, or hmctl settings set. The fourth goes through /api/v1/settings/ini/{name} (administrator password only), hmctl ini set, or on Linux hmailserver --set-setting as root. Since 6.3.4 the database is the settings store, so do not edit the [Settings] line in hMailServer.ini: a line edited there is ignored in favour of the stored value. Settings: when a save takes effect covers the store, what becomes of the file's line (HM5804) and when each key applies. The tab also shows WorkerThreadPriority, read-only: the server stores that value and never reads it. When a new size takes effect. Since 6.3.4 a save applies most settings at once, but MaxNumberOfExternalFetchThreads waits for a reinitialise, because the queue's size is set when the fetch manager is built; GET /api/v1/server/settings/pending lists it until then. For the other three the documents leave a gap: the Control Panel reports a save of its COM settings as "applied immediately", while the Architecture page has the pools created when the servers start, and no document says when a changed count reaches a running pool. Until one does, treat all four alike: after saving, reinitialise (the Control Panel's Reinitialize or Apply now, POST /api/v1/server/reinitialize, or hmctl api POST /api/v1/server/reinitialize) or restart the service. A reinitialise stops and starts every service in place, ends the Control Deck's session, and ends a drain. # The REST listener on this machine: read the server settings group, # then give delivery 25 threads (or: hmctl settings set --set max_delivery_threads=25) curl -u Administrator:pw http://127.0.0.1:8443/api/v1/settings curl -u Administrator:pw -X PUT -H "Content-Type: application/json" \ -d '{"max_delivery_threads":25}' http://127.0.0.1:8443/api/v1/settings # The fetch pool is a [Settings] key; it waits for a reinitialise hmctl ini set MaxNumberOfExternalFetchThreads 20 hmctl api POST /api/v1/server/reinitialize Two asynchronous threads are kept back. Finalization runs the spam tests and event scripts, which wait on things outside the process, so AsyncQueueReservedThreads (2) keeps that many threads free of it: with the defaults at most 13 finalizations run at once. A task waiting for a slot holds no thread and is counted by hmailserver_workqueue_blocking_tasks_waiting{queue}; a count that stays above zero means something those tasks depend on is slow. On Windows the key is on Server limits & expert settings, card Timeouts and queue bounds; anywhere, hmctl ini set. Only two queues report a stall: the asynchronous and name lookup queues write the "every thread busy" report that Diagnosing stalled mail reads. The others are left out deliberately, since their tasks are long-lived by design, so a full IOCPQueue, SMTP delivery queue or External fetch queue shows only in hmailserver_workqueue_depth{queue} and in its symptoms. The database connections every pool shares The deliveries, the sign-ins and the rest of the server share one pool of database connections, NumberOfConnections in the [Database] section of hMailServer.ini, 5 as shipped. A wait for one longer than DBConnectionAcquireTimeout (60 seconds) fails. In hmailserver_db_connections{state}, available pinned at zero with busy at the pool size is saturation, and every mail path is then waiting out that timeout. Raise it with the delivery threads. The relay profile sets maxdelivertythreads to 25 and the sender profile to 50, and each tells you to raise NumberOfConnections to about the delivery thread count. The built-in database has one connection. On Windows, NumberOfConnections is forced to 1 for MSSQLCE. Before You Install calls the built-in engine adequate for anything up to a few dozen mailboxes, and its single connection the first thing to change if the server feels slow under concurrency. Linux has no built-in database; it uses PostgreSQL or MariaDB. Choosing a database covers the choice. It is a bootstrap key. [Database] stays in the file, never in the database, and its keys wait for a restart. On Windows the Performance page's Database tab edits it, on the server machine only. On Linux: sudoedit /etc/hmailserver/hMailServer.ini # [Database] NumberOfConnections=25 sudo systemctl restart hmailserver Connections and requests: the ceilings in front of the pools Where Limit As shipped Set on SMTP sessions MaxSMTPConnections 0, no cap Connections & protocols → Protocols, SMTP tab; max_smtp_connections IMAP sessions MaxIMAPConnections 0, no cap The IMAP tab; max_imap_connections POP3 sessions MaxPOP3Connections not given in the documents; GET /api/v1/settings shows it The POP3 tab; max_pop3_connections Recipients in one message 50,000 50,000 No setting is named; beyond it, 550 Too many recipients REST listener, with the webmail and the Deck 64 connections, closed at accept before any TLS handshake; 4 workers; 64 KB a request (16 MB, with 300 seconds, on the two attachment routes); 30 seconds a request; 300 seconds a connection fixed no key Web-services listener 128 connections; 4 workers; 64 KB a request; 15 seconds a request; 120 seconds a connection fixed no key The webmail shares the REST listener: its users, the Control Deck and every script that calls the API meet the same 64 connections and four workers. Its request rate is in the topic on the REST API. Outbound, since 6.3.4, MaxOutboundConnsPerDestination and MaxOutboundConnsPerTenant (both 0, off) cap sessions per destination and per sending domain; a tenant share larger than the delivery thread count is no share at all (Outbound shaping and IP warm-up). Scheduled work The Scheduler polls its recurring tasks once a minute; one-shot tasks go through the Maintenance queue at once. External fetch is not one of them: the ExternalFetchManager runs its own one-minute loop. Roadmap.md recorded in August 2026 that a due task runs on the scheduler's own thread, so one long task delays the others, and that the real period is the interval plus the run time. The tasks, grouped by cadence: Task Cadence Registered GreyListCleanerTask every GreylistingRecordExpirationInterval minutes (240 as shipped) when SMTP is on RemoveExpiredRecords, WorkQueueHealthTask every minute always; the second writes the stall report TlsRptReporterTask, DmarcRptReporterTask hourly always; each sends only when its from-address is set LogRetentionTask, MailboxRetentionTask start + 6 hours always ArchiveRetentionTask, IMAPExpungeRetentionTask start + 12 hours always MetricsHistoryTask start + every minute always UpdateCheckTask start + every 15 minutes always; does nothing until UpdateCheckEnabled=1 MessageStoreConsistencyTask, DiskSpaceMonitorTask start + hourly always BackupScheduleTask a one-minute tick against the wall clock only when a schedule is set DirectorySyncScheduleTask start + every [LDAP] SyncScheduleMinutes only when that is set AcmeRenewalTask start + hourly only when AcmeEnabled=1 Sizing the machine Memory. Before You Install asks for 2 GB as a minimum on Windows, more for a busy server. It is guidance; nothing enforces it, and memory per session has not been measured. Disk. Size it for how much mail you will keep, not how much you have today. The store is Data\ under the program folder on Windows and /var/lib/hmailserver on Linux, one file per message. For scale, the webmail bench's hundred thousand messages, 5 % of them with an attachment of 20 to 512 KB, are 1,323,987,827 bytes. Below MinimumFreeDiskSpaceMB (100) new mail is refused temporarily, 452 4.3.1 over SMTP and NO [UNAVAILABLE] to an IMAP APPEND, so senders retry; below DiskSpaceWarningThresholdMB (1024) the server logs a warning and keeps accepting. Antivirus. On Windows, exclude Data\, Database\ and Temp\ from real-time scanning. A scanner holding a message file open is a documented cause of delivery that silently does nothing; the server detects one shape of it and logs error 5760. Before You Install names no exclusions for Linux, where the store is /var/lib/hmailserver with its temp and database subdirectories. Durability. MessageStoreFsync (0 as shipped) flushes the spool file to disk before the 250, and a failed flush refuses the message. The published-numbers method runs with it on and labels the shipped default "not durable". Set it on Windows on Server limits & expert settings (card Message store durability), or anywhere with hmctl ini set. A folder of a hundred thousand messages in the webmail Every webmail path reads the folder's shared message collection, the one IMAP, POP3 and delivery use: one small object per message. Until 6.3.4 opening a folder copied the whole of it, under the lock IMAP sessions and delivery wait on, for every page of 200. Since 6.3.4: Operation What it does now What still grows with the folder Open, GET /api/v1/me/folders/{id}/messages One pass under the lock counts the folder and copies only the page of 200; each row's file head is read twice, not three times That pass; and the first open after a start, or after thirty idle minutes in the cache, reads every row of the folder from the database, as an IMAP SELECT does Scroll Older and Newer by before_uid; from three pages up, Oldest in one request (oldest_before_uid) Every page costs what the first costs, wherever it is in the folder: the one pass that counts the folder Search At most 2,000 message files and about two and a half seconds a request, then answers where to go on from; across every folder it walks the mailbox newest first and answers next_before_id; with IndexerFullText and message indexing on (off as shipped), a message the index rules out is passed without opening its file Performance.md does not say Select-all and one action The selection goes to POST /api/v1/me/folders/{id}/bulk: at most 1,000 messages a call for a flag, 500 for a move or a delete, nothing new taken on after about two and a half seconds A changed flag is a row written, a move is a file copied, and a selection narrowed to an inbox tab reads the head of every message Junk over a whole folder is a hundred thousand lessons. With SpamAssassin on, each message moved into or out of Junk queues one lesson, as the same move over IMAP does. In the Settings Reference the lesson on a move is SpamAssassinLearnOnMove (off as shipped), run as a task on the asynchronous work queue. A move holds the mail twice, briefly: original and copy, both counted against the quota, for no longer than one slice of the work. Not changed. The Storage, Subscriptions and Scheduled views still copy each folder they look at; emptying Junk or Trash copies the folder once per 500 messages removed; a quick step over a whole-folder selection still reads at most five thousand messages. For other callers of GET /api/v1/me/search, it now answers complete: false, more: true with next_before_id whenever it is not the end, and its hits come newest first across every folder, where they came folder by folder. Over IMAP, one SEARCH or SORT is bounded by IMAPSearchTimeout (60 seconds) and IMAPSearchMaxMegabytes (2048 MB examined). The webmail portal at /portal covers the rest of the client. What has not been measured The webmail at a hundred thousand. Performance.md has the method and a results table for the Windows bench (SQL Server Compact, 100,000 messages in the inbox, the full-text index off and on). Every cell reads to be measured on the bench; nothing was estimated. Its tools, build/perf/seed-folder.py and build/perf/webmail-timing.py, are in the repository and also run against a Linux server. Record the machine, database, message store, index state and build with any numbers. How much the server carries. PublishedNumbers.md defines messages in and out per second, latency percentiles, IMAP sessions per core with every operation's 95th percentile under 200 ms, memory per session and Dovecot's imaptest, measured against Postfix and Dovecot on the same machine under ten fairness rules. Its results say Not measured yet, and until they are filled the first line of Roadmap2.md is a goal, not a claim. The load generator, in the repository since 6.3.4, is build/perf/load/loadgen.py; a run in which the generator was busy for more than 85 per cent of it gives only a floor and is not published. The Windows bench gives hMailServer's own numbers only, not to be compared with the Linux table. python build/perf/load/loadgen.py --help python build/perf/load/loadgen.py self-test # proves the generator, says nothing about any server Size from the ceilings above, watch the queue depths and the database pool, and treat any throughput figure quoted elsewhere as unmeasured by the project. What it does not do No document gives a rule for sizing the pools to a machine; the only sized examples are the relay and sender profiles' 25 and 50 delivery threads. The main server queue, the maintenance queue and the HTTP listeners' workers, connection caps and deadlines are fixed; no key changes them. Where to read more Wiki: Architecture, Before You Install, Control Panel Reference section 7.8, Settings Reference, The REST API section 8, APIs Reference section 1 and Monitoring and Health. Documents: Performance.md, PublishedNumbers.md, SettingsApply.md, Profiles.md and OutboundShaping.md. The public documentation: Before you install, A database and Prometheus metrics and health probes. On this forum: Diagnosing stalled mail, Reading the logs, Monitoring and health, The webmail in depth, The Control Deck and hMailServer on Linux. Without a GitLab account, a bug or a question can go to the Service Desk address contact-project+hmailserver-hmailserver-86730559-issue-@incoming.gitlab.com, which GitLab files as a confidential issue; the replies arrive by email.
  • 0 Votes
    1 Posts
    137 Views
    ProgressiverobotP
    Written for 6.3.4. hMailServer 6.3.8 (2 November 2026), a security release, reads on Windows the CA file MessageStoreObjectCaFile names from the file configured, or refuses its name: a name it cannot hand to OpenSSL as it is named, as a path holding characters outside ASCII may be, is refused with HM7536 in the error log, and then no request to the object store is allowed, so give the file, and the folders above it, an ASCII name before upgrading. See the 6.3.8 announcement. This topic covers the three things 6.3.4 can do to a stored message that 6.3.3 could not: store it compressed with its large attachments held once, keep it in an S3-compatible or Azure Blob store instead of under the data directory, and store it encrypted to a key of the mailbox's own. All three are new in 6.3.4, and all three are off until an administrator turns them on: a server that turns none of them on stores every message exactly as 6.3.3 did. The one thing to know first: the delivery queue is never compressed, encrypted or put in the store. A queued message is plain RFC 822 on the local disk, so the event scripts, the virus scanners, SpamAssassin, the sandbox and the filter hook read what they always read. One door, and what a client sees Every place that reads, sizes, copies, moves or deletes a stored message now goes through one seam, MessageStore. Everything that hands a message out reads the original bytes back through it, so a client sees nothing different: IMAP FETCH BODY[], RFC822.SIZE, BODYSTRUCTURE, SEARCH, SORT and THREAD; POP3 RETR, TOP, LIST and STAT; the webmail and the REST API; the rules, Sieve, the quota (which counts the original sizes), and a DKIM signature checked against the stored copy. What changes is the file itself. Message.Filename over COM still answers the stored file's path. With compression on, a COM client or a script that opens that path reads a container, not RFC 822; with an object store, once the message is committed there is no file there. Read the message through the API instead. Compression and attachments held once Schema 6098. With MessageStoreCompression on, a message delivered to a mailbox, or put there by IMAP APPEND, is stored as a container: the message compressed with deflate, and each large MIME part, an attachment of at least MessageStoreDedupMinKB once decoded, cut out and held once as a blob that every message carrying the same bytes shares. A mail sent to forty people on this server with a 10 MB presentation keeps the presentation once. Where What Control Panel (Windows) Settings > Maintenance > Performance, the Message store tab Control Deck The two settings on the server settings page, and a Message store section on the dashboard REST message_store_compression and message_store_dedup_min_kb in /api/v1/settings; GET /api/v1/store; POST /api/v1/store/measure COM (Windows) Settings.MessageStoreCompression, Settings.MessageStoreDedupMinKB, Settings.MessageStoreStatus Both are rows of hm_settings and take effect for the next message stored. MessageStoreDedupMinKB is 256 unless changed, 0 to 65536, and 0 holds nothing once and compresses only. The switch rewrites nothing. The reader never asks the setting what a file is: it reads either form, per file, by the file's first eight bytes. Turning it off leaves every stored message readable. Only delivery and IMAP APPEND write the new form. A webmail draft, a message saved through COM, the importers and the IMAP mirror write plain. A message larger than 64 MB is stored as it came, because a container is decoded whole in memory. Every container is checked before a byte is served: its structure, each blob against its hash, and the SHA-256 of the whole. A damaged one is refused and named in the error log (HM7350), never served as something else. Two messages share a blob when the part's encoded bytes are identical. The same file attached twice by a client that wraps its base64 differently is two blobs. The command line, run as the user the server runs as (hMailServer.exe takes the same switches on Windows): hmailserver --measure-store [--account <address> | --domain <name>] hmailserver --compress-store [--account <address> | --domain <name>] [--decompress] hmailserver --recount-store Verb What it does Server --measure-store Changes nothing; prints what the store takes now, what it would take, and how much more it would save May be running --compress-store Rewrites every stored file in place, each written beside itself, read back and compared byte for byte, then renamed over its name Stopped --compress-store --decompress Puts the files back as plain RFC 822 Stopped --recount-store Sets every blob's count to what the stored files reference Stopped Exit 0 is a finished walk, 1 a file that could not be read or written, 2 no database or no such account or domain, and 4 a running server. How much it saves has not been measured on a real mailbox. MessageStore.md holds a placeholder where the figures are to go, and the wiki claims none. Run --measure-store on your own store and read the answer. The document also records that its regression fixture had not yet run against a bench when it was written. Object storage: S3 and Azure Blob The messages in the mailboxes, the blobs and the recover-deleted store's kept copies can be kept in an S3-compatible store (AWS S3, MinIO, Wasabi, Backblaze B2's S3 API, Ceph RGW), signed with AWS Signature Version 4, or in Azure Blob Storage, with Shared Key or a SAS. The database stays the index, and an object is named by the path its file had. There is no schema step. Nothing changes until a store is named. Every write is a local one. A message is placed on the local disk exactly as without a store, and a committer puts it into the store afterwards, and only then removes the local file. It runs every minute; what it has been handed is journalled in <data directory>\_ObjectStore\pending.txt and taken up again after a restart. So a delivery never waits for the store and never fails for it, and every read looks at the local disk first. Property REST key Default MessageStoreBackend message_store_backend file; the other value is object MessageStoreObjectService message_store_object_service empty; s3 or azure MessageStoreObjectEndpoint message_store_object_endpoint empty, which is the service's own MessageStoreObjectRegion message_store_object_region us-east-1 MessageStoreObjectBucket message_store_object_bucket empty MessageStoreObjectAddressing message_store_object_addressing auto; also path, virtual MessageStoreObjectPrefix message_store_object_prefix empty MessageStoreObjectAccessKey message_store_object_access_key empty MessageStoreObjectSecret message_store_object_secret empty MessageStoreObjectSasToken message_store_object_sas_token empty MessageStoreObjectCaFile message_store_object_ca_file empty MessageStoreObjectCacheMB message_store_object_cache_mb 32 These are rows of hm_settings, set over /api/v1/settings or on the Control Deck's Settings > Server form. There is no COM member and no Control Panel page for them. The secret and the SAS are protected, never emitted, and left out of a backup, so after a restore onto another server they are entered again. TLS verification is always on, and no setting turns it off. With MessageStoreObjectCaFile set, the certificate is verified against that file: beside the system's bundle on Linux, and alone on Windows. Turning it on: Make the bucket or container. This server makes none. For S3, an access key allowed s3:GetObject, s3:PutObject, s3:DeleteObject, s3:ListBucket and the multipart upload calls, and a lifecycle rule that aborts incomplete multipart uploads after a day. Name it, with message_store_backend still file. Copy the store into it: hmailserver --copy-store-to-object-storage. It runs beside the server, verifies every object, and goes on where it stopped when run again. Switch new mail to it: message_store_backend to object, taken within five seconds. Remove the local copies when you are ready: stop the server and run the copy again with --remove-source. Going back is the same the other way: message_store_backend to file, then hmailserver --copy-store-from-object-storage, then, once it says nothing failed, message_store_object_service to empty. When the store cannot be reached, mail is not lost. Door Answer for a message only the store holds A delivery Placed on the local disk and done; committed when the store answers again IMAP FETCH NO [UNAVAILABLE] POP3 RETR and TOP -ERR [SYS/TEMP] The webmail and REST 503 HM7473 says, once an hour at most, how many files wait on the local disk. HM7471 is the store refusing this server's credentials (401, 403), which is never taken for an absent message. HM7476 names a message that is neither on the disk nor in the store; no placeholder is written for it while a store is named. What is thinner than it could be: one connection per request; one committer, a pass at a time; credentials are the two the settings hold, with no instance role, no workload identity and no Azure AD token; the Import Tool and the data directory synchronizer walk the local disk only; a changed prefix is not a move. The requests were proved against MinIO and Azurite on the project's lab on 25 September 2026. The server's fixture needs that lab, is skipped on a bench without one, and has not run in the Linux suite. Mailbox encryption at rest Schema 6099. A mailbox whose messages are stored encrypted to an X25519 key of the account's own, with AES-256-GCM, as the container's last stage, after compression. The key opens when the person signs in with their password and closes MailboxKeyIdleMinutes after the owner's last use (15 by default, 1 to 1440). Mail that arrives while nobody is signed in is encrypted all the same, because encrypting needs only the key's public half. When a password is lost, the domain's recovery key opens the key again. It is off for every account until an administrator switches it on for that account. What it protects: a disk taken out of the machine, a snapshot of the volume, a backup archive, the data directory copied somewhere, the database dumped, once the account has signed in with its password after the switch. What it does not protect. It does not protect the mail from the running server, which reads it while the key is open, nor from whoever runs the server, who can capture a password at sign-in. A person who needs that wants a zero-access mailbox, and the two are never on one mailbox. Until the first sign-in the key is pending, held under this machine's own secret: the machine's DPAPI key on Windows, and the key file .hmailserver-secret-key inside the data directory on Linux. On Linux a copy of the data directory together with the database therefore opens every pending key. An account that only receives mail and never signs in with its password stays pending. Switching it on, on the Control Deck or over REST: The domain's recovery key first: the domain page's Mailbox recovery key card, or PUT /api/v1/domains/{domain}/mailbox-recovery with {"passphrase": ..., "hint": ...}. The passphrase is at least 12 characters, and this server never stores it. Keep it where this server is not. Then the account: the Encryption at rest card, or PUT /api/v1/accounts/{address}/mailbox-encryption with {"enabled": true}. The switch-on ends every session the account had. A mail client sees its connection drop; the person signs in again with the password. Then what is already there: hmailserver --encrypt-store --account <address>, with the server stopped. Refused, with a sentence: an account whose domain has no recovery key; a zero-access mailbox; an account that signs in against a Windows directory; and an account with a TOTP second factor enrolled. The last runs both ways: a second factor cannot be enrolled on an account that has a mailbox key. Sign-in. IMAP and POP3 sign a keyed mailbox in only with its own password. An app password, an OAuth2 token, a client certificate or a Kerberos ticket is answered NO [UNAVAILABLE] (POP3: -ERR [SYS/PERM]), and the IMAP master user is refused whether or not the owner's key is open. The webmail refuses a passkey, single sign-on or an app password (403, mailbox_key). SMTP, ManageSieve and DAV are not refused, since they do not read the mailbox. Passwords. The person's own change wraps the key by the new password. An administrator's reset does so while the key is open or pending, and is refused while it is closed, with a sentence naming the recovery: POST /api/v1/accounts/{address}/mailbox-encryption/recover with the domain's passphrase and the new password. A lost password and a lost recovery passphrase together are the mail lost. Nobody can open it. DELETE /api/v1/accounts/{address}/mailbox-encryption?confirm=forfeit removes the key, and with it every message still encrypted to it, for ever. It is refused while a legal hold covers the account. Switching it off stores new mail as it comes. What was stored encrypted stays encrypted until hmailserver --decrypt-store, run with the server stopped and the domain's passphrase in the environment variable HMAILSERVER_RECOVERY_PASSPHRASE, never on the command line. A keyed mailbox stays in its domain. A move to another domain is refused (409); a rename inside the domain goes ahead. What stays in the clear: the queue and the copy a delivery works on, which is overwritten with zeros once placed, though a copy-on-write filesystem, a snapshot or an SSD may not honour that; the calendar and the address book; the quarantine, the archive and journal copies, and a forward; each message's row in the database, with its size, flags, folder and dates; logs and the trace. An encrypted mailbox holds no part once. A blob shared between accounts cannot be under one account's key, so every part stays inside the message's own container. The cost is the de-duplication, for these mailboxes only. Backups A backup with messages copies the data directory as always, with the containers, the Blobs directory and encrypted files as they are. It copies file by file while the server runs, so an archive taken while mail is delivered and deleted can hold a container whose part was written after the Blobs directory was copied: take the backup when the server is quiet. With an object store it also reads every message and blob that is only in the store into the archive; a store that cannot be read fails the backup. A full restore rebuilds the blobs' counts from the stored files. hm_storeblobs is not in the archive. An encrypted mailbox's key is carried with its account, and the domain's recovery key with its domain. A pending key on Windows opens only on the machine that wrote it; elsewhere the mailbox is opened with the recovery. On Linux it opens wherever the key file goes, and the archive does not carry that file. A restore onto a server that never turned compression on reads the containers the archive brought. Known limits in 6.3.4 Limit Where it is stated Encryption at rest is on REST and the Control Deck, with no webmail card and no Control Panel page. The person's own view is GET /api/v1/me/mailbox-encryption CHANGELOG, EncryptionAtRest.md No COM for the switch, the recovery or MailboxKeyIdleMinutes EncryptionAtRest.md A search over an encrypted mailbox reads every message, and works only while its key is open. The full-text index holds none of its words EncryptionAtRest.md With compression on, Message.Filename over COM names the stored form; with an object store it names a path that holds no file once committed CHANGELOG, MessageStore.md, ObjectStorage.md An account's key is never rotated; the domain's recovery key can be replaced EncryptionAtRest.md The encryption fixture runs on Windows and is not in the Linux suite EncryptionAtRest.md No zstd: deflate is the one stage MessageStore.md Where to read more The wiki page: Message Storage The documents: MessageStore.md, ObjectStorage.md and EncryptionAtRest.md, each with its error codes and what proves each claim Zero-access mailboxes: the wiki's Encryption for People, and on this forum End-to-end encryption The documentation page: Backup and restore On this forum: The Control Deck, where the cards and the settings form are, and Backup and restore, for what the archive holds and what it leaves out
  • 0 Votes
    1 Posts
    146 Views
    ProgressiverobotP
    Written for 6.3.4. hMailServer 6.3.7 (17 October 2026) adds no schema step, so a 6.3.6 node and a 6.3.7 node both require schema 6108 and the version check at start does not tell them apart: upgrade both nodes all the same. On MySQL and MariaDB a lost database connection is reconnected and the statement run again, so the statement after a database restart or a failover no longer fails; and the first units of the active-active work are in the tree, and it is still not built. See the 6.3.7 announcement. hMailServer 6.3.8 (2 November 2026), a security release, adds no schema step either, so a node of 6.3.6, 6.3.7 or 6.3.8 requires 6108 and the check at start does not tell them apart: upgrade both nodes all the same. See the 6.3.8 announcement. This topic covers what hMailServer 6.3.4 offers for a second machine. Two operator documents describe one topology from two angles: WarmStandby.md, on what has to reach the standby and the constraints that decide the design, and HighAvailabilityRunbook.md, the active/passive procedure with a virtual IP and a health check. The one thing to know before anything else: exactly one hMailServer service may run against the database at a time. There is no clustering code and no active/active mode. Failover is starting a service on the other machine, which takes minutes, and a second service started against the same database while the first still runs delivers mail twice, with no error anywhere. Why exactly one service may run The code makes two servers destructive to each other in three ways: Startup unlocks the whole queue. PersistentMessage::UnlockAll runs update hm_messages set messagelocked = 0 for every queued message when the service starts, because locks held by a crashed server would otherwise stall those messages for ever. A second server started against the same database releases every message the first is delivering, and both then deliver them. Delivery selection is not coordinated. The queue is driven by select ... where messagelocked = 0 and messagenexttrytime <= now, and the lock is taken after selection, so two servers race that window even after both have started cleanly. External fetch has the same shape. PersistentFetchAccount::UnlockAll runs at fetch-manager start, and a POP3 account fetched twice with delete-after-download is mail split between two stores. The rehearsal of 9 September 2026 measured the first. With the primary holding a queued message locked, the standby was started on a second machine: it bound the same ports, because the listeners are rows in hm_tcpipports and both servers read them, and the lock went to 0. Nothing warned, on either side. So the standby is warm: installed, configured, patched along with the primary, and its service set to Manual start and left stopped. WarmStandby.md says Manual rather than Disabled, because a disabled service cannot be started by the runbook without an extra step. The HA runbook accepts either in its section 4 and its check-list, though the wiki's copy of it lists Disabled among the ways the pair is got wrong, for the same reason. Use Manual. What lives where State Lives in Reaches the standby by Domains, accounts, aliases, lists, rules, routes, IP ranges The database Sharing the database Message metadata, folders, IMAP state The database Sharing the database The delivery queue hm_messages rows and spool files on disk The database and the data directory Greylisting triplets, auto-ban ranges, login-failure state The database Sharing the database Message files (.eml) The data directory Replication or shared storage, below The [Settings] section Since 6.3.4 the database (hm_inisettings, schema 6042); the INI section is a cache Sharing the database [Directories], [Database], [Security], [GUILanguages] Each machine's own hMailServer.ini Configured per machine, deliberately [SettingsOverride] Each machine's own INI Not shared, on purpose PasswordPepper Each machine's own INI; no database and no backup holds it Copied by hand TLS certificates and keys, DKIM keys Files on disk; the database stores their paths The same files at the same paths Route, external-fetch and per-domain-relay passwords The database, DPAPI-protected They do not travel An account's second factor and the [Settings] secrets The database, sealed under the stored-secrets key (schema 6103) Sharing the database, once the standby holds the key The settings store, since 6.3.4. A standby that shares the database inherits the primary's server settings at its next start, without anything copying INI files. Each setting the primary changed since the standby's file was written is named in the standby's error log as HM5804 and its line rewritten: that is the copy catching up, not a fault, and it appears once. A stale file can still do two things. A setting deleted on the primary whose line the standby's file still carries is adopted back into the shared store when the standby starts, so delete it from the standby's file as well. And a key the standby's file holds that the primary never had is adopted the same way. Do not copy the primary's INI over the standby's. On Windows [Database] Password is sealed with the machine's own DPAPI key, and the copy breaks the standby's database connection outright. Treat "no [SettingsOverride] section on either machine" as part of the check-list: a key in that section is applied over whatever the database holds. On 6.3.3 it was different. The wiki records that the [Settings] section was not carried by the shared database there, and had to be copied into the other machine's INI after every settings change. The wiki also notes that [LDAP], [SendingLimits] and [SendingLimitsOverrides] stay in each node's file in 6.3.4, to be kept the same by hand. Paths must resolve on both machines. If the primary's row says C:\certs\mail.pem, the standby needs that file at that path. Rotated DKIM keys are written into <DataDirectory>\DKIM; a standby promoted with an out-of-date copy of that folder signs with a key file it does not have. What does not travel Passwords the server presents to somebody else. Route smart-host credentials, external fetch accounts, the per-domain relay password and SSL private-key passphrases are stored protected by Windows DPAPI with CRYPTPROTECT_LOCAL_MACHINE, and a blob written by one machine cannot be decrypted by another. After a failover the server keeps running, with local delivery, IMAP, POP3 and unauthenticated relaying untouched, but each such credential fails until it is re-entered on the standby. Failback breaks them the other way. Keep the list of credentialed routes, fetch accounts and relay-configured domains, with the passwords in a password manager. It is between zero and five entries on a typical installation. The wiki has a PowerShell script over COM that lists them; none of its getters returns a password. On Linux the server seals these with a key file, <DataFolder>/.hmailserver-secret-key. In the rehearsal the route arrived without its password, the standby reported HM6412, and the delivery attempt went out unauthenticated. A replication that copies the data directory wholesale copies that file too, and the standby then reads every secret the primary could. Decide which you want; the rehearsal excluded it. The administrator password. It lives in hMailServer.ini, not in the database. Until it is set on the standby, the standby has no REST API and no COM authentication. On Linux the command is sudo hmailserver --set-admin-password. On Windows WarmStandby.md names the Control Panel, but a standby installed with 6.3.4 is a new installation, whose service runs as NT SERVICE\hMailServer by default, and under that account the Control Panel's password card is refused: use hMailServer.exe --set-admin-password from an elevated prompt, which is new in 6.3.4. The rehearsal found this when the standby came up mute. PasswordPepper. With a different value on the standby, or none, no account whose password is an Argon2id or scrypt hash signs in there. The stored-secrets key, since 6.3.4. It is kept in each server's INI as [Security] StoredSecretsKey, never in the database, so the standby needs it carried: export it on the primary, elevated, with hMailServer.exe --export-stored-secrets-key FILE (sudo hmailserver ... on Linux), and import it on the standby with --import-stored-secrets-key FILE. If the standby's installer made a key of its own, remove its [Security] StoredSecretsKey line first, or the import is refused. Never copy the line itself; it is sealed under the primary's own DPAPI. Without the key every second factor refuses every code on the standby, and SRS, BATV, HS256 tokens and /metrics stop working there; the standby says so as HM7495 at each start. Hardening a public mail server has what the key seals, and what to do on an upgraded server, which has none until you make one. The message store: the two documents differ WarmStandby.md says replicate, and do not share. It argues against putting the data directory on an SMB share or a Cluster Shared Volume: The accept path flushes the spool file to disk before answering 250. Over SMB that guarantee depends on the file server and on the network between the machines, which is the failure the topology exists for. Local delivery copies the spool file once per local recipient, and the delivery loop rewrites message files; each becomes network I/O. Handles and oplocks held by a crashed primary take time to expire, so the standby can find its files locked by a machine that no longer exists. Cluster Shared Volumes are built for large block-oriented I/O, not millions of small files. It recommends a one-way scheduled robocopy /MIR of the data directory every few minutes, or DFS-R or storage-level replication, stopped as part of failover, so that a stale mirror pass from a half-dead primary cannot delete newly accepted mail. Messages accepted after the last sync are rows whose files are missing on the standby. The server treats a missing file as an error on access, not a crash, so a five-minute interval bounds the loss to five minutes of message bodies; metadata, folders and flags lose nothing. The document calls the SMB and CSV reasoning operational judgement built on code facts, not itself a code fact. HighAvailabilityRunbook.md says share. Both nodes must see the same [Directories] DataFolder on shared storage (SAN, NAS, a clustered file system or a cloud file share), with identical read and write access for both service accounts, and the database and the store on the same failover boundary. Its validation check-list begins with that. Neither document withdraws the other. Choose knowing what each claims. The database Use a server backend, SQL Server, MySQL or MariaDB, or PostgreSQL, with its own availability. SQL Server Compact is excluded: it is a single local file. Both machines must run the same build: REQUIRED_DB_VERSION is checked at every start and refuses a mismatch in both directions, so a half-upgraded pair fails safe at startup where the two builds require different schemas; 6.3.6, 6.3.7 and 6.3.8 all require 6108, so that check does not catch a pair split between them. Health probes and the load balancer The probes are on the metrics listener, enabled on both nodes. Since 6.3.4 these are stored settings: set them in the Control Panel (Windows), over /api/v1/settings/ini, with hmctl, or on a Linux node whose REST listener is not yet on with hmailserver --set-setting NAME=VALUE. Then reinitialise (Apply now, or POST /api/v1/server/reinitialize) or restart. [Settings] MetricsServerPort=8080 MetricsServerBindAddress=0.0.0.0 MetricsServerAuthToken=<32+ random characters> ShutdownDrainSeconds=30 Point the health check at /readyz. It answers 200 only when the server is Running and the database is connected, and 503 while stopping, while draining, or when the database is lost. The passive node's service is stopped, so its connection is refused and it is never routed to. /metrics answers 503 on a non-loopback bind until MetricsServerAuthToken is set; the probes need no credential. ShutdownDrainSeconds defaults to 0, which waits for nothing. Set it. The drain is /api/v1/server/drain, on the REST listener: GET its state, POST to enter, DELETE to leave. Monitoring and health has the three probes and the listener's rules. Two matter here: MetricsServerBindAddress takes an IP literal and nothing else, since a host name keeps the listener, and so the probes, from starting; and the listener serves one connection at a time, so no sub-second health check. Building the standby Install the same build as the primary, with the same drive letter and directory layout. Point its [Database] section at the same database server, verify that it connects, then stop the service and set it to Manual. Set up the store: replication from primary to standby, or the shared directory. Record the credential list somewhere that survives the primary. Set the administrator password, copy PasswordPepper, and import the stored-secrets key. Rehearse the failover at least once before it is real. Planned failover The drain as a state of its own and UpgradeOnStart are new in 6.3.4. On 6.3.3, step 1 is a plain stop with ShutdownDrainSeconds set. Drain A: POST /api/v1/server/drain (manage_server), or the Drain button in the Deck's update view. A closes its SMTP, IMAP, POP3 and ManageSieve listeners, pauses its delivery queue, and reports /readyz 503 draining. A new connection is refused at the socket, which every MTA retries. REST, metrics and web services stay up. Watch GET /api/v1/server/drain until drained is true. Stop A. Verify A is down: /readyz on A is refused. Stop the replication job, in whichever direction it runs, where the store is replicated rather than shared. Do it before B is started. The runbook has no such step, because it assumes a shared store; WarmStandby.md's failover has it. In a planned failover A's machine stays up, so a scheduled mirror pass from it would delete the mail B has just accepted. Move the VIP to B. Start B and wait for /readyz 200. If B's binaries are newer than the shared schema, B refuses to start (HM5011) unless its INI says [Database] UpgradeOnStart=1. Take the database backup before this step. One node at a time. Check the key on B: no HM7495, HM7492 or HM7493 in its error log. /readyz is 200 either way, so this is the check that finds a missing key. Confirm: a test message through the VIP, and /healthz reporting database: up and state: running. Then re-enter the route, fetch and relay passwords on B, as after an unplanned failover. Reverse the steps to fail back, the check of the key included; with a replicated store, follow Failback below, which resynchronises the store first. To take A off the air without a failover, do step 1 and then DELETE /api/v1/server/drain, which reopens the same ports. Unplanned failover The health check on /readyz fails; stop routing to A. Fence A: power it off, or isolate it from the storage and database network. Unreachable is not the same as dead. Stop the replication job, where the store is replicated. Move the VIP, or the DNS record or port forward, to B. Start the service on B. Its startup unlock now works for you: messages the dead primary held mid-delivery are released and retried. A message the primary had delivered but not yet deleted from the queue may be delivered again, which is the known, bounded cost. Re-enter the route, fetch and relay passwords on B. When A is repaired, bring it back stopped, as the new passive node. The two documents order the middle differently: the runbook moves the VIP and then starts B, and WarmStandby.md starts the standby and then moves the endpoint. Both put the fence first. Keep MX TTLs modest, 300 to 3600 s, as a standing decision. Sending servers retry for days, so mail queued remotely during the gap is delayed, not lost. Failback is the same with the roles swapped, plus one step first where the store is replicated: resynchronise the data directory backwards, because the standby has been accepting mail and the primary's store is now the stale one. Then stop the standby's service, replicate from standby to primary once more for the final delta, start the primary, move the endpoint back, re-enter the route, fetch and relay passwords on the primary, and set up the replication from primary to standby again. Verify, after either direction GET /readyz answers 200: the service is running and the database has answered a real round trip within 20 s. A message sent in from outside, read back over IMAP or POP3. A message sent out through each credentialed route. No ERROR entries in the application log since the failover. TLS on 25, 143, 110, 587, 993 and 995 presents the right certificate. What it does not do, and what has not been rehearsed No active/active, no sharding, no clustering code. The runbook puts them out of scope. Not seconds. Failover is minutes, and the timings on the wiki pages are the shape of a rehearsed failover, not a promise. No rolling restart on one node. A single node refuses connections for the length of an install. The drain's state is per process, so a pair drains one node at a time. The rehearsal was a Linux pair: an Ubuntu 26.04 standby, one PostgreSQL 18 database, an SSH tunnel standing in for a LAN route. Not rehearsed: a Windows-to-Windows pair, DPAPI itself, a file-replication product rather than a copy at the moment of failure, DNS or IP takeover, and failback. Where to read more The wiki pages: Warm Standby, with the worked commands and the recovery-window table, and High Availability Runbook, with the probe-lifecycle table and load-balancer configurations The documents: WarmStandby.md and HighAvailabilityRunbook.md The documentation page: A warm standby, active/passive high availability and Prometheus metrics and health probes On this forum: Upgrading to 6.3.4, Moving to new hardware, and Settings: what is not stock and when a save takes effect, for the settings store and [SettingsOverride]