# Changelog All notable changes to **Hermes Secure Email Gateway** are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Hermes uses **calendar versioning**: release tags are `vYYMMDD`, where the digits are a planning label named for a target date — **not** necessarily the ship date. The date shown beside each release below is the **actual release date**. ## Unreleased ### Added - **Network aliases: named sets of IP ranges, resolvable from a DNS record** (#324). Two tables and two disabled seed rows. A `static` alias is a hand-entered list given a name; an `spf` alias carries a hostname such as `_spf.google.com` that a resolver will expand. Nothing reads them yet: the tables land first so the resolver, the console page and the first consumer are not each also a migration. The case for a shared facility rather than another per-feature list is already in the tree. `postscreen_access.cidr` ships with 129 hand-pasted Microsoft ranges and nothing keeps them current, and #323 was about to add a second list with the same defect for Google. Four consumers want the same thing: that postscreen list, Relay Networks, the domain-restricted relay sources of #323, and the fail2ban whitelist. IPv6 ranges are stored and marked by `family` rather than discarded. The mail containers set `net.ipv6.conf.all.disable_ipv6=1`, so they are filtered at render today, but storing them keeps the data correct if that changes. `origin` separates hand-entered rows from resolved ones so a re-resolve never discards what an operator typed, and `first_seen` / `last_seen` give change detection without a second table. - **A console page for managing network aliases**, under System (#324). Create an alias, give it a source, and for a hand-entered one add and remove ranges. The page says plainly, in a callout at the top, that nothing consumes aliases yet and that nothing is regenerated automatically when one changes, because an operator who assumes otherwise would be wrong in a way that is hard to notice. Ranges are validated before they are stored rather than after. A malformed CIDR in a Postfix lookup file is not rejected loudly, it silently never matches, so anything that is not a valid range is skipped and the operator is told how many. IPv6 entries are shown, badged, and labelled as unused while IPv6 is disabled in the mail containers, rather than being hidden. A range that is stored but not applied should be visible as exactly that. Delete is guarded rather than cascading, matching the shape of #320. No consumer exists yet so it cannot currently refuse, but the path is written now so that adopting the first consumer is a change in one place and nowhere else. - **A scheduled resolver for SPF-backed aliases** (#324). Runs daily at 03:30 via Ofelia, expands the record including `include:` and `redirect=` at the SPF specification's ten-lookup limit, and records what changed. Advisory only: it writes alias ranges and nothing else, renders no config file and reloads no service, so it cannot affect mail flow. Three rules it exists to honour. A failed or empty resolve **never empties the list**, because losing ranges to a resolver hiccup is worse than stale ranges. Stderr is never treated as data, which `inc/rbl_test_entry.cfm` learned expensively when a missing binary's error text was folded into the output variable and every block list read as healthy. And ranges an operator typed by hand are never discarded by a resolve; only resolved rows are reconciled. Notification is on change only, never on a clean resolve, or it becomes noise and gets filtered. The email names the ranges that were added or withdrawn rather than reporting a count, and two persistent dashboard alerts cover what an email cannot: one for aliases that failed to resolve, and a separate lower-priority one for aliases that have not resolved in three days, so a resolve that has been quietly failing does not first become visible at the moment the ranges finally matter. ### Changed - **The sidebar keeps the current page's section open and marks the page active** (#309). The menu tree was static markup with no awareness of the requested template, so every navigation collapsed it and the operator had to remember and re-open the section they were working in. Worst in Content Checks and System, which have the longest child lists. Reported by a user. - **The install-path parity check now guards three paths, not two.** A change has to land on a new install, an existing install, and a legacy build 240815 gateway brought across by `migrate_legacy_to_docker.sh`. Only the first two had a guard, which is how the defects behind #322 reached shipped code. A table added to the baseline but not to the migration's merge map produces a migrated gateway whose new feature has no configuration rows at all, and nobody finds out until a migration runs on a customer's box. Unlike fresh-versus-upgrade, this one is mechanically decidable, so it needs no declaration: every table with an `INSERT` in the baseline must be carried by the merge map, by the join-table handler, or by a written exemption. `parameters` is the one current exemption, and the reason is recorded where the exemption is declared. ### Fixed - **The body milter was missing from `smtpd_milters` on every fresh install of v260815**, which silently disabled Link Guard, disclaimers, organizational signatures and external banners. v260815's baseline inserted the internal-only recipients row (#311) with a column-list `INSERT` carrying no id, positioned above ids 474 to 477. The `AUTO_INCREMENT` counter was at 474, that row took it, and the next line's explicit `VALUES (474, 'inet:hermes_body_milter:8893', ...)` collided on the primary key. `INSERT IGNORE` discards a duplicate without erroring, so the import reported success and nothing appeared in any log. `parameters` has only `PRIMARY KEY (id)` and no unique key on anything else, so there was nothing to dedupe on and nothing to fail loudly. Postfix therefore never called the body milter for inbound mail, and `milter_default_action = accept` meant it never complained. The row survived under `non_smtpd_milters`, which is locally submitted mail only, so the feature looked half-present to anyone checking the wrong directive. Upgraded installs were unaffected: they receive that row from `updates/v260815/sql/schema_updates.sql` and never re-run the baseline. Three changes. The auto-id row moves below every explicit id in the baseline, so the collision cannot recur. A repair in the v260912 schema update restores the row on affected gateways, and a phase script pushes the corrected chain into `main.cf`, because the orchestrator restarts Postfix but never re-renders its configuration, so the database fix alone would have sat unused until somebody happened to save a settings page. `scripts/check_fresh_install_parity.sh` now fails the commit when an auto-id `INSERT` precedes an explicit id in the same table's seed. Run against the v260815 baseline it names line 1341 and id 474, so it would have blocked the original commit. - **Legacy-to-Docker migration no longer lands on a stale release stamp or a known postscreen defect** (#322). `migrate_legacy_to_docker.sh` brings a build 240815 backup forward by diffing structure against the shipped baseline and merging absent `parameters` rows. That is additive by design, which is right for operator settings and wrong for a release that corrected a value the legacy build already seeded: the merge sees no gap, so the defect survives the migration that every upgraded install had it removed by. Two such cases had accumulated over four releases. `system_settings.build_no` was one. The installer stamps the current release, then the legacy `system_settings` restore overwrites it with `240815` and nothing put it back. The console reported a legacy build indefinitely, backup and restore version checks read it, and `find_pending_releases()` compares `v260731` as greater than `240815`, so the operator's first update run replayed every `updates/` directory. Now stamped as step 8 of the schema bridge, derived from the newest `updates/v/` directory, the same source the installer uses, so it stays correct per release with nothing to remember. The other was the v260807 DNSBL return-code fix (#293). Legacy seeds four postscreen entries with no `=returncode` filter, so postscreen counts any answer in 127.0.0.0/8 as a hit, including the 127.255.255.0/24 codes lists use for refused and over quota. Two of the four reach `postscreen_dnsbl_threshold = 3` by themselves and reject legitimate mail. The four `UPDATE` statements now run as step 7 of the bridge, carried verbatim from that release so they stay idempotent and preserve a retuned weight. `b.barracudacentral.org` is still not removed, matching what the upgrade path does for a list an operator may have registered for, but the script now says so when the row is enabled. Step 7 is the one part of the bridge that is not self-maintaining. A future release that corrects a seeded value has to add its own statement there. - **The migration now carries every baseline seed row onto a migrated install, instead of printing a count and leaving it** (#322). Tables that exist in both build 240815 and the current baseline are replaced wholesale by the legacy dump, so every row a release added since that build is gone. Only `parameters` was merged back. The other thirty-seven printed a row-count delta and stopped, on the reasoning that their natural keys differ per table and a wrong guess duplicates operator-edited rows. Half of that was true and the conclusion did not follow. These tables do have natural keys: `files` is keyed by `file` (`exe`, `vbs`), which is even part of its primary key, `policy` by `policy_name`, `spam_settings` and `system_settings` by `parameter`. Matching on the name of the thing rather than on an id is exactly what the `parameters` merge already did. And a count delta is not something an operator can act on: it names no missing row, no affected feature and no remedy, and it scrolls past mid-run, ahead of an archive restore that can take hours. Telling someone a number they can do nothing with is not a safeguard. All thirty-seven are now merged on their natural key, additively, so an existing row is left exactly as it is and operator customisation survives. The `id` is never carried over, since baseline and legacy id sequences are unrelated and an id-matched merge would both collide and miss. `file_rule_components` and `malware_feed_urls` are join tables whose identity is a foreign id, so each is resolved through its parent's natural key (`files.file` and `malware_feeds_config.section_name`) after the parent has been merged. Column lists come from `information_schema`, so a column added by a future release is carried without touching the code. What remains is a table the baseline seeds that the key map does not name. That is now reported, and unlike a row count it has an owner and a fix: add the table and its natural key to `merge_seed_rows()`. One behaviour worth naming: a baseline default the operator deliberately deleted comes back. That is the same trade the `parameters` merge already makes, and it is the right side of it, because the console disables rows rather than deleting them, so a row absent from a legacy DB is almost always one that build 240815 never shipped. Verified against a real build 240815 backup, offline: the merge inserts 377 rows, including 16 `system_settings` keys, 29 `parameters2`, 11 `malware_databases` and all 10 `ofelia_jobs`. Rows the operator had customised are left alone, most visibly the 103 `message_rules` on that gateway against 5 in the baseline, and its 96 `files` rows against 94. - **The migration also stamps `version_no`, not just `build_no`** (#322). A legacy database carries the Ubuntu release there, `20.04` on the backup this was checked against, where the Docker baseline carries `Docker`. The seed merge cannot correct it, because the merge is additive and the row already exists, so a migrated console reported the Ubuntu version indefinitely. - **Fixed MySQL error 1093 in the migration's seed merges** (#322). Every `NOT EXISTS` guard named the insert target directly, which MySQL rejects. `updates/v260815` had already hit this and worked around it with a derived table; the same workaround is now applied to the generic merge, both join-table merges, the release stamp, and the pre-existing `parameters` merge, which carried the same shape. It also settles the semantics where the baseline legitimately holds two rows sharing a natural key, as `captcha_list`, `timezones` and `malware_databases` all do: the derived table is a snapshot taken before the insert, so both rows land, matching a fresh install. ## [v260815] — 2026-08-22 ### Added - **Distribution lists, as aliases with more than one destination** (#311). An alias can now deliver to several addresses. No new page and no new concept: a list is an alias with more than one destination, so **Email Relay → Virtual Recipients** and **Email Server → Aliases** gained the capability instead of a third page appearing beside them. Both group by address, so twenty destinations render as one row with twenty chips rather than twenty near-identical lines, each chip editable and removable on its own. Relay domains could always express this, since that table never stopped an address appearing on several rows and Postfix concatenates the rows it gets back into one recipient list; lists built by hand that way are already in the right shape and simply display grouped. - **Reachable By**, controlling who may send *to* an address, which is a different question from where it delivers (#311). Mailbox domains only: a relay domain exists so the internet can send to it, and its own users never traverse the gateway for same-domain mail because their server is authoritative and resolves it locally, so restricting a relay address to internal senders would reject the only traffic that reaches it while permitting traffic that never arrives. Enforced by a new `check_recipient_access` MySQL map, following the shape of the discard-recipients map already in that chain. `permit_mynetworks` short-circuits ahead of it, so anything reaching the map arrived from outside and a plain `REJECT` is correct; no sender-domain test is used, since trusting an unauthenticated claim to be from your own domain would be worse than useless on a gateway. This is what makes external destinations defensible: without it an alias fanning out to twenty external addresses is reachable by anyone on the internet. Registering that restriction takes a seed row, not a template edit. `main.cf.HERMES` is only the file a fresh install starts from; on a running system `generate_postfix_configuration.cfm` rebuilds `smtpd_recipient_restrictions` from the `parameters` table, so the directive is assembled purely from `child = '1'` rows ordered by `order1`. Adding the map to the template alone would have been silently undone by the first Postfix settings save. The release notes previously told operators to perform that save; the upgrade's own post-upgrade phase regenerates the directive, so it is armed without them. Caught on the test box, where the map was rendered with real credentials and the console stored the setting while `postconf -n` showed no sign of it. The row sits at `order1` 1.150, after `permit_mynetworks`, the discard map and `permit_sasl_authenticated`, so internal and authenticated senders short-circuit before it and anything reaching the map came from outside. - **Nine more quotes** in the console's rotation, bringing it to 46. Seeded in both places, so a fresh install and an upgraded one end up with the same set: `hermes_install.sql` as ids 38 to 46, and the release's schema step guarded row by row with `NOT EXISTS`, since `quotes` has no unique key on the text for `INSERT IGNORE` to work against. ### Changed - **External destinations are permitted on mailbox domains**, and flagged wherever they appear on both pages (#311). Previously every destination had to be an existing local mailbox, while relay domains allowed external freely, which was an inconsistency rather than a policy. Forwarded mail leaves with the original sender's address, so SPF fails at the receiving end, and content modification such as an External Banner invalidates the original DKIM signature, which together can mean rejection for senders publishing a strict DMARC policy. Badged rather than warned about once, because the person auditing in six months is not the person who created it. - **Send-As is granted per mailbox** rather than per alias, under **Mailboxes → Actions → Send As**. A single toggle on an alias worked while an alias had one destination and stopped making sense the moment it could have twenty, since it would have granted send-as to every member of a list at once. Membership and send-as are now independent. Existing grants are unchanged. Editing an alias's destination no longer moves the grant with it, which is deliberate: the grant means "this mailbox may send from this address" and has nothing to do with delivery. Documented alongside it: the grant alone changes nothing the user can see, because no mail protocol exposes send-as permissions to a client. The address must also be added as an identity in whatever they read mail with, which is **Mail > Account settings > Aliases > Add alias** in Nextcloud webmail and **Manage Identities** in Thunderbird. That step being undocumented made a working grant look broken, which is the first thing anyone reports. - **Add creates, Edit manages members.** On both pages, adding an address that already exists is now refused outright and points at that row's Edit button, instead of quietly merging into it. Routing both operations through Add gave two ways to do one thing, and it produced an answer nobody could act on: submitting an existing address whose destinations were all already stored could only report that nothing had changed, which reads as a failure for what was arguably a no-op. Edit already owned the whole destination set as chips with a diff save, so it was already the better route; it is now the only one. - **Virtual Recipients is one address per submission**, matching Aliases. The Add card was a newline-delimited textarea creating many addresses at once, which is what forced every outcome to be an accumulating per-row tally across four separate callouts, and what made it impossible to refuse an address that already existed rather than merely report it. One address means one verdict, so those four callouts collapse to plain error messages. Fan-out in the useful direction, one address to many destinations, is unaffected. Creating several addresses now takes several submissions; if a bulk path is wanted again it should be a real import with its own report rather than a shared text field whose failure mode is a tally. - **Virtual Recipients now matches the Aliases page surface.** Add moved from an always-visible card into a modal behind an Add button; the Delivers To field became the same chip picker used everywhere else on both pages, having been the last plain comma-separated text input among the four such fields, including its own Edit modal; the Actions column moved to the left; and the select-all checkbox column with its bulk Delete Selected button was replaced by one Delete per row. The checkboxes existed because the pre-grouping table rendered one row per destination, so an address with a large destination set filled the screen and needed sweeping. Grouping by address removed the reason, and the row still carries every underlying row id, so one Delete removes the address and all of its destinations together. ### Fixed - **Saving Send As destroyed the mailbox's ability to send as itself** (#311). Every mailbox owns a self-row in `sender_login_maps`, written when the mailbox is created, and it is what `reject_sender_login_mismatch` consults on the submission port to let a user send as their own address. The handler replaced the grant set with a delete keyed on `login_user` alone, which took the self-row with it, and nothing put it back because the picker only ever offers aliases. Opening the modal and saving, with nothing selected and nothing changed, was enough. Receiving kept working, so it surfaced as an unexplained client-side send failure with nothing pointing at the page that caused it. The delete is now scoped `sender <> login_user`, and a self-row re-assert follows it, normally a no-op, so a mailbox already damaged by this repairs itself on the next save rather than needing an operator who knows what to look for. - **Nextcloud sharing now stays inside your own domain by default** (#316). Two things at once. First, isolation: a gateway can host unrelated organisations side by side, and Nextcloud groups are named after domains, so restricting shares to members of your own groups stops a user sharing a file into another customer's organisation. Second, it closes a disclosure: Mail's recipient autocomplete suggested every group on the instance, unfiltered by the requesting user's membership, so a user at one customer could type part of another customer's domain and have it suggested by name, and the LDAP and Authelia infrastructure groups were visible to every mailbox user. The installer already set `shareapi_restrict_user_enumeration_to_group`, but that governs users; groups take a separate path, so the protection had a hole its setting name gave no hint of. `shareapi_only_share_with_group_members` was chosen over disabling group sharing outright, because the latter would also have removed sharing with a whole group *within* a domain, which is useful and unrelated to the problem. A default rather than a policy: an operator running one organisation across several domains turns it off under Administration settings, Sharing. Hermes adds no setting of its own, since Nextcloud already exposes one. - **`virtual_recipients` had no index on the column Postfix looks it up by**, so every message caused a full table scan of that table. Unnoticed at a handful of rows, and about to matter considerably more now that operators can build twenty-member lists there. - **A validator would have rejected a destination list outright.** The Virtual Recipients page checked `forwards_1` as a single email address before its handler ever ran, so a comma-separated list would have failed as "not a valid email". It now validates each entry, failing on the first bad one rather than silently dropping it and reporting partial success. Also added the missing guard for an empty destination list, which previously produced a success page that had changed nothing. - **Removing the Send-As control would have silently zeroed the column on every alias edit**, because the handler still read a form field that no longer existed and `cfparam` defaulted it to 0. Caught before release; the column is now left exactly as found. - **Changing the console address to an FQDN could lock you out of the console for a year.** `console.hsts`, `console.ssl_stapling` and `console.ssl_stapling_verify` were seeded `enable` in the baseline while the installer renders all three **commented out** in the generated Nginx config, its own comment reading "admin enables via UI". The database and the live config therefore disagreed on every fresh install, and the first Console Settings save of any kind regenerated Nginx from the database and silently switched all three on. Changing the console address to an FQDN, which is the documented next step after install, is exactly such a save. With the self-signed bootstrap certificate still bound, HSTS then pins every browser that has visited the address to refuse an untrusted certificate there for a year, with no click-through, so the operator loses the console mid-configuration and can only recover by clearing the HSTS entry in each browser individually. All three now seed `disable`, matching what the installer actually writes, so nothing turns on without the admin choosing it. Console Settings also drops the unqualified "(Recommended)" from HSTS and warns explicitly whenever the bound certificate is self-signed. Existing installs are deliberately left alone: silently disabling HSTS on a gateway that is using it correctly would be a security downgrade applied without consent, and the new warning covers the affected case the moment the page is opened. - **The console could die on a fresh install depending on which page you opened first.** Thirty three admin pages read `/opt/hermes/keys/hermes.key`, the AES-256 key used to encrypt credentials at rest, and exactly one page created it: the dashboard, which self-heals it on first visit. Reaching any other consumer first produced `source file [/opt/hermes/keys/hermes.key] is not a file` and an unusable page. Console Settings is one of the thirty three, and is a plausible first click on a new install. This is the same defect `ed9b9013` (#179) fixed in May for the dashboard alone, resurfacing elsewhere because that fix covered the reported page rather than the class. The installer now generates the key alongside every other secret, so no page depends on visit order. Byte identical to what the CFML produced, and guarded so an existing key is never overwritten, since replacing it would orphan every credential already encrypted with it. The dashboard self-heal stays as a fallback for a deleted file or a partial restore. - **Virtual Recipients claimed to bypass all content checking. It never did.** The page carried a callout stating mail through a virtual recipient was delivered "while bypassing ALL content checking (spam, virus, banned files)", and the admin documentation had a section explaining the mechanism behind it. Neither described this system. `content_filter` is set globally in `main.cf` with no per-recipient exception, and the only bypass lane, the `BYPASSALLCHECKS` policy bank on `:10030`, is reachable solely through a `FILTER` action keyed on the **sender**. The documentation additionally had the pipeline backwards, stating that Postfix rewrites the recipient before Amavis sees it: `receive_override_options = no_address_mappings` defers expansion until the `:10026` reinjection listener, so what Amavis filters is the original virtual address. And a domain-scoped policy always applies anyway, because adding a domain seeds an `@domain` row in `recipients` carrying the default policy. The wording traced back to `build-220203` and had survived every rewrite since. An operator reading it would reasonably have avoided virtual recipients for anything sensitive, or assumed a hole that was not there. - **Pickers looked like plain text inputs until you guessed to type in them** (#310). Reported from testing, with the TLS certificate field named as the example. Two families of the same complaint, fixed separately. **Destination pickers.** Clicking Delivers To opened the list on the Add Alias modal but did nothing on the other three, which are the same control. On Virtual Recipients the options are fetched remotely and the loader returned early on an empty query, so focus fetched nothing; it now fetches on focus, with the shared endpoint ordered and capped at 50 rows since an empty search would otherwise match every relay recipient. On both Edit modals the destination chips are added programmatically, which left a query behind in the control so the next focus offered a filtered list; the query is now reset after populating. The Virtual Recipients edit modal was additionally calling `clearOptions()`, discarding every option the search had already fetched. **Certificate and timezone pickers.** The Console Certificate, SMTP TLS Certificate, Dovecot Certificate and Timezone fields were plain text inputs with a jQuery UI autocomplete bound on **keydown**, so the widget did not exist until the first keystroke and that keystroke was spent constructing it rather than searching. They render as TomSelect controls now, with a chevron, and open on click. The four near-identical copies of the handler are replaced by one shared include, `inc/remote_picker_js.cfm`, driven by `data-target-*` attributes declaring which detail field each response key fills, because four copies of the same forty lines is how they drifted apart in the first place. The certificate id the server actually reads is still written by the same two-request endpoint contract, unchanged. - **Selecting a timezone threw a JavaScript exception every time.** The handler derived an index by splitting the field id on `_`, but that field is called `timezone` with no underscore, so the index was `undefined` and it wrote into `timezoneid_undefined`, an element that has never existed on that page. Saving appeared to work only because the visible field was set on the line before the throw. Found while converting the picker above; the element it wanted is gone entirely, since `edit_system_settings.cfm` validates the timezone by name. - **Deleting a certificate always failed, after deleting it.** `delete_system_certificate.cfm` ran two statements: it removed the row from `system_certificates`, then removed the matching SAN rows from `mailbox_domains_sans`. No such table has ever existed in this schema, in the baseline or in any release's schema updates, so the second statement threw every time. Because it threw only after the first had committed, the certificate really was deleted, its acme files really were removed, and the admin was shown a Lucee stack trace instead of a success message, with any SAN rows for it orphaned. The real table is `mailbox_sans` and its foreign key is `certificate`, which is what the Ofelia-scheduled `acme_validate_ip.cfm` has always used, so the table and the column were both wrong. The two statements also now run child before parent, so a future failure leaves the certificate intact and the operation retryable rather than half applied. Because every certificate deleted on every install to date stranded its SAN rows this way, the schema update for this release also removes SAN rows whose certificate no longer exists; rows not yet attached to a certificate are a legitimate state and are left alone. - **A missing per-domain certificate map stopped inbound mail.** `tls_server_sni_maps` was enabled from a row count taken before the map was built, while the map itself was only written for certificates whose files were actually on disk and deleted otherwise. When the two disagreed Postfix was pointed at a map that did not exist, every TLS handshake offering SNI failed with `SSL_accept error`, and inbound delivery stopped. It failed closed and announced itself only as a log warning. The directive is now decided by whether `sni_maps.db` exists, which is the file Postfix actually opens, so the two cannot diverge; being wrong now leaves SNI disabled and mail flowing on the default certificate instead of refusing mail. A map left on disk from an earlier run is deleted before that test rather than after, so the last validated SAN going away cannot leave a leftover file re-enabling the directive for names nothing validates. The reason a certificate was skipped is logged rather than discarded, which it had been. Three situations produced the mismatch, all observed: a deleted certificate, one still Pending because it was requested but never issued, and one imported without its chain so no bundle existed. The first is the certificate-delete bug fixed above, and it is worse than it looks, because `system_certificates` is InnoDB and its auto-increment counter is recalculated as `MAX(id)+1` after a restart, so re-issuing can hand the replacement the same id the deleted one had. The stale rows then point at a valid id again, which is why the SQL cleanup cannot reach them: they are stale by history, not by foreign key. A phase script reconciles them against the filesystem instead, clearing the validated flag rather than deleting the rows so removing a dangling directive from the live Postfix config with a single `postconf -X` rather than regenerating `main.cf` unattended. The stale rows are deleted rather than merely marked unvalidated, because `mailbox_sans` is derived state: `sync_mailbox_sans.cfm` rebuilds it from `additional_sans`, which is where the operator's intent actually lives, so a row whose certificate is gone has nothing left to say. - **A SAN could be marked validated against a certificate that did not contain it**, which then blocked its certificate from ever being requested. `acme_validate_ip.cfm` hashes the SAN *names* and stores that hash per certificate; on a later run, a matching hash was taken as proof the certificate already covered them. It proves only that the requested set has not changed, and says nothing about the certificate's contents. A mailbox domain whose `mailbox_certificate` was the bootstrap certificate, whose SANs are `localhost` and `hermes-bootstrap.local`, ended up with two unrelated names marked "verified against existing certificate" against it. Coverage is now read from the certificate's actual SAN list, accepting a wildcard one label up, and the check fails open so an unreadable certificate changes nothing. Compounding it, the hash was written **before** the certificate request was attempted, so a request that failed still left the hash on the record and every later run read it back as proof the work was done. One failed attempt made the failure permanent: the certificate stayed Pending, its SANs stayed marked validated, and nothing ever retried. The hash is now written only on success. The upgrade also clears the stored hash for any certificate found not to cover its SANs, without which the cleanup would be undone by the next scheduled run. - **Dovecot served an imported certificate without its chain**, so IMAP and POP clients could not verify it while the console looked perfectly fine. Importing a third-party certificate writes four files: the leaf as `_hermes.pem`, the CA chain as `_hermes.chain.pem`, the two concatenated as `_hermes.bundle.pem`, and the key. Nginx has always read the bundle; Dovecot read the bare leaf. A client then had no path from the leaf to a trust anchor and refused the connection, which browsers hide by fetching the missing intermediate over AIA and mail clients generally do not. Nothing was wrong with the import: it verifies the leaf against the supplied chain before accepting it, so a bad chain would have been rejected outright. Dovecot now reads the bundle, falling back to the leaf only when no bundle exists. Certificates issued through ACME were never affected, since `fullchain.pem` already contains the intermediates. - **SMTP TLS served the leaf without its chain**, on 25, 465 and 587. The same defect as the Dovecot one above and found the same way: `openssl s_client -showcerts` against 465 returned exactly one certificate. `smtpd_tls_cert_file` was pointed at the bare leaf, `_hermes.pem` for an imported certificate and `cert.pem` for an ACME one, with the intermediates passed separately as `smtpd_tls_CAfile`. That setting exists to verify remote **client** certificates and is not a dependable route for the server's own chain; Postfix sends what is in `smtpd_tls_cert_file`. Any peer without the intermediate already cached could not build a path to a trust anchor, which for a sending MTA can mean refusing TLS or falling back to plaintext. Both branches now point at the chain-bearing file, `_hermes.bundle.pem` and `fullchain.pem`, matching Nginx and Dovecot. `smtpd_tls_CAfile` is unchanged and still correct for its actual purpose. The three services differ in a way that decides who needs repairing. Nginx and Dovecot recompute their certificate path on every config generation, so both correct themselves as the upgrade runs. Postfix stores its path in the `parameters` table and `generate_postfix_configuration.cfm` only emits what is stored, so the stale leaf path would have survived every regeneration until somebody happened to re-save that page. A phase script rewrites it instead, and only where the chain file is genuinely present: a leaf without its chain is degraded, whereas naming a file that does not exist stops Postfix serving TLS altogether, and the repair must not turn one into the other. - **Binding SMTP TLS to a certificate that was never issued stopped mail being accepted.** The selection checked that the certificate's database row existed, not that its files did, and a record stuck in Pending appears in the picker like any other. Nginx and Dovecot fall back to the bootstrap certificate when their files are missing; Postfix has no fallback for `smtpd_tls_cert_file` and simply fails TLS, which on port 25 means inbound delivery stops. The save is now refused with the missing path named, before anything is written, so the previously bound certificate stays in use. Failing by declining to save is recoverable; failing by pointing Postfix at a file that is not there is silent until mail stops. The upgrade also reports, without changing, any of the console, SMTP or mail certificate bindings already pointing at absent files: which certificate to use is an operator decision, not something to reassign automatically. - **Mailbox certificates could never be issued on a fresh install.** The automated SAN request omitted certbot's `--agree-tos`, `--non-interactive` and contact-address flags, which the manual single-domain path has always passed. certbot registers an ACME account on its first run against an endpoint; without those flags it stopped to ask whether you accept the Terms of Service, found no terminal to ask through, and exited on `EOFError` before attempting a challenge. The automated path could therefore only ever succeed on a gateway where someone had already requested a certificate by hand. Adding a mailbox domain is normally the first ACME action a new install performs, so on a fresh install it failed every time. `admin_email` ships as a placeholder and only the console ever writes it, so the address is passed only when it is a real one; otherwise the account is registered without a contact address rather than bound to one that does not exist, since an ACME account's address is awkward to correct afterwards. - **Every ACME failure was invisible, and one unverifiable name blocked issuance for the whole gateway.** Two faults compounding. certbot writes its failures to stderr, and the request ran the container directly with nowhere for stderr to go, so any failure surfaced as a Lucee exception rather than as output: the handler mailed the administrator and aborted, leaving nothing on the record and nothing on the page. A certbot success was the only outcome the code could observe. Separately, all three failure paths in the SAN verification loop aborted the entire request instead of the single row, and the issuance loop runs after that loop, so one name that could not be verified stopped certificate issuance for every certificate on the gateway, silently, because the scheduler discards output. Failures now run through a temporary script with stderr folded in, are recorded against the SAN row, and appear on the Certificates page; a verification failure skips its row and lets the run continue, without clearing a validation that previously succeeded. Recording them also required binding the message rather than interpolating it, since certbot output routinely contains an apostrophe and the column holds 255 characters. ### Removed - **Two unreachable duplicate pages**, `admin/2/add_virtual_recipients.cfm` and `admin/2/edit_virtual_recipient.cfm`. Both were standalone full-page copies of flows that live as cards and modals on `view_virtual_recipients.cfm`, and nothing in the repository linked to either. Both had also drifted: the add page still validated its destination field as a single email address, the bug fixed on the live page earlier in this release, so multi-destination had never worked there, and the edit page still used the superseded one-row-per-destination model. Dead pages that duplicate a live flow are worse than absent ones, because they get found later and mistaken for the real thing. - **Two dead scheduler files**, `schedule/test.cfm` and `schedule/acme_validate_ip_copy.cfm`. Neither was referenced anywhere or run by Ofelia, which schedules `acme_validate_ip.cfm`, and both queried the same non-existent `mailbox_domains_sans` table as the certificate-delete bug above, so either would have thrown the moment anything reached it. ## [v260814] — 2026-08-14 ### Fixed - **Nextcloud got one install attempt and could never recover from failing it** (#313). The `nextcloud:apache` entrypoint runs `occ maintenance:install` exactly once, on first boot. If MariaDB had not finished creating the Nextcloud database user by then, that attempt failed and left a partial `config.php` carrying `dbname` and `dbhost` but no `dbuser`, no `dbpassword` and no `installed` key. Every later boot read that file, concluded Nextcloud was already configured, and never retried, so the container sat up and healthy and uninstalled indefinitely. Not a timeout: the installer's advice to re-run `--init-db` could not work, because the re-run reached the same already-configured short circuit, and widening the two-minute poll would not help because nothing was still running to wait for. The installer now drives the install itself when it finds `installed: false`, preserving the partial `config.php` under a date-stamped name, confirming the database user can open the database, then running `maintenance:install` with the credentials it already generated and applying `NEXTCLOUD_TRUSTED_DOMAINS` afterwards, which the entrypoint would otherwise have done and whose absence is the #292 untrusted-domain defect. The poll is kept as the fast path, so a healthy install is unchanged. Present since the Docker edition's root commit. - **A wrong host clock took DNS down and the installer blamed the forwarders** (#314). Unbound validates DNSSEC locally, against the host clock, on every answer it receives; forwarding does not delegate that to the upstream resolver. With the clock skewed, every signature reads as invalid, and because the failing signature is on the root zone, Unbound cannot establish trust for anything beneath it, so all name resolution stops rather than only signed zones. Unbound then caches the invalid key, so correcting the clock alone does not restore service, and NTP hostnames no longer resolve so the clock cannot fix itself. The DNS preflight reported this as a forwarder problem, which is the one thing it is not. It now inspects Unbound's log for `signature before inception`, `signature expired` and `key for validation ... marked as invalid`, and separately checks whether the host clock is behind the commit being installed, which proves the clock is wrong without needing any network. Diagnosis only: no new check gates a healthy install and nothing new runs unless DNS has already failed. Present since the Docker edition's root commit. - **A failed disk probe could take down the whole admin console.** The dashboard's five storage rings each ran a probe inside a `cftry` whose `cfcatch` rendered an error page and aborted, so a missing probe script or an unmounted path killed `index.cfm` outright instead of losing one ring. Reachable on any gateway installed before the archive tier was added in #260, since those have no `disk_space_usage_archive.sh`. The catch now leaves the ring's default in place and returns. Completes the fix begun in v260807, where the probes gained defaults so an empty result could not leave the variable undefined. - **The release workflow could not run its own drift check.** `scripts/check_ofelia_seed_drift.sh` was committed non-executable, so the release-images workflow failed at "Verify generated artifacts" with permission denied and skipped the retag job. Same class as the pre-push hook in #296: a script that only ever runs from a fresh checkout, where the git file mode is the only mode that counts. No effect on v260807, whose images were promoted by hand from the tested artifacts. ## [v260807] — 2026-08-13 ### Fixed - **OpenDMARC rejected a domain's own authenticated users** (#300). `submission` and `smtps` did not override `smtpd_milters`, so they inherited the global chain including OpenDMARC, which then evaluated authenticated outbound mail as though it were inbound. That fails by construction: the client address is never in the sending domain's SPF record, and the OpenDKIM instance on that path signs rather than verifies, so there is no DKIM result to consume either. With the shipped `RejectFailures true`, a domain publishing `p=reject` had its own users rejected at DATA with `550 5.7.1 rejected by DMARC policy`. Both listeners now carry an explicit chain that keeps OpenDKIM and body_milter and drops OpenDMARC, using the same per-service override pattern `:10026` and `:10027` already use. Port 25 is unchanged, which is where DMARC belongs. Invisible before this release only because submission itself was never bound (#292). - **The URLhaus malware feed silently stopped updating** (#302). `malware_feeds_config` seeded `urlhaus` with `max_size = 2MB` and the feed has grown past 3MB, so fangfrisch refused the download on every run while still exiting 0. Ofelia recorded the job as successful and nothing surfaced, so the ClamAV third-party URLhaus signatures were never refreshed. Raised to 10MB in the baseline, the shipped configuration and an idempotent upgrade statement guarded on the old value, so a tuned setting is preserved. - **Ofelia could never deliver its failure notifications** (#303). `hermes_ofelia` was the only service with no `networks:` block, so Compose attached it to an auto-created default bridge outside the Docker subnet. Its `[global] smtp-host` could not resolve `hermes_postfix_dkim`, `:10026` would have rejected it on `mynetworks` regardless, and its configured resolver was unreachable. Jobs ran correctly because they dispatch over the docker socket, so the only broken path was alerting, which `mail-only-on-error` means is exercised only when something has already gone wrong. Given a static address on `hermes_net_ext`. - **Adding a Local DNS Record took DNS down for the whole gateway** (#304). `generate_unbound_local_conf.cfm` emitted bare `local-zone:` and `local-data:` lines, but `unbound.conf` includes `forward.conf` first, and when forwarding is enabled (the default install mode) its `forward-zone:` clause closes the `server:` clause. The generated entries then landed inside `forward-zone:`, where neither is valid, and unbound refused to start and crash-looped. Every container resolves through it, and the console needs DNS, so the admin UI could not undo it. The file now declares its own `server:` clause. Two further defects on the same page: the DNS lookup tool rejected underscore labels, excluding `_dmarc`, `_domainkey` and the `_submission._tcp` SRV records this product instructs admins to publish; and the generator could not express any TXT value containing a space or semicolon, so SPF, DKIM and DMARC records were silently truncated at the first semicolon. Hostname and value are now stripped of CR/LF before rendering, since both are admin-supplied and land in a config file. - **CipherMail and OpenLDAP ran on UTC and stamped it into mail** (#305). Both images are built `FROM ubuntu:24.04` with `--no-install-recommends`, which never pulls `tzdata`. Without a zone database, glibc cannot resolve `TZ=America/New_York` as a zone path, falls back to parsing it as a POSIX string, takes `America` as the abbreviation and defaults the offset to zero. The result was UTC labelled `America`, four hours from every other container, written into the `Received:` header of every message CipherMail handled and into the slapd events that reach the Syslog database. `tzdata` is now installed explicitly in both, and `hermes_ldap`, which had no `TZ` at all, now receives one. - **Amavis trusted a private range that was not the Docker subnet** (#297). The shipped `mynetworks` file was a stale snapshot carrying a development LAN, so a fresh install granted originating treatment to an unrelated private range until an administrator saved a Postfix settings page and the file was re-rendered from the database. It now matches the seeded rows. - **Four administrative pages rendered a raw error instead of the error page.** `create_new.cfm`, `deletedomain.cfm`, `edit_smtp_tls_settings.cfm` and `send_smime_certificate.cfm` included `./inc/error.cfm` from inside `inc/`, resolving to `inc/inc/error.cfm`, which does not exist. - **`system_rehost.sh` pointed the rehosted console at an IP instead of its hostname** (#295). `CONSOLE_HOST` defaulted to the new IP when `--to-console` was not given, even though the script had already detected the FQDN and was using it as `trusted_domains[0]`. That value is written to `parameters2.console.host`, which is the console's identity: every generator reads it for the Nginx `server_name`, the `auth.conf` portal URL, Authelia's session cookie domain, Nextcloud's trusted domains, and the hostname autoconfig/autodiscover hand to mail clients as their IMAP and SMTP server. So a rehost broke Nextcloud OIDC (discovery fetched from the IP while Authelia's issuer is the FQDN, against a certificate covering neither), handed mail clients an IP no certificate covers, and left changing the console host as the operator's first task after a rehost. It now adopts the detected FQDN **only when DNS confirms that name points at the new host**, and otherwise falls back to the IP exactly as before, saying why. DNS decides rather than a warning, because `migrate_legacy_to_docker.sh` and `system_restore.sh` both invoke the script with `--force` and without `--to-console`, the migration script never sets `HERMES_HOSTNAME`, and `.env.template` ships a placeholder: trusting the hostname blindly there would have moved the console from "reachable at the IP" to unreachable. An explicit `--to-console` is always honoured and only warned about. - **Changing the console host locked the administrator out of the console** (#294). This affected every new install, because `install_hermes_docker.sh` sets `console.host` to the host IP on purpose (no DNS yet) and expects the administrator to change it afterwards, so the broken operation was step one of every deployment. Saving a new console address restarted Authelia, whose session cookie is scoped to a single domain, so it immediately had no session configuration for the address the browser was still on. The save then handed the browser a redirect to `preload_restart_nginx.cfm` to perform the Nginx restart, but that page and the `inc/restart_nginx_post.cfm` its JavaScript calls both live under `/admin/2/` and are auth-protected. Neither could load, so the one thing that triggers the Nginx restart sat behind the auth flow the hostname change had just invalidated. Nginx kept serving the previous portal URL indefinitely, the browser was redirected to the old address with "unable to determine user state", and the only way back in was restarting `hermes_nginx` by hand from the Docker host. A host change now fires the restart from the save request itself, the last one that is still authenticated, and renders a page that waits out the restart before moving the operator to the new address, so their next sign-in happens after Nginx is back rather than during the restart. Certificate, HSTS, OCSP and DH-parameter changes leave the address and the session intact and keep the existing restart path. - **DNSBL lookups could be silently dead, and the diagnostics said they were healthy** (#293). Found while verifying #292 on a test gateway whose Unbound forwards to a LAN router: ordinary DNS resolved normally while `zen.spamhaus.org` returned nothing at all for a test point that is guaranteed listed. Reputation scoring, both postscreen weights and the SpamAssassin `RCVD_IN_*` rules, contributes nothing in that state, and allowlists such as `list.dnswl.org` stop applying too. - **Four block lists counted refusals as listings.** `bl.spamcop.net`, `bl.suomispam.net`, `bl.spameatingmonkey.net` and `backscatter.spameatingmonkey.net` shipped with no `=returncode` filter, so postscreen counted any answer in `127.0.0.0/8` as a listing, including the `127.255.255.0/24` codes lists use for "refused" and "over quota". Each carries a weight of 2 against a threshold of 3, so two of them answering with error codes reject legitimate mail. All four now filter on `127.0.0.[2..11]`, and existing installs are corrected with their weights preserved. - **The RBL Test button never made a DNS query at all, and reported every list as healthy.** It shelled out to `docker exec hermes_postfix_dkim dig`, but the postfix-dkim image has never contained `dnsutils`, so every probe failed with an OCI "executable file not found" error. Neither probe captured stderr, so Lucee folded docker's error message into the output variable, and the SOA fallback only tested that the output was non-empty. An error message is not empty, so it read as a successful SOA lookup and every entry displayed green "Zone active (SOA)". The probe now runs `dig` locally in `hermes_commandbox`, which has `dnsutils` and is pointed at `hermes_unbound` by compose, so it goes through the same resolver postscreen uses. Every `cfexecute` captures stderr separately, no probe treats stderr as data, the SOA response is validated as an actual SOA record by its five timers rather than by being non-empty, and a missing `dig` is reported as its own verdict instead of being laundered into a DNS result. The same flaw was present in the new install-time DNSBL preflight and is fixed there too. - **System > RBL Configuration had no Apply button.** `inc/rbl_apply_settings.cfm` had existed since the page was written but nothing ever invoked it, so the only way to push a block list change into `main.cf` was to edit an entry and save it without changing anything. It is now wired to an Apply button, which confirms first because it regenerates the Postfix configuration from the database and reloads Postfix. The success message it sets also had no handler on the page, so even a successful apply would have reported nothing. - **The RBL Test button also mis-read the answers it did get.** It accepted any answer beginning `12` as success, so `127.255.255.254` displayed as live data, and its SOA fallback reported green for a zone that returned no data at all. It now runs a two-point probe and reports data returned, zone present but silent, or refused and wildcarded, which a single probe cannot distinguish from a healthy list. The wildcard test compares the two points as **sets** and requires them to be identical, rather than treating any answer at `1.0.0.127.` as a wildcard: reputation services return a verdict for every query, not only for listed senders, so `hostkarma.junkemailfilter.com` answers both points with different code sets and an "any answer" test marked a healthy service as broken. What makes a wildcarded or hijacked zone dangerous is that it returns the **same** answer to everything, so every connecting IP scores identically; a zone that discriminates is doing its job. A zone that answers the never-listed point while the always-listed point stays silent is reported as inverted rather than as either healthy or wildcarded. - **The Postfix template and the database shipped different block lists.** A fresh install filtered on the 27 entries hardcoded in `main.cf.HERMES`, including six retired `dnsbl.sorbs.net` entries at weights up to 8, while the admin console showed the 17 seeded in the database. The template only converged on the database when someone saved a settings page. Both lists now match exactly. This is the same root pattern as #292: generated config written before or independently of the database, with the file winning until an admin happens to save. - **`b.barracudacentral.org` is no longer seeded.** It answers only once the querying IP is registered with Barracuda, so on a stock gateway it returned nothing while carrying a weight of 7, above the rejection threshold of 3 on its own. Existing entries are left in place, since an operator may have registered. - **The installer now tests block list reachability.** Forward mode remains the install default for bootstrap reliability, and switching to recursive remains the operator's call, but the cost of forward mode was invisible: ordinary DNS resolving normally says nothing about whether reputation answers survive the trip. Routers commonly strip `127.0.0.0/8` replies as DNS rebinding protection, returning NOERROR with an empty ANSWER and empty AUTHORITY, and public resolvers are refused by the lists outright. The new preflight distinguishes reachable, refused, stripped, synthesised, and off-zone answers, and reports the remedy. It warns rather than aborting, since mail still flows in that state, it just filters badly. The DNS forwarder prompt now states the consequence instead of framing recursive mode as a privacy preference. - **Razor could not work even once registration landed in the right place** (#292 follow-up). `-home=/etc/razor` corrected where `razor-admin` writes, but `docker exec` runs as root, so the identity and server discovery files were created root-owned while SpamAssassin runs as `amavis`. Razor writes its home directory at scan time, refreshing `servers.*.lst` and the per-server configs, so root ownership left it mute. The ownership pass in the installer, the post-upgrade repair, and the Initialize Razor handler all now include `/etc/razor`, and registering removes the stale `/root/.razor` identity that earlier attempts left behind. - **First-run provisioning defects on fresh installs** (#292). Reported by an outside user on a clean install. Each has been present since the Docker edition shipped and is invisible on any gateway where an administrator saved the relevant settings page, because the application layer silently corrects the installer's output. - **Every message received a 5 point spam-score discount.** SpamAssassin's `RCVD_IN_VALIDITY_CERTIFIED` and `RCVD_IN_VALIDITY_SAFE` rules are allowlists carrying `-3` and `-2`. Validity refuses queries from unregistered resolvers and answers `127.255.255.255`, which SpamAssassin matches as a hit, so both fired on every message. Hermes resolves through its own recursive Unbound instance, so the query always originates from an unregistered address and no stock install escaped it. Configured thresholds therefore behaved five points higher than they read. All three Validity rules now score `0`; operators registered with Validity can restore them through Score Overrides. - **SMTP submission was never enabled.** `master.cf` shipped with the `submission` (587) and `smtps` (465) listeners commented out in every variant, while Docker published both ports and the mailbox domain page advertised them over SRV. On a mailbox or hybrid install no user could send mail from any client. Receiving and webmail were unaffected, because Nextcloud Mail reaches Postfix on port 25 over the Docker network, which is why the gap went unnoticed. Both listeners are now enabled with Dovecot SASL and `reject_sender_login_mismatch`. - The update orchestrator restarted only `hermes_commandbox`, so a release that changed `master.cf` or `main.cf` without also rebuilding images would not have applied it: Postfix reads both at startup, and Compose recreates a container when its definition changes, not when a bind-mounted file's contents change. Phase 4 now restarts `hermes_postfix_dkim` as well. - Amavis quarantine subdirectories (`clean`, `virus`, `spam`, `banned`, `bad_header`) were never created, so Amavis could reject mail outright. The pre-Docker installer created all five and the Docker rewrite dropped the step, along with the ownership pass that let amavis write the quarantine tier and the Bayes corpus. - The installer enabled mailbox encryption against empty placeholder keys regardless of the database setting, which defaults to off, breaking IMAP and SMTP authentication. - SpamAssassin's `local.cf` was copied rather than rendered, so nine placeholders reached SpamAssassin verbatim. It discarded them and fell back to built-in defaults, which enable the collaborative network checks and automatic Bayes learning. - `NEXTCLOUD_TRUSTED_DOMAINS` was written comma-separated where a space-separated list is expected, so Nextcloud rejected the console address the installer configures before DNS exists. - Enabling Nextcloud on an existing mailbox never provisioned the Nextcloud Mail profile, and the failure was silently discarded. It was built from the account's login password, which Dovecot cannot accept: IMAP and SMTP authenticate only against app passwords. ### Changed - **`system_update_docker.sh --remote` now selects code, images and tag from one source** (#298). Previously it chose only where the code came from, while images resolved from `IMAGE_REGISTRY` and `HERMES_DOCKER_IMG_VERSION` in `.env`, which the orchestrator never read or wrote. The upgrade path could therefore never advance the image version, so image-level fixes could not reach an existing install, and testing a release candidate meant hand-editing `.env` first. The remote now maps to a registry and the resolved tag is written before `docker compose pull`, guarded by a `docker manifest inspect` probe so the per-release tag is pinned only when the registry actually has it. An upgrade that works today cannot start failing, and the production path begins pinning automatically once per-release tags exist (#289). `--image-registry=` and `--image-tag=` override the mapping; `.env` is backed up before either key changes, and `--skip-compose` leaves it untouched. - **Collaborative spam checks now ship disabled** (#292). Razor, Pyzor and DCC each transmit a digest of every scanned message to a third-party network, so new installs leave that decision to the operator. Existing installs keep their current settings. Razor had never been registered on any install and so returned no result regardless. - **Bayes ships untrained, and automatic learning is off by default** (#292). Hermes had been shipping a pre-trained corpus built from unrelated mail; upgrading clears it once so each gateway learns from its own traffic. Auto-learning trains on the rule set's own verdicts, reinforcing its mistakes as readily as its successes. - **DCC is no longer in the published mail filter image** (#292). Its licence is free only to organisations that do not sell filtering devices or services except to their own users, and does not permit redistributing binaries. Most self-hosted operators qualify; Deeztek does not. `docker-compose.yml` carries a commented build block for operators who want it, fetching DCC from Rhyolite directly. ### Documentation - **The console FQDN and a real certificate are now documented as a hard prerequisite** (#299). Nextcloud login cannot work on a fresh install, because OIDC requires the Nextcloud container to make a server-side HTTPS call back to the console address, and the bootstrap certificate is self-signed with the common name `localhost`. Importing it into the container's trust store does not help, since trust and name are separate checks. Get Started gains a Step 2 covering both parts, and the mail-client half: **the Console Certificate and the SMTP TLS certificate are separate bindings**, and setting only the first leaves the console perfect in a browser while every mail client is offered `CN=localhost`, because `autoconfig` hands clients the console host as their SMTP server. The `Self-signed cert` dashboard nudge previously described this as producing only a client TLS warning, which understated it. - **The CipherMail console's stock `admin` / `admin` credentials are now stated explicitly** (#306), rather than referred to obliquely as "its default administrator password". - **`master.cf` has exactly one copy in the repository.** A duplicate under `conf_files/` drifted for three months onto the pre-#232 configuration that caused the `:10026` outage, because commit `9e90bc9a` fixed the real file and not the copy, and nothing read the copy so nothing detected it. Both it and an equally stale `master.cf.postscreen` are removed, and `docs/general/email-flow.md` records why a second copy must not be reintroduced. ## [v260628] — 2026-06-28 ### Changed - **Let's Encrypt / ACME certificate management is now available in all editions** (#282). The console-certificate **Request ACME Certificate** button and the mailbox-domain **Auto-managed (Let's Encrypt)** SAN certificate mode — automated issuance, SAN validation, and auto-renewal — are no longer restricted to Pro Edition. Existing Pro installations are unaffected. ## [v260612] — 2026-06-20 **Initial public Docker release.** The first tagged public release of Hermes SEG as a Docker product, replacing the legacy bare-metal Ubuntu installer with a 19-container Docker Compose stack. Early-adopter release — feature-complete and validated on our DEV/Test infrastructure; run your own acceptance tests in parallel before cutting a production gateway over. **Fresh-install only** (legacy-to-Docker migration tooling is still skeletal). ### Added - Full Docker stack: 19 containers via a single `docker compose up -d`; no host-level mail services. - **Link Guard (Pro Edition)** — time-of-click "safe links": inbound links are rewritten through a Hermes redirect and reputation-checked **at the moment the user clicks**. Dedicated `hermes_linkguard` container; layered verdicts (heuristics + URLhaus / OpenPhish feeds + optional Google Safe Browsing / VirusTotal), open-redirect detection, admin-configurable per-tier actions, and outbound link restoration. - Five-tier storage topology (Config / Data / Archive / Vmail / Nextcloud), each independently mountable so each tier can live on the right kind of disk. - Single-command update orchestrator (`scripts/system_update_docker.sh`): a 5-phase pipeline that auto-resolves the latest tag via the GitHub Releases API and runs `occ upgrade` on Nextcloud version bumps. - Authelia SSO with unified MFA (TOTP / WebAuthn / Duo Push) across the admin console, user portal, and Nextcloud (via OIDC). - Nextcloud integrated: webmail, file sync, calendars (CalDAV), and contacts (CardDAV) — pre-provisioned on first OIDC login. - Docker-aware backup, cross-host disaster recovery, and re-host tooling (hot backups, storage-topology remap, credential reconciliation). - GitHub-based release pipeline: container images published to `ghcr.io/deeztek/hermes-:`. Full release notes: [`updates/v260612/README.md`](updates/v260612/README.md). ## [v260609] — 2026-06-11 Backup, disaster-recovery, and upgrade-tooling release on top of the v260119 baseline. ### Added - Docker-aware backup/restore (`system_backup.sh`, `system_restore.sh`) with hot (zero-downtime) backups, scoped storage tiers, and a directory-style format (#219). - Cross-host disaster recovery plus `system_rehost.sh`, with storage-topology auto-remap and a version-match gate (#220). - Update orchestrator: pre-container `pre-scripts/` hook + self-re-exec (#221). - Authelia DB credentials relocated `keys/ → creds/` (migrated automatically on upgrade). ### Fixed - #266 — repo `.sh` scripts shipped non-executable, breaking CFML panels that shell out (e.g. the dashboard disk-usage panel); now `chmod +x` at build time and re-applied on install/restore. - #267 — `hermes_smoke_test.sh` hardcoded `build_no=v260119`, so the post-install smoke test falsely failed on every later release; now version-agnostic. > **Upgrade note:** the v260119 → v260609 hop requires a one-time bridge > (`updates/v260609/upgrade-to-v260609.sh`), not the normal updater. See `RELEASE-NOTES.md`. ## [v260119] — 2026-05-30 The first Docker-era release (labeled for January 2026, actually tagged 2026-05-30 after ~6 months of development). Established the Dockerized rewrite as a coherent shipping product and completed the `docs/admin/` operator-documentation buildout. ## Legacy releases (pre-Docker) Before the Docker era, Hermes shipped as a bare-metal Ubuntu install under the `build-YYMMDD` tag line: `build-240815` (Aug 2024) back through `build-211207` and `build-211019` (2021). This repository was created 2017-12-21 and the project has been public on GitHub since. Those releases predate the Dockerized rewrite and the move to GitHub Releases, and are not itemized here. The full pre-Docker history, **186 commits spanning 2017 to 2025**, is preserved on the [`legacy`](https://github.com/deeztek/Hermes-Secure-Email-Gateway/tree/legacy) branch, along with its seven `build-*` releases. `legacy` is a **separate codebase** from `main`, not an earlier stage of it. The Docker Edition is a ground-up rewrite of the bare-metal monolith rather than a continuation, which is why `main` carries none of those commits. See [Project history](README.md#project-history) in the README. [v260612]: https://github.com/deeztek/Hermes-Secure-Email-Gateway/releases/tag/v260612