Files
ihasmail-inbuxa/README.md
T
jcoffey-dev f72c67864e Let the container run with nothing writable
The server writes to one path and no other: SESSION_FILE, from sessions.ts.
Everything else it touches on disk it only reads. So a container with a
read-only root filesystem already works -- except that `VOLUME ["/data"]`
quietly undid it. Docker acts on that directive: a container started without
`-v` gets an anonymous volume mounted there anyway, writable even under
`--read-only`. It persisted nothing across a redeploy, since each new container
got a fresh empty volume, and it left an orphan behind every time one was
replaced. Deployments that want the sessions to survive already say so
themselves -- docker-compose.yml and deploy.example.sh both mount a named
volume -- so removing the line changes nothing for them.

IMMUTABLE=1 asserts that this is how the instance is running. It is checked
rather than believed: the server refuses to start if SESSION_FILE is still set,
or if the filesystem it is installed on turns out to be writable. Left
unchecked the misconfiguration is silent, because persisting sessions is
best-effort -- a read-only /data costs one warning at the first sign-in and
nothing more until the instance is replaced and everyone is signed out.

SessionBackend names what the rest of the server asks of a session store, and
`sessions` in app.ts is typed as it. Nothing changes today; SessionStore is
still the only implementation. It is there so the OAuth work is written against
the interface rather than the class, and so the interface can record which of
its methods a stateless backend could satisfy alone: create, resolve, reseal
and destroy each touch one session, while listForUser and destroyAllForUser
have to reach sessions other than the caller's. The second of those carries the
guarantee that changing a password invalidates the sessions still holding the
old one, which is why it needs a registry -- Stalwart's token registry, once
sign-in goes through OAuth.
2026-08-27 21:48:23 -07:00

211 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<p align="center">
<img src="web/public/img/logo.png" alt="ihasmail" width="150">
</p>
<p align="center">
<a href="LICENSE"><img alt="Licence: AGPL-3.0-or-later" src="https://img.shields.io/badge/licence-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.19" src="https://img.shields.io/badge/Stalwart-0.16.19-6366f1?style=flat-square"></a>
<a href="https://docs.ihasmail.org" target="_blank" rel="noreferrer"><img alt="Documentation: docs.ihasmail.org" src="https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9?style=flat-square"></a>
<a href="https://linuxexpert.org" target="_blank" rel="noreferrer"><img alt="by LINUXexpert.org" src="https://img.shields.io/badge/by-LINUXexpert.org-0f766e?style=flat-square"></a>
</p>
# ihasmail
**A fast, friendly, Gmail-class webmail for [Stalwart Mail Server](https://stalw.art) — 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](https://ihasmail.org)** | What it is, what it looks like, the full feature list |
| 📘 **[docs.ihasmail.org](https://docs.ihasmail.org)** | [Installing](https://docs.ihasmail.org/install/) · [Configuring](https://docs.ihasmail.org/configure/) · [Using it](https://docs.ihasmail.org/using/) · [Shortcuts](https://docs.ihasmail.org/shortcuts/) · [Rebranding](https://docs.ihasmail.org/rebranding/) · [Troubleshooting](https://docs.ihasmail.org/troubleshooting/) |
| 🧪 **[KNOWN-ISSUES.md](KNOWN-ISSUES.md)** | What was verified live, and where Stalwart departs from a spec |
| 🛣 **[ROADMAP.md](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, dark theme](docs/screenshots/inbox-dark.jpg) | **Inbox & conversation (light)** ![Inbox, light theme](docs/screenshots/inbox-light.jpg) |
| **Composer** ![Composer](docs/screenshots/compose.jpg) | **Calendar** ![Calendar](docs/screenshots/calendar.jpg) |
| **Contacts** ![Contacts](docs/screenshots/contacts.jpg) | **Sieve filter builder** ![Filters](docs/screenshots/filters.jpg) |
More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
## 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.json` in 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](https://ihasmail.org/#features); how to
drive each one is in [Using ihasmail](https://docs.ihasmail.org/using/).
## 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`](https://github.com/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
- Upgrading? [stalwart-migrator](https://github.com/LINUXexpert-org/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)
```bash
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](https://docs.ihasmail.org/install/) ·
[Configuring](https://docs.ihasmail.org/configure/).
### 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:
```bash
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](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.
```bash
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](https://docs.ihasmail.org/install/) and
[Configuring](https://docs.ihasmail.org/configure/).
### 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`.
```bash
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`](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.
```bash
./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](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
[SECURITY.md](SECURITY.md) — please report vulnerabilities privately.
## License
Copyright (C) 2026 LINUXexpert.org — AGPL-3.0-or-later. See
[LICENSE](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](https://docs.ihasmail.org/rebranding/).