Files
ihasmail-oneshot/docs/how-it-works.md
T
jcoffey-dev 3fd909cba3
ci / test (pull_request) Successful in 58s
ci / release (pull_request) Skipped
Point links at git.coffeylabs.org after the move from GitHub
GitHub took the organization's repos and GHCR offline on 2026-09-20. Repo,
release, raw-file and clone links now go to Gitea at git.coffeylabs.org,
container images to registry.coffeylabs.org, and GitLab-style /-/blob paths
to Gitea's /src/branch form. Go module paths are identifiers and stay as
they are; links to GitHub issues and pull requests are left as history.
2026-09-22 09:08:08 -07:00

14 KiB

How ihasmail-oneshot works

The design of the deployment this tool writes, and the reasons behind each choice: what runs where, the order the deploy happens in, how Stalwart is set up without its web wizard, how two services share certificates on one pair of ports, and how Stalwart's automatic bans are kept from locking everyone out. For installing and using the tool, start with the README or the guide on docs.ihasmail.org.

Why it exists

Stalwart and ihasmail each install easily on their own. Making them a working, safe mail host together takes a series of decisions that are easy to get wrong, and most of the mistakes don't show until later:

  • Stalwart 0.16 has no configuration file to template. Its settings live in its data store, and a new server starts in a bootstrap mode that expects a person at a web wizard. Automating that means driving its registry API, and making sure the bootstrap credential doesn't quietly outlive the setup.
  • Both services want the same ports for certificates. The webmail needs HTTPS on 443. Stalwart needs its own certificate for IMAP and SMTP, and its default way of getting one also wants port 443. Two ACME clients fighting over one port fail in confusing, intermittent ways.
  • Stalwart's automatic IP bans don't expect a proxy in front of it. Stalwart bans addresses that probe for things like WordPress admin pages. Behind a reverse proxy, every client arrives from the proxy's address, so one bot scanning your mail host gets the proxy banned. Autoconfig, calendar sync and certificate renewals all stop working, for everyone, with nothing obviously wrong. This was reproduced on Stalwart 0.16.22 while building this tool, as was a second trap: the setting that fixes it only takes effect after a restart.
  • The route from webmail to mail server matters. Sent over the private Docker network, it never leaves the host and costs about a third of the memory per signed-in browser tab that going back out over public HTTPS does.
  • Mail doesn't flow until DNS is right, and the list of records (MX, SPF, DKIM, DMARC, SRV, MTA-STS, autoconfig) is long.

This tool makes each of those decisions once, the same way every time, and checks the result before telling you it is done. Each one is explained below.

What it deploys

A single, statically linked binary for Linux (amd64 and arm64). You run it on the Docker host that should become the mail server. It needs Docker and the compose plugin, and nothing else: no configuration file to write first, no language runtime, no other scripts.

It deploys three containers:

Container Image Role
Stalwart stalwartlabs/stalwart The mail server: SMTP, IMAP, POP3, JMAP, CalDAV, CardDAV, spam filtering, DKIM. It holds all the mail and all the accounts
ihasmail registry.coffeylabs.org/coffey-labs/ihasmail The webmail: mail, calendars, contacts, files and filters in the browser, talking to Stalwart over JMAP. It holds nothing but sessions
Caddy caddy The HTTPS front: certificates and TLS for the webmail and for Stalwart's web side

It has two shapes:

deploy --domain example.com deploy --local
For A real mail host on the internet Trying ihasmail against a real Stalwart on your own machine
Containers Stalwart, ihasmail, Caddy Stalwart, ihasmail
Publishes 25, 80, 443, 465, 993, 995, 4190 on every interface Nothing but two loopback ports
Certificates Let's Encrypt, for both Caddy and Stalwart None
Webmail https://webmail.example.com http://127.0.0.1:8080
Stalwart admin https://mail.example.com/admin http://127.0.0.1:8081/admin

What a deploy does, in order

