hermEX is a modular, native Microsoft Exchange Server replacement written entirely in Go. It speaks the Exchange client protocols on the wire (IMAP, POP3, SMTP, CalDAV, CardDAV, ActiveSync, EWS, MAPI/HTTP with RPC/HTTP and ROP, and NSPI, the global address list), so existing Outlook, mobile, and standards-based clients connect unmodified. The server daemons, the webmail UI, the admin tooling, and every sync interface are pure Go.
Why not?
Active development. Not yet production-hardened.
The project draws one hard line:
- External client protocols are wire-compatible with Microsoft Exchange. What an Outlook or mobile client sees on the wire matches Exchange behavior: IMAP, POP3, SMTP, CalDAV, CardDAV, ActiveSync (WBXML), EWS (SOAP), MAPI/HTTP + RPC/HTTP + ROP, and NSPI.
- Everything internal is original. The IPC, the daemon boundaries, and the config surface are the project's own design.
The one deliberate exception is the physical mailbox store
(internal/objectstore): it mirrors a proven per-mailbox SQLite schema (generic
property tables + content-addressed CID files). Each mailbox is a directory
holding objects.sqlite3 + imapindex.sqlite3 + cid/ + eml/.
A single TLS front door (cmd/gateway) reverse-proxies every HTTP-based
protocol by longest-prefix match (/autodiscover/, /ews/,
/microsoft-server-activesync, /mapi/, /rpc/, /rpcwithcert/, /dav/,
/.well-known/{cal,card}dav, /tlsrpt to the MTA) and serves the webmail SPA at the catch-all /, so
the whole stack is reachable behind one FQDN. It terminates TLS from a per-SNI
certificate store with optional ACME issuance (internal/tlscert).
| Layer | Packages |
|---|---|
| MAPI core | internal/mapi (property model), internal/ext (MS-wire serialization), internal/ndr (RPC NDR), internal/lzxpress (ROP buffer compression) |
| Mailbox store | internal/objectstore (sole store), internal/ics (IDSET/GLOBSET sync codec), internal/publicfolder (public store) |
| Format conversion | internal/oxcmail (MIME to MAPI and back), internal/oxcical (iCalendar), internal/oxvcard (vCard), internal/oxtask (tasks), internal/oxcfg (master category list), internal/recurrence, internal/conversation, internal/mime, internal/smime, internal/tnef (winmail.dat decoding) |
| Protocol servers | internal/{smtp,imap,pop3,dav,activesync,ews,mapihttp,nspi,rop}, transport codecs internal/{rpchttp,wbxml,oxews,oxmapihttp}, internal/easpolicy, internal/netline (bounded pre-authentication line reader) |
| Mail flow | internal/mta (delivery), internal/relay + internal/spooler (outbound), internal/meeting, internal/itip (iMIP scheduling mail), internal/fetchmail, internal/mailreport (inbound DMARC and TLS report parser) |
| Filtering | internal/antispam (SPF/DKIM/DMARC + Bayes + rules), internal/antivirus (clamd), internal/quarantine |
| Security & TLS | internal/dkimsign, internal/mtasts, internal/dane, internal/tlsrpt, internal/tlscert (per-SNI cert store + ACME), internal/ssrfguard |
| Abuse control | internal/authlimit (failed-login lockout), internal/httplimit (per-client request cap), internal/connlimit (total and per-client connection cap); all DB-backed and tunable without a restart |
| Directory & auth | internal/directory (MariaDB-backed), internal/crypt (password hashing), internal/totp (second factor), internal/sendas (which From a caller may use), internal/ldapauth + internal/ldapsync (AD/LDAP sync) |
| Notifications | internal/notify (publisher/consumer) + internal/notifyd (SSE relay), a wake bus for IDLE/Ping/streaming across daemons |
| Platform | internal/config, internal/serve (HTTP daemon base), internal/lifecycle (graceful shutdown), internal/logging (Mongo sink), internal/health, internal/migrate (schema runner), internal/buildinfo (source stamp), test support internal/tlstest + internal/avtest |
| Web & admin | internal/webmail2 (React SPA) + internal/webmail2api, internal/admin (operator panel), internal/gateway (single-FQDN front door) |
Development is Docker-based and driven entirely through the Makefile, which
wraps docker compose and runs the toolchain in the dev container (the host Go
toolchain has no MariaDB, so DB-backed tests skip and silently hide failures).
Run make gate locally before every commit.
CI (.github/workflows/ci.yml) runs on every push to main and every pull
request. Two jobs block: the Go gate (make gate-host, the same fmt-check, vet
and test on the runner's toolchain, with MariaDB and MongoDB as service
containers) and the webmail2 SPA checks (build, lint, typecheck, test).
golangci-lint and the dependency audit run beside them as reports that never
fail the build. The live clamd test skips in CI.
| Target | What it does |
|---|---|
make up |
Start the dev environment (MariaDB, Mongo, ClamAV, toolchain, every mail service) |
make down |
Stop the dev environment |
make build |
Compile every command binary into bin/ |
make gate |
fmt-check + vet + full test, the pre-commit gate |
make gate-host |
The same checks on the host toolchain, what CI runs |
make test |
Full test run in the dev container |
make test-host |
Host quick-feedback run; DB-backed tests skip |
make test-race |
Race-detector run in the dev container |
make bench PKG=<pkg> |
Host benchmarks only (BENCH= selects them) |
make lint |
golangci-lint v2 on the host; not part of the gate |
make lint-modernize |
Only the modernize analyzer, on the host |
make audit-deps |
govulncheck over the Go tree and npm audit over the SPA |
make fmt |
gofmt -w over the source tree |
make fmt-check |
Fail when any file needs gofmt |
make vet |
go vet |
make tidy |
Sync go.mod / go.sum, downloading any new dependency |
make images |
Rebuild every service image with the source stamp |
make rebuild SVC=<name> |
Rebuild and restart one service |
make dump-db |
Write a compressed dump of the whole directory database |
make restore-db DUMP=<file> |
Load a dump back |
make dump-mail |
Write a consistent copy of every mailbox's mail content |
make version |
Report the release and commit a build would carry |
make release VERSION=x.y.z |
Set the next release number in VERSION and the webmail manifests |
make compose-check |
Validate the compose file syntax |
make clean |
Remove built binaries |
make help |
List every target |
test, test-host and test-race all take PKG= and RUN= to narrow the run:
make test PKG=./internal/objectstore
make test PKG=./internal/objectstore RUN=TestCreateMessageAll three bake in -count=1 (Go's test cache returns stale results otherwise).
Run make gate clean before every commit.
go test prints a per-package failure mid-stream, so the trailing output of a
full run looks the same whether it passed or not. Read the exit code and grep for
failures rather than the tail of the log.
Some tests need infrastructure and skip without it, which is why a green
make test-host proves less than a green make test: MariaDB, MongoDB and
clamd are reached through HERMEX_TEST_MYSQL_DSN, HERMEX_TEST_MONGO_URI and
HERMEX_TEST_CLAMD_ADDR, which the dev container sets and the host does not, and
the S/MIME interop tests need openssl on PATH. The dev image asserts openssl at
build time, so those four tests always run under make gate.
The webmail2 service serves a prebuilt React bundle from
internal/webmail2/dist/ (bind-mounted into the container); the Go image does
not run Vite, and make gate never touches the frontend. After changing
internal/webmail2/src/, rebuild the bundle on the host, and the running
container serves it on the next request:
cd internal/webmail2
npm run build # regenerate dist/
npm run dev # Vite dev server with hot reload
npm run preview # serve the built bundle locally
npm run lint # eslint, max-warnings 0
npm run typecheck # tsc --noEmit
npm test # vitest, single run
npm run test:watch # vitest in watch mode
npm run test:coverage # vitest with coveragemake rebuild SVC=webmail2 rebuilds only the Go backend, not the bundle.
make up exposes the 8140-8149 host block; webmail2 runs alongside at 8450.
| Service | Host | Container |
|---|---|---|
| SMTP (mta) | 8140 | 25 |
| POP3 | 8141 | 110 |
| MariaDB | 8142 | 3306 |
| IMAP | 8143 | 143 |
| Admin panel | 8144 | 8081 |
| DAV | 8145 | 8080 |
| ActiveSync | 8146 | 8080 |
| EWS | 8147 | 8080 |
| MAPI/HTTP | 8148 | 8080 |
| Gateway (TLS) | 8149 | 8080 |
| Webmail2 | 8450 | 8080 |
SMTP, POP3, IMAP and the gateway publish on every interface. Every other port, the database included, is published on the loopback interface only, so the dev credentials and the per-protocol HTTP backends are not reachable from the network.
Four services are internal-only and expose no host port: Mongo (the log
sink), ClamAV (clamav:3310, the antivirus engine), notify (the SSE push
relay), and pebble (an offline ACME test CA, used only in TLS acme mode).
Nothing depends_on them, so the mail path comes up and serves even when any of
them is down.
cmd/admin serve (the operator panel) runs as the admin compose service on
host port 8144. It listens on :8081 in the container (config admin_addr),
serves HTTPS, and requires admin_secret. Nothing depends_on it.
hermEX carries one release number, a plain x.y.z in the VERSION file at the
repository root. Releases are tagged vx.y.z. Every binary links in that number
together with the commit and the build time, and shows it as:
| Build | Shown as |
|---|---|
| The tagged release commit, clean tree | 0.1.0 (adeff8a) |
| Any other commit | 0.1.0-dev+adeff8a |
| A tree with uncommitted changes | 0.1.0-dev+adeff8a-dirty |
That one value appears on each daemon's health endpoint (the admin panel's live
status view), in its process.start log event, under the admin panel sidebar, and
in the webmail login footer and About page. The webmail's package.json carries
the same number, and a test fails the gate when the two disagree. The Exchange
protocol versions that EWS and ActiveSync announce to clients are wire values and
do not follow it.
The container images build from a context that excludes .git, so the toolchain
records nothing about the source. The Dockerfile reads VERSION from the build
context, and the Makefile supplies the commit, the build time and whether the
build is the tagged release. Build images with make images or
make rebuild SVC=<service>; a bare docker compose build produces binaries that
report the release number with an unknown commit. make version prints what a
build would carry right now.
To cut a release, make release VERSION=x.y.z writes the new number to VERSION
and the webmail manifests and checks them. It refuses a number that is not above
the current one or that is already tagged. The changelog entry in CHANGELOG.md,
the commit and the vx.y.z tag follow as separate steps.
config.json is the sole config file, loaded by every daemon including the
gateway. Each daemon takes exactly one flag and no other, so a service is only
ever started one way:
hermex-<daemon> -config /etc/hermex/config.jsonThat hermex- prefix is the name inside the service images. make build
compiles the same commands into bin/ under their bare directory names
(bin/mta, bin/admin, and so on).
docker/config.example.json is the template; its placeholders are deliberately
non-functional, so an unedited copy fails to connect rather than starting with a
working default credential. That holds for the signing secrets too: every daemon
refuses to start while one is still REPLACE_WITH_A_LONG_RANDOM_STRING, rather
than signing sessions and wrapping stored private keys with a value published in
this repository. Generate each one separately:
openssl rand -hex 32It holds infrastructure only: the database DSN and data directory (both
required), listen addresses, gateway backend URLs, TLS certificate paths, secrets,
and the log sink settings. It holds no accounts and no operator policy; both live
in the directory database. There is no .env file and no environment-variable
expansion.
Anything an operator tunes at runtime (rate limits, lockout thresholds, protocol size caps, retention windows, spam thresholds) is stored in the database, managed from the admin panel, and re-read by a poll, so a change applies within about a minute without restarting anything.
All schema changes go through internal/migrate, never ad-hoc DDL. Four schemas
are versioned this way: the directory database, the relay spool, and a mailbox's
two SQLite files. Each owns numbered .sql files embedded into the binary, and
the highest file number is that schema's version. A daemon applies the pending
migrations for every database it opens, at startup, so whichever service restarts
first with a new image carries the shared databases forward. A daemon that refuses
to start with a message about the schema being newer than the binary is a stale
image, not a broken database: rebuild that service.
The directory database is the only copy of things the system generates and cannot re-derive: every domain's DKIM signing key, every uploaded TLS private key, and the accounts, aliases, admin roles and policy around them.
make dump-db # write a compressed dump of the whole directory database
make restore-db DUMP=<file> # load one back (this replaces the current directory)
hermex-admin -config <file> export-dkim <domain> # one domain's signing key to stdoutBoth paths run in the operator's shell, so no private key crosses the network. Take a dump before an upgrade, and keep it off the mail host.
The daemons run unprivileged. make up passes your own user and group id to
every mail container, so nothing that parses network input runs as root and the
bind-mounted docker-data/ tree stays writable by the account that owns it. The
three daemons that listen below port 1024 (SMTP 25, POP3 110, IMAP 143) bind
through a sysctl scoped to their own container rather than through root.
Set key_secret in the configuration file. With it, the DKIM and TLS private
keys are encrypted in the database, so a dump on its own no longer hands anyone a
working signing key: opening it needs the configuration file too. Without it the
keys are stored as they always were, and every daemon says so at startup. Keys
written before the secret was set are re-encrypted the next time they are read,
with nothing to run.
The secret is not recoverable. Losing it loses every stored DKIM and TLS key with it, so back it up separately from the database dump, and export what you need before changing it.
The mail itself is a separate backup. Each mailbox is its own pair of SQLite files plus a content tree, none of which the directory dump touches.
make dump-mail # a consistent copy of every mailbox's mail content
hermex-admin -config <file> backup-mail <email|all> <dest-dir> # one mailbox, or allThe copy is taken with the services running: each mailbox's databases are snapshotted inside a read transaction rather than copied as files, and the content files are copied afterwards so everything the snapshot references is present. The cached wire copies are left out on purpose; the store rebuilds one on the next read of a message, so carrying them would roughly double the backup for nothing. The result mirrors the live directory layout, which makes restoring a plain file copy back into place with the mail services stopped.
Both commands write into docker-data/backup, which is the same disk as the live
data. Nothing in this repository schedules them or copies the result anywhere.
Taking a backup off this host, and doing it on a schedule, is work the operator
has to set up: a host loss, a disk failure or ransomware reaching docker-data
destroys the live data and every backup taken so far together. Until that off-host
copy exists, the recovery point is whenever someone last remembered to type the
command. The database dump verifies itself before it is named, so a file under
docker-data/backup is a complete dump rather than something a restore discovers
is truncated; that is a check on the copy, not a substitute for having one
elsewhere.
hermex-admin is both the provisioning CLI and, under its serve subcommand,
the operator panel. Every invocation takes -config <file> (default
/etc/hermex/config.json) before the subcommand.
hermex-admin -config config.json <command> [args]| Command | Purpose |
|---|---|
ensure-schema |
Apply pending directory migrations and exit |
create-domain <domain> |
Add a mail domain |
create-user <email> <password> |
Add a mailbox |
create-alias <alias> <user-email> |
Point an address at an existing mailbox |
set-password <email> <password> |
Reset a password; forces a change at next login and ends every live session (--no-force-change, --keep-sessions) |
create-contact <email> <domain> [name] |
Add an external mail contact to the address list |
update-contact <email> <name> |
Rename a contact; an empty name clears it |
delete-contact <email> |
Remove a contact |
list-contacts |
List every contact |
grant-admin <email> <system|org|domain> [id] |
Grant an admin role at the given scope |
list-sessions <email> |
Show the account's live webmail and panel sessions |
revoke-sessions <email> |
End all of them; the compromise response |
ldap-sync <connection-id> |
Import each bound domain's LDAP/AD accounts from the connection |
export-dkim <domain> |
Write the domain's DKIM private key to stdout |
check-mailbox <email|all> [--repair] [--recover] |
Report store damage; --repair rebuilds the IMAP index, --recover rebuilds a damaged database and loses the rows on its damaged pages |
sweep-content <email> |
Reclaim orphan content files; refuses while the mailbox is in use |
prune-eml <email|all> [days] |
Drop cached wire copies older than N days (default 30) |
backfill-previews <email|all> |
Fill the message-list snippet and attachment flag on older index rows |
move-mail <email> <src> <dst> <uid>... [--copy] |
Relocate messages between two folders of one mailbox |
backup-mail <email|all> <dest-dir> |
Consistent copy of mail content; safe on a live mailbox |
serve |
Run the admin API and panel |
The panel covers domains, users, aliases, mailing lists, delegates, devices, DKIM keys, DNS checks, the mail queue, quarantine, inbound DMARC and TLS reports, their retention, outbound DMARC report sending, and the spam model. It is separate from end-user webmail and is not behind the gateway.
Every daemon can serve a /healthz endpoint reporting its dependency state and
its build stamp. It is opt-in per daemon (health_addr, empty disables it), and
the panel's live status view aggregates the endpoints an operator adds on that
page. The list is stored in the directory database, so a change applies without
a restart.
A serving certificate that is within two weeks of expiry is reported two ways: the
/healthz probe marks the daemon degraded, and the certificate poll writes a
warning to the central log every few hours (an error once the certificate has
lapsed). The log path needs no configuration, so a deployment that leaves
health_addr empty still learns about a failed renewal.
The Bayes model and the SpamAssassin-style ruleset are seeded by two one-shot tools rather than fetched at runtime:
antispam-bootstrap -spam <dir> -ham <dir> [-out model.json]
antispam-rules -from <dir-of-.cf-files> [-out <data_dir>/antispam-rules.cf]antispam-rules validates before writing and refuses a directory that yields no
evaluable rules, so a wrong path fails instead of shipping an empty ruleset. The
MTA picks up a new ruleset within a minute, with no restart. Fetching and
verifying the upstream rules stays with the operator's own sa-update or a
checkout, which is what does the signature checking.
fetchmail -config <file> runs the POP3/IMAP retrieval loop that pulls external
accounts into local mailboxes. The accounts themselves are configured in the
admin panel, not on the command line.
| Path | Purpose |
|---|---|
cmd/ |
Service executables (mta, imap, pop3, webmail2, dav, activesync, ews, mapihttp, gateway, notify, admin, fetchmail, antispam-bootstrap, antispam-rules) and healthcheck, the probe binary every container healthcheck runs |
internal/ |
Shared libraries: MAPI core, mailbox store, format conversion, protocol servers, mail flow, filtering, security/TLS, directory, notifications, platform |
docker/ |
Dev and service container images |
- Database: MariaDB (
email) viago-sql-driver/mysql; password hashing iscrypt_sha512at a high round count, and a successful login re-hashes a credential stored below the current cost. Tests use a separate, auto-createdhermex_testdatabase. - Mailbox store:
internal/objectstore, per-mailbox SQLite, addressed by built-inPrivateFID_*folder constants, never by name lookup. Opening a mailbox takes a shared advisory lock, so maintenance passes that reclaim storage can tell a live mailbox from an idle one instead of racing a writer. - Auth and accounts:
internal/directorybacked by MariaDB. Address-book queries are scoped to the caller's organization, or to the caller's own domain when the domain belongs to none. - Second factor: webmail and the admin panel share one TOTP enrollment per account, with single-use recovery codes. Once an account enables it, the account password stops working on IMAP, POP3, SMTP submission, ActiveSync, DAV, EWS and MAPI/HTTP, and only a per-client app password (minted in webmail) logs in there.
- Inbound reports: DMARC aggregate, DMARC failure and TLS-RPT reports mailed to a hosted domain's
postmaster@address are delivered as ordinary mail and also stored, then shown domain-scoped on the admin panel's Reports page. A report is stored only when its domain matches the addressed postmaster domain. TLS report 640F s posted over HTTPS (RFC 8460) arrive athttps://<hostname>/tlsrpt, served by the MTA onmta_http_addrbehind the gateway, and are stored the same way; the prescribed_smtp._tlsrecord names both addresses. Each stored report records how it arrived and the DKIM result of a mailed one, because its content is the sender's claim. - Outbound DMARC reports: when a system administrator turns it on (off by default), the MTA counts inbound mail per From domain that publishes
rua=and sends each such domain a daily RFC 7489 aggregate report fromnoreply@<hostname>to itsmailto:addresses. A destination outside the domain's organization must publish the RFC 7489 §7.1 authorization record. The report is DKIM-signed only when the hostname itself has a DKIM key. - Mail construction:
internal/oxcmail.Export()is the single path from a MAPI object to MIME bytes; outgoing mail is never hand-rolled. Every send path converges on one delivery function, so the DKIM signer always sees the final bytes. - Inbound filtering: delivery scores each message through
internal/antispam(SPF/DKIM/DMARC auth + a Bayes classifier + rules), then narrows the verdict by operator and recipient allow-block tiers. Each DNS-dependent check carries a deadline, since the queried nameserver belongs to the sender. - Antivirus: delivery streams each message to ClamAV (
internal/antivirus); a hit is quarantined (internal/quarantine) and the recipient plus domain admins are notified. The scan fails open, so a down clamd never blocks mail. - Push:
internal/notify+cmd/notifyare a self-standing SSE wake bus. Long-poll consumers (MAPI/HTTP async, EWS streaming, IMAP IDLE, EAS Ping, DAV push) wake on a mailbox change; a down relay silently drops every consumer back to polling. - Logging: a self-healing MongoDB sink. If Mongo is down, events spill to disk and replay on reconnect, and the mail path never hard-depends on it.
Licensed under the MIT License. See the LICENSE file.
hermEX is a Go rewrite of the gromox project.