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
8080published,restart: unless-stopped - a named volume
ihasmail-datamounted at/data, whereSESSION_FILEdefaults to/data/sessions.json— this is what stops a restart signing everybody out TRUST_PROXY=1andIMAGE_PROXY=1- a healthcheck hitting
/api/healthevery 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:
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;
}
}
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¶
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 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.