In order, in one run:

  1. Checks the host before changing anything: Docker and compose are there and usable, every port it needs is free, the deployment directory is new or empty, no compose project of the same name exists, and the hostnames resolve. It reports every problem at once, not the first.
  2. Finds ihasmail's newest release, shows you the plan and asks before going ahead, so what you agree to is an exact ihasmail version. --yes skips the question, and is required when there is no terminal to ask on.
  3. Writes the deployment directory: compose.yaml, the Caddyfile, and .env holding a freshly generated APP_SECRET.
  4. Pulls the images: Stalwart and Caddy at the versions this release of the tool was tested with, and the ihasmail release it found, written into compose.yaml by its dated tag so nothing moves it later.
  5. Starts Stalwart in bootstrap mode with a one-time administrator whose password exists only in the tool's memory.
  6. Completes Stalwart's setup through its API, the same setup its web UI would otherwise walk you through: hostname, mail domain, DKIM keys, logging. Stalwart creates the permanent administrator and returns its password, which is written to credentials.txt straight away.
  7. Starts the whole stack. Stalwart is recreated without the one-time administrator.
  8. Links ihasmail and Stalwart: exempts ihasmail from Stalwart's automatic IP bans, tells Stalwart to trust the client addresses Caddy forwards, and restarts Stalwart so both apply.
  9. Creates the mailboxes you asked for with --user, each with a generated password.
  10. Proves the link by signing in through ihasmail as the administrator. That only succeeds if ihasmail can reach Stalwart and Stalwart accepts the credentials.
  11. Requests certificates (mail host only): sets up an ACME account in Stalwart for its IMAP and SMTP certificate, and waits for that and for Caddy's certificates to arrive.
  12. Writes dns-records.zone, every DNS record Stalwart wants published, and prints a summary of URLs, credentials and anything still to do.

The shape of the deployment

flowchart LR
    Browser(["Browser"])
    Apps(["Mail apps, other mail servers"])

    subgraph Host["Docker host"]
        subgraph Net["private network 172.31.253.0/24"]
            Caddy["Caddy<br/>.10"]
            Ihasmail["ihasmail<br/>.11<br/>read-only, no volume"]
            Stalwart["Stalwart<br/>.12"]
        end
        Data[("stalwart-data")]
    end

    Browser -- "443 webmail.example.com" --> Caddy
    Browser -- "443 mail.example.com/admin, DAV, autoconfig" --> Caddy
    Caddy -- "http :8080" --> Ihasmail
    Caddy -- "http :8080" --> Stalwart
    Ihasmail -- "JMAP over http://stalwart:8080" --> Stalwart
    Apps -- "25, 465, 993, 995, 4190" --> Stalwart
    Stalwart --- Data
  • Caddy is the only thing on ports 80 and 443. It serves the webmail at the webmail host, and Stalwart's web side (admin UI, JMAP for other clients, CalDAV, CardDAV, autoconfig, MTA-STS) at the mail host and the autoconfig, autodiscover, mta-sts and ua-auto-config names.
  • Stalwart publishes its mail ports directly, so SMTP and IMAP clients reach it with their real addresses.
  • ihasmail talks to Stalwart over plain HTTP on the private network, as http://stalwart:8080. The browser never talks to Stalwart for webmail; it talks to ihasmail, and ihasmail makes the JMAP calls.
  • The three containers have fixed addresses, because Stalwart is told about two of them (see below), and an address Docker picks afresh on every recreate couldn't be written into Stalwart's settings.

The deploy, as a sequence

sequenceDiagram
    autonumber
    participant T as ihasmail-oneshot
    participant S as Stalwart
    participant I as ihasmail
    participant C as Caddy
    participant CA as Let's Encrypt

    T->>S: start with a one-time admin (bootstrap mode)
    T->>S: x:Bootstrap/set: hostname, domain, DKIM, logging
    S-->>T: permanent administrator + password
    T->>S: recreate without the one-time admin
    T->>I: start
    T->>C: start
    C->>CA: certificates for its names (TLS-ALPN-01, port 443)
    T->>S: allow ihasmail's address, trust Caddy's X-Forwarded-For
    T->>S: restart to apply
    T->>S: create mailboxes
    T->>I: sign in as the administrator
    I->>S: JMAP session (proves the link)
    T->>S: create ACME account (HTTP-01), domain to automatic certificates
    S->>CA: order certificate
    CA->>C: HTTP-01 challenge on port 80
    C->>S: forwarded /.well-known/acme-challenge/
    S-->>T: certificate issued
    T->>S: read the DNS zone

Setting up Stalwart without its web wizard

Stalwart 0.16 keeps its configuration in its data store rather than a file. A server started with an empty configuration comes up in bootstrap mode: a temporary administrator and a setup wizard on port 8080. The wizard is a single registry object, x:Bootstrap, written with one JMAP call.

