Groundwork for un-shelving translations. Chrome's translator rewrites the rendered DOM directly, wrapping text nodes in <font> elements React has never heard of, and the next update can then call removeChild against a parent whose children have moved (facebook/react#11538). This is the structural defence against that, plus the setting the served language will read from. The language setting is `uiLanguage`, and it is deliberately not the `locale` field that already exists. That one is a formatting choice -- what calendar, clock and numerals to use -- and folding the two together would silently rewrite everybody's date format the first time they picked a language. German dates with an English interface is a real preference, and so is the reverse. It defaults to English when absent, which covers both a new account and every settings file written before this, and Accept-Language is not consulted: a served locale should be something the reader chose rather than something guessed and then written down as though they had. Only languages with strings shipped are offered, which today means English alone -- a picker entry without a catalogue behind it would leave the page claiming a language it is not in, which stops a reader translating a page they cannot read. `<html lang>` is set where applyTheme is set: at store module load, from the localStorage cache, before createRoot() has rendered anything. Not in an effect -- a lang that is briefly wrong is enough to raise the translate prompt on a page that needed none. There is no server-rendered alternative to reach for here: ihasmail serves a static shell and holds no account state, and the settings file lives in the reader's own JMAP Files, so reading it before the page existed would mean authenticating to Stalwart on every page load. The static lang="en" in index.html covers the first bytes; the store only ever corrects a reader who chose otherwise. Both halves are tested. translate="no" and class="notranslate" go on the narrow boundaries only: rendered email bodies, raw message source, attachment text, the generated and hand-edited Sieve, the brand and the login name. Not on <body> -- someone whose language ihasmail does not speak yet should still be able to translate the parts that are ours. Email bodies turn out to live in a shadow root, so React never reconciles them and they were never a crash risk; the marker there is about not rewriting what a sender actually wrote. Twenty-four fragile interpolation points were found with the TypeScript parser rather than grep, and fifteen refactored. Pluralisation and "count + label" pairs are collapsed into a single expression so the text is a lone child React updates with textContent, rather than a text node with conditional siblings to insert around. One of them -- InviteCard's {method === "REPLY" && organizer ? "" : ""} -- rendered an empty string either way and is simply gone. The boundary is scoped to the main content, so the header, folder tree and any open composer sit outside it and survive independently. It recovers by remounting the subtree, which costs nothing because everything inside re-derives from the stores, and it logs at info rather than error: a reader translating a page is expected and recovered from, and filing it as an error would put an entry in every console-reading reporter for behaviour that worked. It re-raises anything that is not a DOM mutation error, so a real bug still surfaces as one, and it gives up after three attempts rather than looping invisibly. Worth recording: the crash could not be reproduced on React 19.2.8. Wrapping 207-249 React-managed text nodes in <font>, exactly as the translator does, then driving in-place conditional toggles and navigations, left the app intact with the boundary never firing. The original issue is from React 16 and the reconciler has changed a great deal since. So this lands as defence whose premise is weaker than assumed rather than as a fix for something observed here, and the boundary is insurance rather than a load-bearing part. The notranslate markers and the collapsed interpolations stand on their own merits either way.
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 |
| 📋 FEATURES.md | Everything it does, feature by feature, with the capability each one needs |
| 🧪 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 - On a phone — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation
- 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 v2026.8.30+pr129 — the date of the commit this was built from, and
the pull request that commit arrived through. A commit that did not arrive
through one carries its short SHA instead: 2026.8.30+g1fa6578. It all comes
from git at build time; nothing writes a version into the tree, and
package.json sits at 0.0.0 because it is no longer the source of anything.
The date is the commit's own rather than today's, so rebuilding an old commit gives the version it had the first time.
node scripts/version.mjs # the version for the current checkout
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2026.8.30 .
.dockerignore excludes .git deliberately, so an image build cannot work this
out for itself — pass it in. Left out, the build reports 0.0.0, which is meant
to look wrong: a version with no +pr or +g means whoever built the image did
not pass one.
The version says nothing about Stalwart, deliberately. It used to: 2.16.x had
16 for the 0.16 generation it targeted, which leaves nowhere to go once
Stalwart reaches 1.0 — 2.1 sorts below the 2.16 already deployed, so every
image and About screen would read as a downgrade. Which Stalwart a build needs is
stated where it can be precise, in the badge at the top of this file and in
KNOWN-ISSUES.md, rather than compressed into one digit.
The pull request lives after the +, as build metadata, because it is
provenance rather than a rank: at the rate they merge here it climbs without
bound and says nothing about how new a build is. Everything after the + is
ignored when versions are compared, which is the right reading — two builds from
the same day differ in where they came from, not in age. Nothing here depends on
that comparison: images are pruned oldest-first by creation time, and a rollback
names a git ref.
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 v2026.8.30 --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.






