**The login rate limiter could be sidestepped.** X-Forwarded-For is a list each hop appends to, and nginx's $proxy_add_x_forwarded_for appends ours — so a client sending "X-Forwarded-For: 1.2.3.4" arrives as "1.2.3.4, <their real address>". Reading the leftmost entry, as we did, handed the caller a rate-limit key they could change per request: unlimited password guessing against a deployment that looks correctly configured. Read from the right instead, skip hops that are themselves trusted proxies, and believe the header only when the peer is one (loopback and the private ranges by default, TRUSTED_PROXIES to be explicit). **The upload cap was a suggestion.** It read content-length, which a chunked request simply omits. Count the bytes through a stream, as the image proxy already does. **App password secrets were drawn with a modulo.** 256 is not a multiple of 33, so the first 25 characters of the alphabet came up on 8 byte values and the last 8 on only 7. Rejection sampling instead. The test weighs the whole tail of the alphabet rather than single characters, because a 7/8 skew is invisible per character against the noise — and it does fail when the bias is put back. **Upstream headers were relayed wholesale.** Anything the mail server set — cookies, auth challenges, CORS grants — landed on our origin, where it means something else. Allowlist what is actually wanted.
182 lines
18 KiB
Markdown
182 lines
18 KiB
Markdown
<p align="center">
|
||
<img src="web/public/img/logo.png" alt="ihasmail" width="180">
|
||
</p>
|
||
|
||
# ihasmail
|
||
|
||
**A fast, friendly, Gmail-class webmail for [Stalwart Mail Server](https://stalw.art) — built on JMAP, from the ground up.**
|
||
|
||
ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters and every other modern feature Stalwart exposes, 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.
|
||
|
||
> Status: 2.0 rewrite, in QA against a live Stalwart 0.15.5 server. The previous FastAPI/HTMX prototype has been removed entirely (only the logo survived).
|
||
|
||
## Screenshots
|
||
|
||
*All screenshots are taken against the built-in mock server (`npm run dev:mock`) with sample data — no real mailbox involved.*
|
||
|
||
| | |
|
||
| --- | --- |
|
||
| **Inbox & conversation view (dark)**  | **Inbox & conversation view (light)**  |
|
||
| **Reply composer** — identities, Reply-To, rich text, signature, quoted text  | **Calendar (month view)**  |
|
||
| **Contacts**  | **Sieve filter builder** — also reachable from a message's right-click menu  |
|
||
| **Sign-in**  | **Mobile layout** <img src="docs/screenshots/mobile.jpg" alt="Mobile" width="300"> |
|
||
|
||
## Features
|
||
|
||
**Mail**
|
||
- Gmail-style three-pane layout (reading pane right/bottom/off, **drag-to-resize splitter** in both orientations, quick layout switch in the list menu), conversation view with collapsed messages and "show quoted text", dense/cozy/comfortable density, light/dark/system theme with accent colours
|
||
- Virtualised, infinitely-scrolling message list; multi-select (click, ⇧-click, ⌃-click), drag & drop to folders, right-click context menus, hover actions, Gmail keyboard shortcuts (`j/k`, `e`, `#`, `r/a/f`, `g i`, `/`, `?` …)
|
||
- Archive / delete / spam / star / mark read / move / labels (IMAP keywords with colours) with **Undo**
|
||
- **"Filter messages like this…"** from the message context menu: creates a Sieve rule pre-filled from the sender/list (target folders can be created on the fly), and can **apply it immediately to the existing messages in the folder** (evaluated client-side, actions applied via JMAP)
|
||
- Safe HTML rendering: DOMPurify sanitisation inside a Shadow DOM, **remote images blocked by default** with a per-sender allow-list and an optional **privacy image proxy** (like Gmail's)
|
||
- Messages sit on a light card by default, untouched as the sender designed them. *Appearance › Apply the theme to messages too* lets them follow the app's light/dark theme instead — plain-text mail always does, and with the option on so does HTML mail that brings no colours of its own; mail that styles itself is still left alone
|
||
- Attachments: previews for images/PDF/text, download all, inline `cid:` images, `.eml` export, *Show original*, header viewer
|
||
- Invitations: `.ics` parts render as an invite card with **Yes/Maybe/No** RSVP (via `CalendarEvent/parse` + iTIP); `.vcf` parts offer *Add to contacts*; `List-Unsubscribe` one-click
|
||
- **Right-click anyone named in a message** — sender, To, Cc, Bcc, Reply-To — to add them to the address book (the contact editor opens prefilled, with the display name split into first/last), edit them if they are already known, write to them, or copy the address
|
||
- Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel
|
||
- Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, autosaved drafts, reply/reply-all/forward with quoting and inline images preserved
|
||
- Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge
|
||
- A–Z folder list with Inbox pinned on top (other special folders mixed in), subfolders nested and collapsed by default with chevrons in their own gutter so every icon lines up; unread folders are bold (a parent is bold when a subfolder has unread mail); right-click a folder to mark it read *including subfolders*, create/rename/hide/share/empty, quota bar, Outlook-style module bar (Mail · Calendar · Contacts · Files) at the bottom of the pane, multi-account switching for shared accounts
|
||
|
||
**Calendar** (JMAP Calendars / JSCalendar)
|
||
- Month / week / day / agenda views, mini calendar, multiple calendars with colours, show/hide, create/edit/share calendars
|
||
- Create events by click or drag, edit everything: all-day, time zones, recurrence (presets + custom rule builder), location, meeting link, description, reminders, status/privacy/free-busy, colour
|
||
- Attendees with invitations (`sendSchedulingMessages`), RSVP, and **free/busy lookup** via `Principal/getAvailability`
|
||
- **Right-click menus** on events (open, edit, duplicate, colour, category, delete) and on empty slots/days (new event here, go to day/week)
|
||
- **Outlook-style colour categories**: named colours managed in Settings, assigned from the context menu or editor; stored as JSCalendar `categories` (+ `color`) so they sync
|
||
|
||
**Contacts** (JMAP Contacts / JSContact)
|
||
- Address books (create/rename/share/default), contact list with search and letter index, full contact editor (names, emails, phones, addresses, org/title, birthday, website, notes, photo), **groups**, vCard import/export, compose-to-contact
|
||
|
||
**Files** (JMAP FileNode)
|
||
- Browse folders, upload (drag & drop), download, create folders, rename, move, delete
|
||
|
||
**Settings**
|
||
- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:AccountSettings/get`, falling back to `x:Account/get`), and from the browser where the server will not say; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `<input type="date">` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse
|
||
- **Self-service credentials** in Settings › Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Works against both Stalwart generations: the `x:AccountPassword` / `x:AppPassword` registry objects on 0.16+, and the `/api/account/auth` REST endpoint on 0.15.x (the latter confirmed live)
|
||
- **Light and dark** follow the system by default, with a toggle in the top bar for flipping between them and a three-way choice in Settings › Appearance
|
||
- Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings
|
||
|
||
**Platform**
|
||
- Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB
|
||
- **Default mail app**: register ihasmail as the browser's handler for `mailto:` links from Settings › General (`registerProtocolHandler`; needs HTTPS and a browser that supports it — Safari does not). Installed as an app it also declares `protocol_handlers` in the manifest, which is what lets the operating system offer ihasmail wherever it asks for a mail client. Links arrive with recipients, Cc, Bcc, subject and body filled in
|
||
- **About** reports the Stalwart generation ihasmail detected (0.16+ or older) and the edition where the server gives one. Stalwart does not publish a version number to clients, so no version is shown rather than a made-up one
|
||
- Security: no credentials in the browser (server-side session with per-session encrypted upstream credentials), httpOnly SameSite cookies, CSRF header + Sec-Fetch-Site checks, strict CSP, sandboxed blob downloads, SSRF-safe image proxy, login rate limiting, security headers
|
||
|
||
## 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 stores: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views` (mail, compose, calendar, contacts, files, settings), `src/lib` (sanitiser, search parser, Sieve codec, dates and locale-aware formatting, vCard, …).
|
||
- `server/` — tiny Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, stores the credentials sealed with a key derived from the cookie secret (the server never persists plaintext passwords), proxies JMAP/blob/SSE calls, serves the SPA with a strict CSP. Also contains `src/mock/` — an in-memory fake Stalwart for local development and demos.
|
||
|
||
Stalwart 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, for the account locale). Features degrade gracefully when a capability is missing.
|
||
|
||
## 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 (TOTP codes are supported via the "two-factor code" field, which Stalwart accepts as `password$code`).
|
||
|
||
## Development
|
||
|
||
Requirements: Node ≥ 20.10 (22 recommended), npm ≥ 10.
|
||
|
||
```bash
|
||
npm install
|
||
|
||
# against a real Stalwart (set STALWART_URL in .env or the environment)
|
||
npm run dev # server on :8080 (tsx watch) + Vite dev server on :5173 (proxying /api)
|
||
|
||
# against the built-in mock Stalwart ([email protected] / demo) — no real mailbox needed
|
||
npm run dev:mock # mock on :8788, server on :8080, Vite on :5173
|
||
|
||
# the same, with the mock impersonating Stalwart 0.15 instead of 0.16
|
||
npm run dev:mock:legacy
|
||
|
||
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).
|
||
|
||
### The mock, and which Stalwart it pretends to be
|
||
|
||
`npm run mock` impersonates **0.16** by default; `MOCK_STALWART=0.15` (or
|
||
`npm run mock:legacy`) impersonates the generation before the registry. The
|
||
older mode is not a smaller mock — it reproduces the specific ways that
|
||
generation differs, none of which the server reports as an error:
|
||
|
||
- `urn:stalwart:jmap` is not a capability it knows, and naming one it cannot
|
||
parse fails the **whole request**, not the one call that wanted it
|
||
- `x:` methods do not exist, so the registry — credentials, account settings —
|
||
is unreachable, and self-service credentials live at `POST /api/account/auth`
|
||
- `FileNode/query` masks its results to non-containers, so it returns files and
|
||
**never folders**, silently; `FileNode/get` has no such mask
|
||
- FileNode has no `nodeType` (a directory is a node with no file properties),
|
||
and rights are only `mayRead`/`mayWrite`/`mayShare`
|
||
|
||
Both modes enforce the 2047-**byte** cap on identity signatures. Every one of
|
||
these cost a live debugging session against a real 0.15.5 server, because the
|
||
0.16-shaped mock could not express them; `server/src/account-legacy.test.ts`
|
||
now pins them.
|
||
|
||
## Configuration
|
||
|
||
All configuration is via environment variables (see `.env.example`):
|
||
|
||
| Variable | Default | Description |
|
||
| --- | --- | --- |
|
||
| `STALWART_URL` | `https://mail.example.com` | Base URL of Stalwart; the JMAP session is discovered at `/.well-known/jmap` |
|
||
| `APP_SECRET` | *(required in production)* | Secret used to derive session encryption keys |
|
||
| `PORT` / `HOST` | `8080` / `0.0.0.0` | Listen address |
|
||
| `TRUST_PROXY` | `1` | Honour `X-Forwarded-*`, but only from a peer listed in `TRUSTED_PROXIES` |
|
||
| `TRUSTED_PROXIES` | *(loopback + private ranges)* | Comma-separated CIDRs or addresses whose forwarding headers are believed. Anything else is attributed by its socket address, whatever it claims |
|
||
| `SECURE_COOKIES` | `auto` | `auto` (Secure on https), `1`, or `0` for plain-HTTP dev |
|
||
| `SESSION_TTL` / `SESSION_REMEMBER_TTL` | `43200` / `2592000` | Idle session lifetime (seconds), with/without "keep me signed in" |
|
||
| `SESSION_FILE` | *(unset)* | Persist sessions across restarts (ciphertext only) |
|
||
| `IMAGE_PROXY` | `1` | Route remote images through the privacy proxy |
|
||
| `MAX_UPLOAD_BYTES` | `52428800` | Upload size limit (Stalwart has its own limit too) |
|
||
| `APP_NAME` | `ihasmail` | Branding |
|
||
|
||
## Keyboard shortcuts
|
||
|
||
Press `?` anywhere. Highlights: `c` compose · `/` search · `j`/`k` navigate · `o`/`Enter` open · `u` back · `e` archive · `#` delete · `!` spam · `s` star · `r`/`a`/`f` reply/reply-all/forward · `v` move · `l` label · `x` select · `⇧I`/`⇧U` read/unread · `g i` inbox · `g l` calendar · `g c` contacts · `Ctrl+Enter` send.
|
||
|
||
## Known issues / pending QA
|
||
|
||
Verified against the mock server, and against a live Stalwart 0.15.5 for the mail flows, self-service credentials, Files and signatures.
|
||
|
||
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
|
||
- **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap`, lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24).
|
||
- **Self-service credentials** — the **0.15.x REST path is confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 registry path has only been exercised against the mock**, which enforces the same rules a real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it) — it still wants a pass against a real 0.16 server. Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
|
||
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
|
||
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
|
||
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand.
|
||
|
||
## Roadmap / not yet
|
||
|
||
- Snooze and scheduled send (needs server-side support)
|
||
- Read-receipt (MDN) sending, S/MIME / OpenPGP
|
||
- Translations (strings are English-only for now)
|
||
|
||
## License
|
||
|
||
Copyright (C) 2026 LINUXexpert.org
|
||
|
||
ihasmail is free software: you can redistribute it and/or modify it under the
|
||
terms of the GNU General Public License as published by the Free Software
|
||
Foundation, either version 3 of the License, or (at your option) any later
|
||
version. See [LICENSE](LICENSE) for the full text.
|