The tool starts Stalwart with STALWART_RECOVERY_ADMIN set to a random password held only in memory. It's passed through an override file that exists only for that step, and through the environment of the docker compose process, never written into compose.yaml. It then sets:

  • serverHostname and defaultDomain from --mail-host and --domain;
  • generateDkimKeys on, so outgoing mail is signed from the start;
  • the log to stdout, because Stalwart's default log directory doesn't exist in the container image and isn't a volume;
  • requestTlsCertificate off, because on 0.16.22 that switch creates no ACME account and leaves the domain on manual certificates, so it would look like certificates had been handled when they hadn't. The tool sets ACME up itself, explicitly.

Stalwart answers with a permanent administrator, admin@DOMAIN, and its password. The tool writes that to credentials.txt before doing anything else, so a failure later can't lose it. Then it brings the full stack up from compose.yaml alone, which recreates Stalwart without the one-time administrator, so no fixed recovery credential outlives the setup.

The tool refuses to set up a Stalwart that isn't in bootstrap mode, so it can never reconfigure a server that already holds someone's mail.

Certificates for two services on one pair of ports

Caddy needs certificates to serve HTTPS. Stalwart needs its own for IMAP, POP3, submission and STARTTLS on SMTP. Both need one for the mail host's name, and both would normally want port 443 to prove they control it.

They're separated by ACME challenge type:

Challenge Port How
Caddy TLS-ALPN-01 443 Configured with disable_http_challenge for Stalwart's names
Stalwart HTTP-01 80 Caddy forwards /.well-known/acme-challenge/* for Stalwart's names to Stalwart, untouched. Everything else on port 80 is redirected to HTTPS

Neither ever answers the other's challenge, and neither needs the other's key. Stalwart's certificate covers the mail host plus autoconfig, autodiscover, mta-sts and ua-auto-config under the domain, the names its own DNS zone points at the mail host, and Caddy fronts all five so every challenge reaches it.

Stalwart starts its order as soon as the ACME account exists, and renews by itself from then on. If an order fails, usually because DNS isn't in place yet, Stalwart doesn't try again by itself. certs starts a new order.

Stalwart's automatic IP bans, behind a proxy

Stalwart bans an IP address that behaves like an attacker. For example, 30 requests in a day for scanner paths such as /wp-admin.php get an address banned. That's good protection, but it counts by the address a connection comes from, and two kinds of traffic reach Stalwart from a single shared address:

  1. Everything through Caddy arrives from Caddy's address. Unchanged, one bot scanning https://mail.example.com bans Caddy. That was reproduced on 0.16.22: after a scan, autoconfig answered 403 to everyone, and so would calendar sync and certificate renewal.

    Fix: Stalwart is told to take the client's address from the X-Forwarded-For header Caddy sets. Re-tested after the change, the scanner was banned and every other client still got through. This is safe only because nothing untrusted can reach Stalwart's HTTP port: it's published on loopback alone, and on the private network its only peers are Caddy, which sets the header itself, and ihasmail, which sends none.

  2. Every webmail request arrives from ihasmail's address: every sign-in, and every push stream opened and dropped as people open and close tabs. A ban on that one address would be a ban on everyone's webmail.

    Fix: ihasmail's fixed address is added to Stalwart's allowed addresses. ihasmail limits sign-in attempts per real client itself, so repeated wrong passwords are still slowed down, just not by banning the webmail.

On 0.16.22 neither setting takes effect until Stalwart restarts. Applied to a running server, a scan straight afterwards still banned Caddy. So the tool restarts Stalwart after making both changes, before anything else depends on them.

The webmail's side

ihasmail is deployed the way its own documentation recommends for this situation:

  • Immutable: read-only root filesystem, no volume, sessions in memory. A restart signs everyone out and loses nothing else, because everything durable, each user's settings included, lives in Stalwart.
  • Private route to Stalwart (STALWART_URL=http://stalwart:8080): the credentials that travel on that leg never leave the host.
  • Push by subscription (PUSH_URL=https://webmail.example.com): Stalwart posts mailbox changes to ihasmail instead of holding a connection open for every browser tab. If Stalwart can't reach that URL, every tab uses the ordinary relay instead and nothing breaks. /api/health shows which is in use.
  • Behind a trusted proxy (TRUST_PROXY=1): ihasmail believes Caddy's forwarded headers, because Caddy is on a private network range, so it sets secure cookies and attributes sign-in attempts to the real client.