CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# 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<YYMMDD>/` 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-<service>:<tag>`.
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