Merge pull request #85 from LINUXexpert-org/docs/readme-slim
docs: slim the README, split out known issues and roadmap
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
# Known issues and pending QA
|
||||||
|
|
||||||
|
What was checked, against which server, and when. For a failure you are hitting
|
||||||
|
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
|
||||||
|
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
|
||||||
|
|
||||||
|
The live instance runs **0.16.19**, and as of **2026-08-26 there is nothing
|
||||||
|
left pending**: every entry below has been exercised against it. What remains
|
||||||
|
here is not a list of unknowns but of things worth knowing — where Stalwart
|
||||||
|
departs from a spec, where a setting has to be turned on for a feature to work,
|
||||||
|
and what ihasmail deliberately does not do.
|
||||||
|
|
||||||
|
Entries keep saying what was checked and when, because this section has been
|
||||||
|
wrong before: the 0.16 registry path was once recorded as verified live when a
|
||||||
|
capability looked for in the wrong place meant it had never run at all.
|
||||||
|
|
||||||
|
Some entries record what a live **0.15.5** proved before that server was
|
||||||
|
upgraded on 2026-08-25. They are kept where the finding is about ihasmail
|
||||||
|
rather than about 0.15 — a byte cap that still applies, a flow that still
|
||||||
|
works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||||
|
0.15 was removed on 2026-08-26; 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).
|
||||||
|
|
||||||
|
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
|
||||||
|
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
|
||||||
|
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
|
||||||
|
- **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.
|
||||||
|
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognise ([#54](https://github.com/LINUXexpert-org/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
|
||||||
|
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` now filter on `parentId`/`isTopLevel` alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
|
||||||
|
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). 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.
|
||||||
|
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a cancelled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
|
||||||
|
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch has to be aimed at the base event: `CalendarEvent/set` refuses a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
|
||||||
|
- 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. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<p align="center">
|
<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="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://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>
|
<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>
|
</p>
|
||||||
|
|
||||||
@@ -12,99 +13,56 @@
|
|||||||
|
|
||||||
**A fast, friendly, Gmail-class webmail for [Stalwart Mail Server](https://stalw.art) — built on JMAP, from the ground up.**
|
**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.
|
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
|
||||||
> Status: 2.0 rewrite, running against a live Stalwart **0.16.19** since
|
(plus Stalwart's blob/upload/EventSource endpoints) — no IMAP, no SMTP, no
|
||||||
> 2026-08-25, with the pending-QA list cleared on 2026-08-26. The previous
|
database.
|
||||||
> FastAPI/HTMX prototype
|
|
||||||
> has been removed entirely (only the logo survived, and it has since lost
|
|
||||||
> the `.com` wordmark it used to carry — ihasmail is the software, not the
|
|
||||||
> hosted instance).
|
|
||||||
|
|
||||||
**ihasmail requires Stalwart 0.16 or newer.** Sign-in refuses anything older,
|
|
||||||
by name, rather than letting Files and credentials fail separately with
|
|
||||||
nothing to connect them.
|
|
||||||
|
|
||||||
It used to support 0.15 as well. The two are less alike than the version
|
|
||||||
numbers suggest — 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 — and carrying both meant every call site had to
|
|
||||||
ask which one it was talking to. The cost was not the branches. It was that a
|
|
||||||
wrong answer had somewhere to fall back to, so it failed *quietly*: one
|
|
||||||
capability looked for in the wrong place downgraded every real 0.16 server to
|
|
||||||
the 0.15 path, and that reached production. With one supported generation a
|
|
||||||
wrong guess is a loud error on the first call instead.
|
|
||||||
|
|
||||||
The last release that runs on 0.15 is tagged
|
|
||||||
[`stalwart-0.15-support`](https://github.com/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support),
|
|
||||||
if you are on 0.15 and cannot upgrade yet.
|
|
||||||
|
|
||||||
The live instance was moved from 0.15.5 to 0.16.19 with
|
|
||||||
[stalwart-migrator](https://github.com/LINUXexpert-org/stalwart-migrator), a
|
|
||||||
companion project: an in-place upgrade tool that checkpoints every phase,
|
|
||||||
refuses to start on the things that cannot be fixed mid-migration, and
|
|
||||||
validates the server afterwards. The upgrade is genuinely treacherous by hand
|
|
||||||
— the store is migrated in place with no way back, and Stalwart's own
|
|
||||||
converter drops settings without saying so — and that migration took eight
|
|
||||||
seconds of downtime with nothing lost.
|
|
||||||
|
|
||||||
## 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)**  |
|
| 🌐 **[ihasmail.org](https://ihasmail.org)** | What it is, what it looks like, the full feature list |
|
||||||
| **Reply composer** — identities, Reply-To, rich text, signature, quoted text  | **Calendar (month view)**  |
|
| 📘 **[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/) |
|
||||||
| **Contacts**  | **Sieve filter builder** — also reachable from a message's right-click menu  |
|
| 🧪 **[KNOWN-ISSUES.md](KNOWN-ISSUES.md)** | What was verified live, and where Stalwart departs from a spec |
|
||||||
| **Sign-in**  | **Mobile layout** <img src="docs/screenshots/mobile.jpg" alt="Mobile" width="300"> |
|
| 🛣 **[ROADMAP.md](ROADMAP.md)** | What ihasmail does not do, and why |
|
||||||
|
|
||||||
## Features
|
This file is for people working *on* ihasmail. Everything about running it
|
||||||
|
lives in the docs.
|
||||||
|
|
||||||
**Mail**
|
## Screenshots
|
||||||
- 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 themes plus **ihasmail** — the palette from ihasmail.org, and what a new account starts on — each with accent colours over the top
|
|
||||||
- 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**
|
|
||||||
- **Delete all spam** — a banner across the top of Junk Mail, and an item in the folder's right-click menu and the list's own menu, that empties it in one action. The messages are destroyed rather than moved to Deleted Items, since routing spam through the bin leaves you with the same problem in another folder; the dialog says so before you commit, and there is no undo. Only Deleted Items and Junk Mail can be emptied this way — enforced in the store rather than merely hidden in the menus — and a folder that is already empty offers it greyed out
|
|
||||||
- **"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
|
|
||||||
- **Read receipts**: when a sender asks for one, the message offers to send it — a real RFC 8098 `multipart/report`, never automatically. Bulk mail, mailing lists and anything marked `Auto-Submitted` are not offered one at all, and a receipt aimed somewhere other than the sender says so before you send it. Sending is recorded with RFC 3503's `$mdnsent` keyword, so a second look — or another client — knows not to ask again
|
|
||||||
- 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**, **scheduled send** (quick picks or an exact date and time; the message waits in the server's queue, so it goes out whether or not ihasmail is open), 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
|
|
||||||
- **Notifications that arrive with ihasmail closed** — Web Push signed with VAPID ([RFC 9749](https://datatracker.ietf.org/doc/rfc9749/)), which Stalwart 0.16 supports and without which Chromium and Safari cannot receive push at all. Stalwart pushes straight to the browser's own push service: no relay, no extra service to run, and ihasmail's server is not in the delivery path. With [`emailpush`](https://datatracker.ietf.org/doc/draft-ietf-jmap-emailpush/) the payload carries the sender and subject, so the notification is useful without a round-trip — and the filter lives on the server, so spam never leaves it. The subscription is torn down on sign-out, because it belongs to the account rather than the session and would otherwise keep notifying a shared machine for a mailbox nobody is signed into. **"Closed" means ihasmail, not the browser:** Web Push is delivered over a connection the browser holds, so something of it has to be running. Confirmed on 0.16.19 (2026-08-26): with Chrome open and every ihasmail tab closed, a notification arrives immediately, naming the sender and subject. With Chrome fully quit and *Continue running background apps* off, nothing arrives until Chrome is started again, at which point the queued notification is delivered. Turning that setting on keeps a process alive and restores immediate delivery. Note the queue is not indefinite — a Web Push message carries a TTL, and one that expires before the browser returns is dropped rather than delivered late. None of this is ihasmail's to fix; it is what Web Push is on a desktop, and an installed PWA does not change it (on Android it would, since the push service can wake the browser from cold)
|
|
||||||
- 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)
|
*Taken against the built-in mock server (`npm run dev:mock`) with sample data — no real mailbox involved.*
|
||||||
- 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
|
| --- | --- |
|
||||||
|
| **Inbox & conversation (dark)**  | **Inbox & conversation (light)**  |
|
||||||
|
| **Composer**  | **Calendar**  |
|
||||||
|
| **Contacts**  | **Sieve filter builder**  |
|
||||||
|
|
||||||
**Files** (JMAP FileNode)
|
More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
|
||||||
- Browse folders, upload (drag & drop), download, create folders, rename, move, delete
|
|
||||||
|
|
||||||
**Settings**
|
## What's in it
|
||||||
- **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 and manage **app passwords** (a separate password per mail app or device, revocable on its own). Built on the `x:AccountPassword` / `x:AppPassword` registry objects. Turning **two-factor authentication** *on* is not offered here — ihasmail cannot sign in with a code yet, so enrolling would only lock the account out on its next sign-in. An account that already has 2FA on gets one control, to turn it off
|
|
||||||
- **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
|
|
||||||
- **Hide identities from the compose picker** — an account with alias domains can have every address twice over while only a handful are ever sent from, which makes the From picker unusable. Hiding is presentation only: the identity still exists, still receives, and stays listed and editable in Settings, the way an unsubscribed folder is still a folder. The identity a draft is already using and the default can never be hidden, and hiding every one of them offers them all again — a sender picker with nothing in it is worse than a cluttered one
|
|
||||||
- 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
|
|
||||||
- **Settings follow the account, not the browser**: they are kept in a `settings.json` in the account's own JMAP Files, so the default identity, locale, date and time formats, theme, labels, templates, folder colours and the rest are the same wherever you sign in — including a private window. ihasmail still stores nothing itself; the file lives in the mail store and is backed up with it. Settings that describe *this* screen or browser stay local, because syncing them would be wrong rather than helpful: list-pane sizes, density, font size, sidebar state, and the notification toggles (which track a permission the browser grants per-device). localStorage is kept as a cache so the first frame is already right, and the file corrects it a moment later
|
|
||||||
|
|
||||||
**Platform**
|
- **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
|
||||||
- Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB
|
- **Calendar** — JMAP Calendars / JSCalendar: month/week/day/agenda, recurrence, attendees and free-busy, colour categories
|
||||||
- **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
|
- **Contacts** — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export
|
||||||
- **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
|
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete
|
||||||
- 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
|
- **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.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -114,21 +72,14 @@ browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ─
|
|||||||
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
|
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, …).
|
- `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/` — 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.
|
- `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.
|
||||||
|
|
||||||
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 and to tell the generations apart). Features degrade gracefully when a capability is missing.
|
Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`,
|
||||||
|
`contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`),
|
||||||
## Quick start (Docker)
|
`quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own
|
||||||
|
`urn:stalwart:jmap` (read-only). Features degrade gracefully when one is
|
||||||
```bash
|
missing.
|
||||||
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 — its web interface is an OAuth client, which is why signing in *there* works — and it offers no password grant, so no client holding a username and password can exchange them plus a code for a token. The concatenated `password$code` form this README used to claim was accepted is not a route the server has. App passwords bypass TOTP and are Stalwart's own answer for clients like this one; ihasmail already relies on that, since turning 2FA on moves the current session onto an app password for exactly this reason.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -137,15 +88,9 @@ Requirements: Node ≥ 20.10 (22 recommended), npm ≥ 10.
|
|||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# against a real Stalwart (set STALWART_URL in .env or the environment)
|
npm run dev # real Stalwart (STALWART_URL in .env) — server :8080, Vite :5173
|
||||||
npm run dev # server on :8080 (tsx watch) + Vite dev server on :5173 (proxying /api)
|
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
|
||||||
# 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 advertising FUTURERELEASE but dropping every hold —
|
|
||||||
# the shape of a real server whose `futureRelease` setting was never turned on
|
|
||||||
npm run dev:mock:no-future-release
|
|
||||||
|
|
||||||
npm run typecheck # tsc for both packages
|
npm run typecheck # tsc for both packages
|
||||||
npm test # vitest (web) + node:test (server)
|
npm test # vitest (web) + node:test (server)
|
||||||
@@ -153,178 +98,71 @@ npm run build # web/dist + server/dist
|
|||||||
npm start # serve the production build
|
npm start # serve the production build
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:5173 in dev (or http://localhost:8080 for the production build).
|
Open http://localhost:5173 in dev, or http://localhost:8080 for the production
|
||||||
|
build. Running it for real is covered in
|
||||||
### Version numbers
|
[Installing](https://docs.ihasmail.org/install/) and
|
||||||
|
[Configuring](https://docs.ihasmail.org/configure/).
|
||||||
`ihasmail v2.16.57`, shown on the sign-in page, in Settings › About, and by `/api/health`:
|
|
||||||
|
|
||||||
| | |
|
|
||||||
| --- | --- |
|
|
||||||
| `2` | ihasmail's own major |
|
|
||||||
| `16` | the **Stalwart** generation this build targets — 0.16, the oldest it supports |
|
|
||||||
| `57` | the pull request the commit came from |
|
|
||||||
|
|
||||||
The first two live in the root `package.json`, so there is one place to bump
|
|
||||||
them; `16` becomes `17` when ihasmail moves to Stalwart 0.17. The third comes
|
|
||||||
from git at build time, because it does not exist until the pull request has
|
|
||||||
merged — a version committed to the tree would always be describing a merge
|
|
||||||
that had not happened yet, and every open branch would collide on the same
|
|
||||||
line. Nothing writes one back.
|
|
||||||
|
|
||||||
A commit that did not arrive through a pull request carries the last number
|
|
||||||
plus its own short SHA — `2.16.57+g1fa6578` — which says plainly that the build
|
|
||||||
is *past* that pull request rather than being it.
|
|
||||||
|
|
||||||
`node scripts/version.mjs` prints the version for the current checkout.
|
|
||||||
|
|
||||||
`.dockerignore` excludes `.git` deliberately, so an image build cannot work any
|
|
||||||
of this out for itself. Pass it in:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2.16 .
|
|
||||||
```
|
|
||||||
|
|
||||||
Left out, the build falls back to the base version from `package.json`
|
|
||||||
(`2.16.0`) rather than failing — so a version with no PR number on it means
|
|
||||||
whoever built the image did not pass one.
|
|
||||||
|
|
||||||
### The mock
|
### The mock
|
||||||
|
|
||||||
`npm run mock` is an in-memory fake Stalwart 0.16 — enough of JMAP to develop
|
An in-memory fake Stalwart 0.16 — enough JMAP to develop and demo against
|
||||||
and demo against without a real mailbox. It models the things a real server
|
without a real mailbox. It reproduces the things a naive fake would get wrong,
|
||||||
does that a naive fake would not, because each of these cost a live debugging
|
because each cost a live debugging session: `urn:stalwart:jmap` advertised
|
||||||
session to find:
|
**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.
|
||||||
|
|
||||||
- `urn:stalwart:jmap` is advertised **per-account**, in `primaryAccounts` and
|
### Version numbers
|
||||||
each account's `accountCapabilities`, and never in the session-level
|
|
||||||
`capabilities`. That is where Stalwart actually puts it, and a client that
|
`ihasmail v2.16.84` — `2` is ihasmail's own major, `16` the Stalwart generation
|
||||||
tests for it in the obvious place concludes it is talking to something far
|
this build targets, `84` the pull request the commit came from. The first two
|
||||||
older than it is. The mock used to advertise it in the wrong place, which is
|
live in the root `package.json`; the third comes from git at build time, since
|
||||||
exactly why nothing caught that bug
|
it does not exist until the PR has merged. A commit that did not arrive through
|
||||||
- identity signatures are capped at 2047 **bytes**, not characters
|
a PR carries the last number plus its short SHA — `2.16.84+g1fa6578`.
|
||||||
- `CalendarEvent/set` uses Stalwart's vocabulary, not RFC 8984's, and refuses
|
|
||||||
what the real server refuses — advertising the RFC spelling is how that one
|
```bash
|
||||||
reached a live server
|
node scripts/version.mjs # the version for the current checkout
|
||||||
- `MOCK_NO_FUTURE_RELEASE=1` (or `npm run mock:no-future-release`) advertises
|
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2.16 .
|
||||||
FUTURERELEASE and then drops every hold, which is the shape of a real server
|
```
|
||||||
whose `futureRelease` setting was never turned on
|
|
||||||
- `MOCK_NO_REGISTRY=1` omits the Stalwart capability, so the sign-in refusal
|
`.dockerignore` excludes `.git` deliberately, so an image build cannot work this
|
||||||
for unsupported servers can be tested. That is all it does — the rest still
|
out for itself — pass it in. Left out, the build falls back to the base version
|
||||||
behaves like 0.16. Emulating 0.15 properly went with the support for it
|
from `package.json`, so a version with no PR number means whoever built the
|
||||||
|
image did not pass one.
|
||||||
|
|
||||||
### Deploying
|
### Deploying
|
||||||
|
|
||||||
[`deploy.example.sh`](deploy.example.sh) is a single-host Docker deploy: it
|
[`deploy.example.sh`](deploy.example.sh) is a single-host Docker deploy: it
|
||||||
fetches, refuses anything held back, shows what is about to be introduced and
|
fetches, refuses anything held back by `.deploy-hold`, shows what is about to be
|
||||||
asks, rebuilds the image with the right version baked in, replaces the
|
introduced and asks, rebuilds with the right version baked in, replaces the
|
||||||
container and waits for it to report healthy. Copy it, or run it as-is and set
|
container, waits for healthy, then prunes all but the newest
|
||||||
what differs in the environment — `IHASMAIL_APP`, `IHASMAIL_ENV`,
|
`IHASMAIL_KEEP_VERSIONS` images — never the one actually running.
|
||||||
`IHASMAIL_NAME`, `IHASMAIL_BIND`, `IHASMAIL_VOLUME`, `IHASMAIL_IMAGE`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy.sh # origin/main, asks before shipping new commits
|
./deploy.sh # origin/main, asks before shipping new commits
|
||||||
./deploy.sh --dry-run # run the guards and stop
|
./deploy.sh --dry-run # run the guards and stop
|
||||||
./deploy.sh v2.16.57 --yes # a named ref, no prompt (there is no tty over ssh)
|
./deploy.sh v2.16.84 --yes # a named ref, no prompt (there is no tty over ssh)
|
||||||
```
|
```
|
||||||
|
|
||||||
Two guards, because a deploy script is exactly where a careless run does the
|
`--yes` does not override a hold; clearing one means deleting its line.
|
||||||
most damage. `.deploy-hold` lists commits that must not reach production yet,
|
|
||||||
one per line, and a target carrying one that production does not already have
|
|
||||||
is refused outright — `--yes` does not override it, and clearing a hold means
|
|
||||||
deleting its line. Separately, anything introducing new commits is listed and
|
|
||||||
has to be confirmed; over SSH, with no terminal to answer on, that means
|
|
||||||
passing `--yes` deliberately rather than a bare run shipping whatever `main`
|
|
||||||
has picked up since.
|
|
||||||
|
|
||||||
Each build is tagged with its own version as well as `:current`, so rolling
|
## Contributing
|
||||||
back is running the previous tag rather than rebuilding it. The environment
|
|
||||||
file is never read by the script, only handed to `docker run --env-file`.
|
|
||||||
|
|
||||||
Those images add up — roughly 650 MB each, one per deploy, and `docker image
|
[CONTRIBUTING.md](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
|
||||||
prune` will not touch them because they are tagged. After a deploy reports
|
[SECURITY.md](SECURITY.md) — please report vulnerabilities privately.
|
||||||
healthy, the script removes all but the newest `IHASMAIL_KEEP_VERSIONS` of them
|
|
||||||
(three by default; `0` keeps every version). It never removes the image the
|
|
||||||
container is actually running, which matters after a rollback, when that is an
|
|
||||||
old one — and it prunes only *after* the health check, so a rollback target is
|
|
||||||
never dropped while the thing replacing it is still unproven.
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
The live instance runs **0.16.19**, and as of **2026-08-26 there is nothing
|
|
||||||
left pending**: every entry below has been exercised against it. What remains
|
|
||||||
here is not a list of unknowns but of things worth knowing — where Stalwart
|
|
||||||
departs from a spec, where a setting has to be turned on for a feature to work,
|
|
||||||
and what ihasmail deliberately does not do.
|
|
||||||
|
|
||||||
Entries keep saying what was checked and when, because this section has been
|
|
||||||
wrong before: the 0.16 registry path was once recorded as verified live when a
|
|
||||||
capability looked for in the wrong place meant it had never run at all.
|
|
||||||
|
|
||||||
Some entries record what a live **0.15.5** proved before that server was
|
|
||||||
upgraded on 2026-08-25. They are kept where the finding is about ihasmail
|
|
||||||
rather than about 0.15 — a byte cap that still applies, a flow that still
|
|
||||||
works the same way — and dropped where 0.15 was the whole subject. Support for
|
|
||||||
0.15 was removed on 2026-08-26; 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).
|
|
||||||
|
|
||||||
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
|
|
||||||
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
|
|
||||||
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
|
|
||||||
- **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.
|
|
||||||
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognise ([#54](https://github.com/LINUXexpert-org/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
|
|
||||||
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` now filter on `parentId`/`isTopLevel` alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
|
|
||||||
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). 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.
|
|
||||||
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a cancelled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
|
|
||||||
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch has to be aimed at the base event: `CalendarEvent/set` refuses a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
|
|
||||||
- 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. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
|
|
||||||
|
|
||||||
## Roadmap / not yet
|
|
||||||
|
|
||||||
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
|
|
||||||
- Translations (strings are English-only for now)
|
|
||||||
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see Quick start), and Settings › Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Reported as [#75](https://github.com/LINUXexpert-org/ihasmail/issues/75)
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright (C) 2026 LINUXexpert.org
|
Copyright (C) 2026 LINUXexpert.org — AGPL-3.0-or-later. See
|
||||||
|
[LICENSE](LICENSE).
|
||||||
|
|
||||||
ihasmail is free software: you can redistribute it and/or modify it under the
|
ihasmail was relicensed from GPL-3.0 to AGPL-3.0 on 2026-08-25: webmail is
|
||||||
terms of the GNU Affero General Public License as published by the Free
|
nearly always run as a network service rather than handed to anyone as a binary,
|
||||||
Software Foundation, either version 3 of the License, or (at your option) any
|
and the AGPL's section 13 closes that gap.
|
||||||
later version. See [LICENSE](LICENSE) for the full text.
|
|
||||||
|
|
||||||
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: anyone running a modified
|
|
||||||
ihasmail for other people has to offer them its source, which the GPL alone
|
|
||||||
does not require.
|
|
||||||
|
|
||||||
That offer has to point at *your* source, not this one. If you run a modified
|
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
|
ihasmail, set `SOURCE_URL` to your own repository — the sign-in page and
|
||||||
Settings › About both show it, so the people using your instance are told where
|
Settings › About both show it. See
|
||||||
the code they are actually running can be found.
|
[Rebranding](https://docs.ihasmail.org/rebranding/).
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Roadmap / not yet
|
||||||
|
|
||||||
|
Things ihasmail does not do, and why. Anything with an issue number is tracked
|
||||||
|
in [the issue tracker](https://github.com/LINUXexpert-org/ihasmail/issues); the
|
||||||
|
rest is here because the answer is "no", not "not yet".
|
||||||
|
|
||||||
|
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
|
||||||
|
|
||||||
|
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
|
||||||
|
- Translations (strings are English-only for now)
|
||||||
|
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Installing](https://docs.ihasmail.org/install/#checking-it-came-up)), and Settings › Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Reported as [#75](https://github.com/LINUXexpert-org/ihasmail/issues/75)
|
||||||
Reference in New Issue
Block a user