Skip to content

Installing ihasmail

ihasmail is one small Node process in front of your Stalwart server. It holds no mail, runs no database and stores nothing but sessions, so installing it is mostly a matter of telling it where Stalwart is and putting TLS in front.

What you need

Stalwart 0.16 or newer. This is a hard requirement: sign-in refuses an older server by name rather than letting individual features fail separately. 0.16 replaced the REST management API with JMAP registry objects, changed the shape of FileNode and split its rights up — carrying both generations meant a wrong guess had somewhere to fall back to, so it failed quietly. If you are on 0.15 and cannot upgrade yet, the last release that runs on it is tagged stalwart-0.15-support.

Upgrading 0.15 → 0.16?

The store is migrated in place with no way back, and Stalwart's own converter drops settings without saying so. stalwart-migrator is a companion project for exactly this: it checkpoints every phase, refuses to start on the things that cannot be fixed mid-migration, and validates the server afterwards.

Somewhere to run it. Either Docker (the quick start below), or Node ≥ 20.10 — 22 is what the image uses — with npm ≥ 10.

Network access from ihasmail to Stalwart. The server discovers the JMAP session at <STALWART_URL>/.well-known/jmap. Nothing in the browser talks to Stalwart directly, so it is the container that needs to reach it, not your users.

A reverse proxy terminating TLS. Strictly optional for a first look, and strictly required in practice: Secure cookies, the installable PWA, Web Push notifications and registering as the system mailto: handler all need HTTPS.

Two values to decide first

Variable What to set it to
STALWART_URL Scheme and host of your Stalwart server, no path — https://mail.example.com
APP_SECRET A strong random value: openssl rand -base64 48. Session encryption keys are derived from it

APP_SECRET is not optional in production

The image runs with NODE_ENV=production, and the server refuses to start if APP_SECRET is unset or still change-me. Outside production it generates an ephemeral one and warns — which means persisted sessions do not survive a restart.

Quick start with Docker

git clone https://github.com/LINUXexpert-org/ihasmail.git
cd ihasmail
cp .env.example .env
# edit .env: STALWART_URL, and APP_SECRET=$(openssl rand -base64 48)
docker compose up --build -d

That is a working instance on http://localhost:8080.

What docker-compose.yml sets up for you:

  • port 8080 published, restart: unless-stopped
  • a named volume ihasmail-data mounted at /data, where SESSION_FILE defaults to /data/sessions.json — this is what stops a restart signing everybody out
  • TRUST_PROXY=1 and IMAGE_PROXY=1
  • a healthcheck hitting /api/health every 30 seconds

Nothing else is persisted. There is no database and no mail store to back up: even user settings live in the account's own JMAP Files, on Stalwart.

Building the image with a version on it

.dockerignore excludes .git on purpose, and git is not installed in the build stage, so an image build cannot work out what version it is. Pass it in:

docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2.16 .

Left out, the build falls back to the base version from package.json (2.16.0) rather than failing — so a version with no PR number on it means whoever built the image did not pass one.

Putting TLS in front

Bind ihasmail to loopback and let the proxy hold the certificate. Two details matter in both examples: the Server-Sent Events connection carrying push updates must not be buffered, and the body limit has to clear MAX_UPLOAD_BYTES (50 MiB by default).

server {
    listen 443 ssl http2;
    server_name mail.example.com;
    # ssl_certificate ...; ssl_certificate_key ...;

    client_max_body_size 60m;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        # Server-Sent Events (push notifications)
        proxy_buffering off;
        proxy_read_timeout 3600s;
    }
}
mail.example.com {
    encode zstd gzip
    reverse_proxy 127.0.0.1:8080 {
        # Keep SSE (push) connections open
        flush_interval -1
    }
}

Both are in the repo as nginx.example.conf and Caddyfile.example.

ihasmail marks cookies Secure and sends HSTS when it sees X-Forwarded-Proto: https, which it believes only from a peer in TRUSTED_PROXIES — unset, that means loopback and the private ranges, so a proxy on the same host or Docker network is already covered. A request from anywhere else is attributed to its socket address whatever its headers claim; otherwise anyone could pick their own key for the login rate limiter.

Checking it came up

$ curl -s http://127.0.0.1:8080/api/health
{"ok":true,"name":"ihasmail","version":"2.16.57"}

docker compose ps should show the container healthy within a minute — the healthcheck is the same request.

Then open the site and sign in with a Stalwart mailbox address and password. There are no ihasmail accounts to create; it authenticates against Stalwart and holds the credentials for the session only, sealed with a key derived from the cookie secret.

An account with two-factor authentication needs an app password

The two-factor code field on the sign-in page cannot work, and no version of ihasmail has made it work. Stalwart accepts a TOTP code only through an OAuth flow — its own web interface is an OAuth client, which is why signing in there succeeds — and it offers no password grant, so a client holding a username and password has nowhere to exchange them plus a code for a token.

Create an app password in Stalwart's own settings and sign in with that instead. App passwords bypass TOTP; it is Stalwart's documented answer for clients like this one. The field is still on the page, because someone with 2FA will look for it and finding nothing is worse than finding a field that explains itself — and a sign-in that carries a code is refused with that explanation rather than a bare "invalid credentials".

Sign-in says the server is too old

ihasmail decides this by looking for Stalwart's urn:stalwart:jmap capability. Stalwart advertises it per account — in primaryAccounts and each account's accountCapabilities — never in the session-level capabilities, so a server that really is 0.16 will still be refused if something between ihasmail and Stalwart is rewriting the session response. Check what <STALWART_URL>/.well-known/jmap returns for the account.

Installing from source

npm install
npm run build          # web/dist + server/dist
npm start              # serves the production build on :8080

npm start reads the same .env. For development there are two modes:

npm run dev            # against a real Stalwart (STALWART_URL in .env)
npm run dev:mock       # against the built-in mock — no real mailbox needed

npm run dev puts the server on :8080 and Vite on :5173; open :5173. The mock signs in as demo@example.com / demo.

The mock is worth knowing about

npm run mock is an in-memory fake Stalwart 0.16 — enough JMAP to develop and demo against. It deliberately reproduces the things a real server does that a naive fake would not, each of which cost a live debugging session to find: where urn:stalwart:jmap is advertised, the 2047-byte cap on identity signatures, and CalendarEvent/set refusing RFC 8984's spelling in favour of Stalwart's own.

Redeploying later

deploy.example.sh in the repo is a single-host Docker deploy: it fetches, refuses anything held back, shows what is about to be introduced and asks, rebuilds the image with the right version baked in, replaces the container and waits for it to report healthy.

./deploy.sh                 # origin/main, asks before shipping new commits
./deploy.sh --dry-run       # run the guards and stop
./deploy.sh v2.16.57 --yes  # a named ref, no prompt (there is no tty over ssh)

Two guards, because a deploy script is where a careless run does the most damage. .deploy-hold lists commits that must not reach production yet, one per line, and a target carrying one is refused outright — --yes does not override it. Separately, anything introducing new commits has to be confirmed, so a bare run over SSH cannot ship whatever main picked up since.

Each build is tagged with its own version as well as :current, so rolling back is running the previous tag rather than rebuilding it. Old images are pruned down to IHASMAIL_KEEP_VERSIONS (three by default) after the health check passes, and never the one the container is running.


Next: Configuring ihasmail — every environment variable, and the handful of Stalwart-side settings some features need.