Files had a breadcrumb and a Move to… dialog. Moving anything meant
opening a dialog and walking down the folder you wanted, which is a lot
of ceremony for something every file manager does by dragging, and there
was nowhere to see the shape of the account at all.
There is now a folder tree in the sidebar, beside the mailbox tree it
borrows its look from. Rows in the list and folders in the tree can be
dragged onto any folder in either, and folders dropped from outside are
uploaded with their structure intact.
The tree arrives in a single query. `filter: { nodeType: "directory" }`
returns every folder in the account -- checked against 0.16.19 on
2026-08-27 -- so nothing waits on an expand, and a drag knows every
folder it could land on including ones nobody has opened. It is
deliberately its own request: a filter Stalwart refuses fails with a
request-level 400 that takes every method call in the request with it,
which `{ parentId: null }` does, so a per-level query batched alongside
the listing would blank the whole view rather than just the sidebar.
Two things the writing of this turned up.
The mock ignored the `nodeType` filter the live server applies, so the
tree asked for directories, was handed files as well, and drew them as
folders you could open into nothing. The mock now filters the way 0.16.19
does. The store also filters again on the way in, because a tree that
believes whatever a server sends is a tree that draws files as folders on
the next server that gets this wrong.
And the drag state was per-pane, which cannot work: a drag that starts in
the list has to be recognised by the tree, and the pane that did not
start it never lit up or accepted the drop. Dropping still worked, since
the drop handler re-checks from the drag itself -- which is why this
would have shipped looking fine and been unusable. It lives in the store
now, with the reason written down.
Dropping a folder in goes through `webkitGetAsEntry`, which is
non-standard in name and universal in practice. Its `readEntries` returns
*up to* some entries per call and signals the end with an empty array, so
a single read loses everything past the first batch. Both bounds in there
-- depth, and entries per directory -- exist because a directory tree
from outside the app is not something to take on trust; the test that
covers the second one found the version without it looping for ever.
Verified against the mock: a row dragged onto a folder in the tree lights
the target, is accepted, and moves it on the server; a top-level folder
dragged to All files is refused as the no-op it is; the tree's own menu
creates, renames, shares and deletes; and the tree lists folders only.
ihasmail
A fast, friendly, Gmail-class webmail for Stalwart Mail Server — built on JMAP, from the ground up.
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.
| 🌐 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 - 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.
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 LINUXexpert.org — 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.






