Send the length of the bytes we are actually sending
ihasmail
Immutable webmail for Stalwart Mail Server — a container with nothing to persist, and a Gmail-class client on top of it.
Mail, calendars, contacts, files and filters in a responsive single-page app
that works equally well on a desktop monitor and a phone. It talks only JMAP
(plus Stalwart's blob/upload/EventSource endpoints) — no IMAP, no SMTP, no
database, and with IMMUTABLE=1 no writable filesystem either. Everything
durable belongs to Stalwart; the container is disposable.
| 🌐 ihasmail.org | What it is, what it looks like, the full feature list |
| 📘 docs.ihasmail.org | Installing · Configuring · Using it · Shortcuts · Rebranding · Troubleshooting |
| 🧪 KNOWN-ISSUES.md | What was verified live, and where Stalwart departs from a spec |
| 🛣 ROADMAP.md | What ihasmail does not do, and why |
This file is for people working on ihasmail. Everything about running it lives in the docs.
Screenshots
Taken against the built-in mock server (npm run dev:mock) with sample data — no real mailbox involved.
Inbox & conversation (dark) ![]() |
Inbox & conversation (light) ![]() |
Composer ![]() |
Calendar ![]() |
Contacts ![]() |
Sieve filter builder ![]() |
More, including the mobile layout, on ihasmail.org.
What's in it
- Mail — three-pane Gmail-style layout, conversation view, virtualised list, labels, undo, Gmail search operators and keyboard shortcuts, Sieve rules from a message's context menu, sanitised HTML with remote images blocked, read receipts, invitations and RSVP, multi-composer rich-text editing with signatures, scheduled send and undo send
- Calendar — JMAP Calendars / JSCalendar: month/week/day/agenda, recurrence, attendees and free-busy, colour categories
- Contacts — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export
- Files — JMAP FileNode: browse, upload, download, rename, move, delete
- Settings that follow the account, not the browser — kept in a
settings.jsonin the account's own JMAP Files, so ihasmail itself stays stateless - Runs read-only — one optional write path, and with it switched off the container needs no volume and no writable root.
IMMUTABLE=1is checked at startup rather than trusted, so a half-applied switch refuses to boot instead of failing quietly. See Running immutably - Platform — installable PWA, Web Push with ihasmail closed,
mailto:handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
The long version is on ihasmail.org; how to drive each one is in Using ihasmail.
Requires Stalwart 0.16 or newer
Sign-in refuses anything older, by name. 0.16 replaced the REST management API
with JMAP registry objects, changed the shape of FileNode, split its rights up
and moved configuration into the store; supporting both generations meant a
wrong guess had somewhere to fall back to, so it failed quietly — and that
reached production. With one supported generation a wrong guess is a loud error
on the first call.
- Still on 0.15? The last release that runs on it is tagged
stalwart-0.15-support. - Upgrading? stalwart-migrator does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
Quick start (Docker)
cp .env.example .env
# edit: STALWART_URL=https://mail.example.com and APP_SECRET=$(openssl rand -base64 48)
docker compose up --build -d
# → http://localhost:8080 (put Caddy/nginx in front for TLS; see Caddyfile.example / nginx.example.conf)
Users sign in with their Stalwart mailbox credentials. An account with two-factor authentication needs an app password, created in Stalwart's own settings — Stalwart accepts a TOTP code only through an OAuth flow and offers no password grant, so no client holding a username and password can exchange them plus a code for a token.
Full instructions, TLS, and every environment variable: Installing · Configuring.
Running immutably
The server writes to exactly one path, the optional SESSION_FILE. Clear it
and there is nothing left to write, so the container can run with no writable
filesystem at all:
docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
IMMUTABLE=1 is an assertion the server checks at startup rather than a switch
that changes what it does: it refuses to start if SESSION_FILE is still set,
or if the filesystem it is installed on turns out to be writable after all.
Without it the same misconfiguration is silent — sessions are held in memory
and persisting them is best-effort, so a read-only /data costs one warning at
the first sign-in and nothing else until the instance is replaced and everyone
is signed out.
That sign-out is the standing cost of this mode today, since sessions have nowhere to live across a restart. Removing it means moving the session upstream into a token Stalwart itself issues and can revoke, which is what the OAuth work in ROADMAP.md is for.
Architecture
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart
React SPA • session cookie ⇄ Basic auth
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
web/— Vite + React 19 + TypeScript SPA.src/jmap(client, push, types),src/store(zustand: session, mail, compose, contacts, calendar, files, sieve, settings),src/views,src/lib(sanitiser, search parser, Sieve codec, locale-aware dates, vCard, …).server/— Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, seals the credentials with a key derived from the cookie secret, proxies JMAP/blob/SSE, serves the SPA under a strict CSP.src/mock/is an in-memory fake Stalwart for development and demos.
Capabilities used: core, mail, submission, vacationresponse, sieve,
contacts(+parse), calendars(+parse), principals(+availability),
quota, blob, filenode, EventSource push, plus Stalwart's own
urn:stalwart:jmap (read-only). Features degrade gracefully when one is
missing.
Development
Requirements: Node ≥ 20.10 (22 recommended), npm ≥ 10.
npm install
npm run dev # real Stalwart (STALWART_URL in .env) — server :8080, Vite :5173
npm run dev:mock # built-in mock Stalwart ([email protected] / demo), mock on :8788
npm run dev:mock:no-future-release # mock that advertises FUTURERELEASE and drops every hold
npm run typecheck # tsc for both packages
npm test # vitest (web) + node:test (server)
npm run build # web/dist + server/dist
npm start # serve the production build
Open http://localhost:5173 in dev, or http://localhost:8080 for the production build. Running it for real is covered in Installing and Configuring.
The mock
An in-memory fake Stalwart 0.16 — enough JMAP to develop and demo against
without a real mailbox. It reproduces the things a naive fake would get wrong,
because each cost a live debugging session: urn:stalwart:jmap advertised
per-account rather than session-level, identity signatures capped at 2047
bytes, and CalendarEvent/set speaking Stalwart's vocabulary rather than
RFC 8984's. Two switches: MOCK_NO_FUTURE_RELEASE=1 advertises FUTURERELEASE
and then drops every hold; MOCK_NO_REGISTRY=1 omits the Stalwart capability so
the sign-in refusal can be tested.
Version numbers
ihasmail v2.16.84 — 2 is ihasmail's own major, 16 the Stalwart generation
this build targets, 84 the pull request the commit came from. The first two
live in the root package.json; the third comes from git at build time, since
it does not exist until the PR has merged. A commit that did not arrive through
a PR carries the last number plus its short SHA — 2.16.84+g1fa6578.
node scripts/version.mjs # the version for the current checkout
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2.16 .
.dockerignore excludes .git deliberately, so an image build cannot work this
out for itself — pass it in. Left out, the build falls back to the base version
from package.json, so a version with no PR number means whoever built the
image did not pass one.
Deploying
deploy.example.sh is a single-host Docker deploy: it
fetches, refuses anything held back by .deploy-hold, shows what is about to be
introduced and asks, rebuilds with the right version baked in, replaces the
container, waits for healthy, then prunes all but the newest
IHASMAIL_KEEP_VERSIONS images — never the one actually running.
./deploy.sh # origin/main, asks before shipping new commits
./deploy.sh --dry-run # run the guards and stop
./deploy.sh v2.16.84 --yes # a named ref, no prompt (there is no tty over ssh)
--yes does not override a hold; clearing one means deleting its line.
Contributing
CONTRIBUTING.md · CODE_OF_CONDUCT.md · SECURITY.md — please report vulnerabilities privately.
License
Copyright (C) 2026 Coffey Labs — AGPL-3.0-or-later. See LICENSE.
ihasmail was relicensed from GPL-3.0 to AGPL-3.0 on 2026-08-25: webmail is nearly always run as a network service rather than handed to anyone as a binary, and the AGPL's section 13 closes that gap.
That offer has to point at your source, not this one. If you run a modified
ihasmail, set SOURCE_URL to your own repository — the sign-in page and
Settings › About both show it. See
Rebranding.






