diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..61232c7 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,856 @@ +# Features + +Everything ihasmail does, in one place, at the level of detail someone +evaluating it or working on it actually needs. + +This is the inventory. Three files sit beside it and answer different +questions: + +| | | +| --- | --- | +| [ROADMAP.md](ROADMAP.md) | What ihasmail deliberately does **not** do, and why | +| [KNOWN-ISSUES.md](KNOWN-ISSUES.md) | What was verified live, and where Stalwart departs from a spec | +| [docs.ihasmail.org](https://docs.ihasmail.org) | How to install, configure and drive each of these | + +Written against the tree at Stalwart **0.16.20**, which is the version the live +instance runs and the one every behaviour below was checked against. ihasmail +requires 0.16 or newer and refuses older servers at sign-in, by name. + +## The shape of it + +ihasmail is a single-page React app plus a small Node server that speaks JMAP +to Stalwart on the browser's behalf. There is no IMAP, no SMTP, no database, no +search index and no cache tier. Every durable thing — mail, calendars, +contacts, files, filters, and ihasmail's own settings — lives in the mail store. +The container is disposable, and with `IMMUTABLE=1` it has nothing writable at +all. + +That constraint decides most of what follows. Where a feature looks unusual, +it is usually because the obvious implementation would have required ihasmail +to keep something of its own. + +### Capabilities, and what happens without them + +Features are gated on what the server advertises, one by one, and a missing +capability removes its feature rather than breaking the app. + +| Capability | Powers | Missing | +| --- | --- | --- | +| `urn:ietf:params:jmap:core` | Everything | Nothing works; sign-in fails | +| `urn:ietf:params:jmap:mail` | Mail, folders, labels, search, drafts | No mail views | +| `urn:ietf:params:jmap:submission` | Sending | Compose is read/save only | +| `urn:ietf:params:jmap:submission` + `futureRelease` (per-account) | Scheduled send | The clock button is not offered | +| `urn:ietf:params:jmap:vacationresponse` | Out of office | The Settings section hides | +| `urn:ietf:params:jmap:sieve` | Filters, visual and raw | Filters hides | +| `urn:ietf:params:jmap:contacts` (+`:parse`) | Contacts; vCard import | Contacts hides; import only needs `parse` | +| `urn:ietf:params:jmap:calendars` (+`:parse`) | Calendar; iTIP invitations in mail | Calendar hides; invite cards do not render | +| `urn:ietf:params:jmap:principals` | Directory lookup, sharing pickers | Sharing and directory autocomplete step aside | +| `urn:ietf:params:jmap:principals:availability` | Free/busy when scheduling | Guests show no availability | +| `urn:ietf:params:jmap:quota` | Storage bar under the folder list | The bar is not drawn | +| `urn:ietf:params:jmap:blob` | Attachments, message source, vCards, signature images | Downloads and uploads degrade | +| `urn:ietf:params:jmap:filenode` | Files, attach-from-Files, **synced settings** | Files hides; settings fall back to this browser | +| `urn:ietf:params:jmap:webpush-vapid` | Notifications with ihasmail closed | Only in-tab notifications | +| `urn:ietf:params:jmap:emailpush` | Sender and subject inside a push payload | Push says "new mail" and nothing more | +| `urn:stalwart:jmap` (per-account) | Password change, app passwords, 2FA state | **Sign-in is refused** — this is the 0.16 check | +| EventSource push | Live updates | Falls back to polling | + +`urn:stalwart:jmap` is advertised per **account**, not session-wide, and the +submission capability keeps `futureRelease` in the same place. Both are read +out of `accountCapabilities`; reading them at the top level finds an empty +object and silently disables the feature, which is why the mock reproduces the +per-account shape. + +--- + +# Mail + +## Layout + +Three panes: folder tree, message list, reading pane. The splitter between the +list and the reading pane is dragged to resize, and the size is remembered per +device — a width chosen on a 27" monitor is wrong on a laptop, so it is one of +the few settings that does not follow the account. + +- **Reading pane** right of the list, below it, or off (messages open full width). +- **Density** comfortable, cozy or compact, which changes row height as well as padding. +- **Font size** small, medium or large. +- **Sidebar** collapsible to icons; a drawer on mobile. +- **Mobile layout** with a bottom tab bar, full-screen composer and full-screen reading. + +## The message list + +- **Virtualised** — rows are windowed with `@tanstack/react-virtual`, so a + folder of 100,000 messages scrolls at the same speed as one of ten. Row + height follows density and the one- or two-line layout. +- **Infinite scroll** with server-side paging, 50 at a time by default. +- **Conversation view** groups a thread into one row, with the thread's own + message count; it can be switched off to list messages individually. +- **Multi-select** with `x`, shift-click for ranges, `Ctrl/Cmd+A` for all. +- **Drag and drop** onto any folder in the tree, moving the selection or the + row under the cursor. +- **Context menu** on any row: reply, forward, archive, delete, spam, read/unread, + star, move to…, label…, and *Filter messages like this…*. +- **Snippets and avatars** are optional; the star is always in the row. +- **Skeleton rows** while a page loads, rather than an empty pane. + +### Actions, and Undo + +Archive, delete, spam, star, mark read/unread, move and label all offer **Undo** +in the toast that follows, and the undo restores the previous state rather than +guessing at an inverse. + +`Delete` moves to the bin. **Empty** destroys, and is offered only on Deleted +Items and Junk Mail — enforced where the action happens, not merely hidden in +the menu. Emptying Junk destroys rather than moving to the bin, because routing +spam through the bin leaves the same problem in another folder. There is no undo +for that one, and the dialog says so. + +## Folders + +Real JMAP mailboxes, with the server's roles honoured. + +- Create, rename, create a subfolder, delete (with or without its mail). +- **Drag a folder onto another** to reparent it. Folders with a server role + (Inbox, Sent, Drafts, Trash, Junk, Archive) are structural and are not + offered the drag, because the server refuses to move them anyway. +- **Subscribe / unsubscribe** — *Show in list* / *Hide from list*. An + unsubscribed folder still exists and still receives; it is just out of the + way. Inbox cannot be hidden. +- **Mark all as read**, optionally including subfolders. +- **Folder colours**, per mailbox id. +- **Unread counts** per folder, live. +- **Storage quota** bar under the tree where the server reports one. +- Rights are respected per folder: rename, delete, create-child and share each + grey out when `myRights` says no. +- A folder in the address that this account does not have says *this folder is + missing*, rather than drawing an empty folder — a stale link should not read + as a folder that emptied itself. + +## Labels + +Labels are **IMAP keywords** with a colour and a display name kept in settings. +Because they are keywords, every other client that reads the mailbox sees them, +and they survive ihasmail entirely. A message can carry any number. They are +managed in Settings › Labels, applied from `l` or the context menu, and +optionally listed in the sidebar. + +## Search + +The query runs on the **server**, over the whole mailbox, not over the part the +browser happens to have loaded. Gmail operators work as written. + +| Operator | Notes | +| --- | --- | +| `from:` `to:` `cc:` | Address or name substring | +| `subject:` `body:` | | +| `has:attachment` | | +| `has:star` `has:flag` `is:starred` `is:flagged` | | +| `is:unread` `is:read` | | +| `in:` `folder:` | By role (`inbox`, `sent`, `spam`, `starred`), then by exact name, then by substring. `in:anywhere` / `in:all` searches everything | +| `label:` `keyword:` | Repeatable; `-label:` excludes | +| `before:` `older:` `older_than:` | | +| `after:` `since:` `newer:` `newer_than:` | | +| `larger:` `size:` `smaller:` | `500k`, `5m`, `2g`, or bare bytes | + +Dates parse as `2025-11-22`, `2025/11/22`, `11/22/2025`, anything `Date` accepts, +or relative: `3d`, `2w`, `6m`, `1y`. Quoted phrases hold together, including +inside an operator (`subject:"quarterly report"`). Bare words become full-text +terms. With no `in:`, the search is scoped to the folder being viewed. + +An **advanced panel** behind the magnifier offers From, To, Subject, Has the +words, folder, date range, has-attachment and unread-only, and composes the +same query string — so what it builds can be read, edited and learned from. + +## Reading a message + +- **Sanitised HTML**, rendered inside a **Shadow DOM** so the sender's CSS + cannot reach the app. DOMPurify strips scripts, event handlers, forms and + anything that could navigate the top window. +- **Remote images blocked by default**, with a banner offering *Show images* or + *Always from this sender*. The per-sender allow-list lives in settings and so + follows the account. Policy is one of ask (default), automatic for people in + your contacts, or always. +- **Privacy image proxy** (on by default): approved remote images are fetched by + ihasmail's server, so the sender learns nothing about the reader — no IP + address, no user agent, no read time. With the proxy off, images load + directly and the sender learns all three; the docs say so plainly. +- **Inline images** (`cid:`) are resolved against the message's own parts. +- **Attachments** listed with type and size: download, open in a new tab, and an + inline preview for images and PDFs. +- **Show original**, **Show headers**, **Download (.eml)** and **Print**. +- **Unsubscribe** where the message carries `List-Unsubscribe`. +- **Sender details** expand to the full From/To/Cc/Reply-To with addresses. +- **Message body theming** is off by default — sender HTML is left exactly as it + was designed, on a light card. One setting lets mail that brings no colours of + its own follow the app's theme instead. + +### Conversations + +- A conversation opens on its **first unread message**, not the newest, so + unread mail is never above the fold with only a marker to hint at it. +- The opening scroll is held until the thread settles, so the reading position + does not jump as messages measure themselves. +- `n` / `p` move between messages in the thread; `]` archives and opens the + next conversation. +- **Auto-advance** after archive or delete: back to the list (default), or on to + the next or previous conversation. +- **Mark as read** immediately, after 2s, after 5s, or never automatically. + +### Cards inside a message + +- **Invitations (iTIP)** render an invite card: what, when, where, the guest + list with each person's status, and Yes / Maybe / No. The reply is written to + the event and sent back to the organiser. Cancellations are recognised too. +- **vCard attachments** render a card offering to add the person to an address + book. +- **Right-click anyone named** in the message — From, To, Cc, Bcc or Reply-To — + to add them to contacts with the editor prefilled and the display name split + into first and last, edit them if already known, write to them, or copy the + address. + +### Read receipts (MDN, RFC 8098) + +Stalwart does not implement JMAP's `MDN/send`, so ihasmail assembles the +`multipart/report` itself, uploads it, imports it and submits it like any other +message — which is why a sent receipt lands in Sent. + +Nothing is ever sent automatically, and there is deliberately **no "always" +setting**: a receipt confirms to whoever asked that the address is live and when +it was read, to an address of their choosing. The rules: + +- Bulk mail, mailing lists and anything marked `Auto-Submitted` are not offered + a receipt at all. +- A receipt aimed somewhere other than the sender says so before it is sent. +- Sending is recorded with `$mdnsent`, so a second look — or another client — + knows not to ask again. +- The setting offers *ask me each time* or *never*. + +Requesting one on your own outgoing mail is a separate switch. + +## Composing + +**Multiple composers at once**, floating in a dock at the bottom right, each +minimisable and maximisable; full-screen on mobile. + +- **Rich text**: bold, italic, underline, strikethrough, text colour, highlight, + font size, alignment, bulleted and numbered lists, indent/outdent, blockquote, + code block, links (`Ctrl+K`), inline images, an emoji picker, and remove + formatting. Tab and Shift+Tab indent inside the body. +- **Plain text** as a per-message or default format. +- **Recipient chips** with autocomplete from contacts, shared address books you + have added, the server directory and recent recipients; your own cards win a + tie against a colleague's copy of the same person. Free-form addresses parse + leniently (`Ann , bob@y; "C, D" `). +- **Recipient picker** — the contacts button beside Cc/Bcc, or the To label — + opens the address books to search across every book or one, tick as many + people as needed and send them to To, Cc or Bcc. Every address gets its own + row, so somebody with a work address and a personal one is a choice. +- **Cc, Bcc and Reply-To** revealed as needed. +- **Priority**. +- **Identities**: multiple From addresses, a per-account default that ihasmail + keeps (JMAP has no such flag), and hiding identities from the picker without + deleting them — an account with alias domains can have every local part twice + over while only a handful are ever used. +- **Signatures** in HTML per identity, inserted above or below the quote. + Stalwart caps an identity signature at 2047 **bytes** of UTF-8, so ihasmail + compacts the HTML, and where it still will not fit, stores the full signature + in the account's Files and leaves a marker plus a plain-text fallback in the + identity. Signature images live in Files too and are turned into inline + `cid:` parts when the message is sent. +- **Templates**: named subject + body, inserted into any draft, managed in + Settings. +- **Attachments** by picking or dragging onto the composer, with progress per + file and the size limit the server states (`MAX_UPLOAD_BYTES`, 50 MB by + default). A pasted image is inserted inline instead, and pasted HTML is + sanitised on the way in. +- **Attach from Files** — anything the server already holds attaches with **no + upload at all**, however large. A file from someone else's shared folder is + copied to your account first, because a message can only carry blobs from the + account sending it; the picker says so before it does. +- **Attachment reminder** when the text mentions an attachment and none is there. +- **Spell check** toggle. +- **Drafts** save as you type and on close, with the save state shown. +- **Quoting** on reply, with the signature placed above or below it, and + reply-all as an optional default. +- **Send and archive**, and **archive on reply**, as options. + +### Undo send, and scheduled send + +Two different mechanisms, deliberately. + +**Undo send** holds the message in the browser for 0/5/8/15/30 seconds (8 by +default) and shows a toast with a way back. Nothing has been submitted yet. + +**Scheduled send** hands the message to *Stalwart's* queue. JMAP has no +client-settable `sendAt` — RFC 8621 makes it server-derived — so the hold is +requested through SMTP FUTURERELEASE (RFC 4865) as a `HOLDUNTIL` parameter on +the envelope, and the server reports back the `sendAt` it settled on. It goes +out whether or not ihasmail is open, or ever opened again. + +Held messages wait in a **Scheduled** folder ihasmail maintains itself (JMAP has +no role for one), and reconciles when you next open it: released messages move +to Sent, cancelled ones back to Drafts. The picker offers presets and an exact +date and time, bounded by the maximum delay the server advertises. + +> If Stalwart's `futureRelease` is not configured, a "scheduled" message is sent +> **immediately**, with no error and no sign the hold was dropped. ihasmail only +> offers the feature when the account advertises the capability, and the mock +> has a switch (`MOCK_NO_FUTURE_RELEASE=1`) that advertises it and then drops +> every hold, so the failure can be developed against. + +--- + +# Calendar + +JMAP Calendars and JSCalendar (RFC 8984), with Stalwart's vocabulary where it +differs from the RFC. + +## Views + +Month, week, day and agenda, each addressable by URL (`/calendar/week/2026-08-30`). +A mini calendar for jumping, *Today*, and next/previous by keyboard (`n`/`p`) or +button. The default view, week start, working hours and default event duration +are settings. + +## Calendars + +The sidebar keeps three groups apart: + +- **My calendars** — yours, each with a colour, each hideable with a click. +- **Shared with me** — other people's, once added. +- **Available to add** — shared with you but not yet added, with a plus beside + each. An unadded calendar draws nothing. This is deliberate: the server + reports every collection in an account you can reach, whether or not anyone + meant to share it, so being handed one is not evidence that it was offered. + +Right-click your own to rename, recolour, share, stop sharing or delete; +right-click one of someone else's to remove it from your view, which changes +nothing for anybody else. + +## Events + +Created by clicking an empty slot or dragging across a range; a context menu on +empty space offers a timed or all-day event at that moment, or *Go to day* / +*Go to week*. + +The editor covers title, start and end (all-day or timed, with a time zone), +calendar, location, meeting link, guests, description, reminders, repeat, +status (confirmed / tentative / cancelled), show-as (busy / free), visibility +(default / private / secret), category and colour. + +- **Recurrence** — none, daily, weekly, weekdays, monthly, yearly, or a custom + builder: interval, by-weekday, by-month-day, and an end by count or by date. +- **Reminders** — one or more alerts before the start, with a default in settings. +- **Colour categories**, Outlook-style: named colours managed in Settings › + Calendar, assigned from the editor or the context menu, and stored as + JSCalendar `categories` so other clients see them. (The per-event colour + picker that predated them is gone; a colour comes from the category, or the + calendar.) +- **Duplicate** an event from the context menu. +- **Popover** on click with the detail and quick actions; the editor on + *Edit…*. + +## Attendees, invitations and free/busy + +Invitations go out as iTIP when guests are added, replies come back and are +applied to the event, and cancelling notifies the guests. Guests are added by +name or address with the same autocomplete the composer uses. Where the server +implements `Principal/getAvailability`, each guest's busy periods are looked up +for the day being scheduled. + +## Recurring events: series and single occurrence + +Editing or deleting a recurring event asks which it applies to, and both +answers work: the **whole series**, or **this occurrence only** (written as a +`recurrenceOverrides` entry). + +Two things about that are worth stating, because they are the reason it took +work: + +- **Not everything can differ per occurrence.** 0.16.20 sorts properties into + three groups, and only one is honest: some are *rejected* loudly; some are + *inherited* — dropped from the patch while the response still reports + success; the rest are applied. ihasmail checks the patch before sending it, so + a rejected property is an error you can see and an inherited one is reported + as something it could not do for one date, rather than claimed as saved. +- **Occurrence ids are not stable across a write.** Stalwart's synthetic ids + encode a position in the expanded series, and writing an override renumbers + them — confirmed live on 0.16.20: after one override, the same five ids + addressed a different five dates. So an occurrence is re-resolved from its + `recurrenceId` (the date itself) immediately before it is touched, and a + vanished date says so rather than acting on an id that now means something + else. + +*This and future* is not offered: the server refuses an occurrence that belongs +to such a change, and where it does, ihasmail says so and offers the series. + +Events are edited in the editor rather than dragged around the grid. + +--- + +# Contacts + +JMAP Contacts and JSContact. + +- **Address books**, yours under *My address books* and other people's plainly + separate below, with the same *Available to add* / *Shared with me* split the + calendar uses. Create, rename, share, stop sharing, delete; one is the default + for new cards. +- **Contact records**: photo, prefix, first, middle, last, suffix, nickname, + company, job title, any number of emails, phones and addresses with types, + birthday, website and notes. +- **Groups** as a card kind, with members picked from the book. +- **Letter index** down the list, with `#` for everything that does not start + with a letter. +- **Search** across name, address, organisation and notes, in one book or all. +- **vCard import** through `ContactCard/parse` (a file of any number of cards), + and **export** of one card or the whole book as `.vcf`. +- **Directory lookup** through `Principal/query`, so colleagues on the server + can be addressed without being in an address book first. +- **Recent recipients**, kept on the device — and only on a device you said was + yours. +- Contacts in a shared book you have added are offered when addressing a + message exactly like your own; your own card wins a tie. + +--- + +# Files + +JMAP `FileNode`, in the shape 0.16 defines (`nodeType`, four separate rights). + +- **Folder tree** in the left pane, fetched **in one request**, so opening a + folder never waits on a round trip. +- Browse, download, create folders, rename, move, delete. +- **Drag a row** onto a folder in the list or anywhere in the tree to move it. + A folder cannot be dropped inside itself. +- **Drag from the desktop** to upload — and drag a *folder* to upload it with + its structure intact, subfolders created as needed. (The structure is only + reachable through `webkitGetAsEntry`, whose entries go stale the moment the + drop handler returns, so the tree is read out synchronously and walked + afterwards.) +- **Sharing** per file or folder, with rights per person. +- **Attach from Files** in the composer, with no re-upload. +- One folder is hidden on purpose: **`ihasmail`**, contents and all. It holds + the settings file and signature images. Hiding the folder alone would have + been worse than showing it — the tree attaches a node whose parent is missing + to the root, so signature images would have spilled into the top level. + +--- + +# Filters (Sieve) + +A visual rule builder that round-trips losslessly to a real Sieve script. Rules +are stored inside the script itself as `# rule:{…}` JSON comments, with the +generated Sieve below each one — so the script the server runs is the script you +can read, and the builder can reconstruct the rules from it. + +**Conditions** — match all or any of: + +| Test | Options | +| --- | --- | +| Header | From, To, Cc, Subject, List-Id, Reply-To, X-Spam-Status, or any header you name | +| Address | Any header, matching the whole address, the local part or the domain | +| Size | Over / under | +| Body | Contains / does not contain | + +Each header and address test takes: contains, is, matches (wildcards `*` `?`), +regex, exists — and the negation of each. + +**Actions**: file into a folder (creating it on the spot, optionally keeping a +copy), redirect to an address, discard, keep, reject with a reason, add / set / +remove a flag, mark read, star, and stop. + +Also: + +- **Enable or disable** a rule without deleting it, and reorder them by dragging + or with the up/down buttons — order is what Sieve evaluates in. +- **Raw script editor** underneath, with the server validating before save — a + script Stalwart will not accept is refused in front of you rather than failing + quietly at delivery. +- **Preview generated Sieve** for the visual rules. +- **Refuse to save from a script we only partly read** — a save is checked for + completeness against the shape the generator emits, after a compressing proxy + once truncated a download and the next save wrote the short version back over + the real one. +- **Filter messages like this…** from a message's context menu, pre-filled from + the sender or the list it came from. +- **Apply to the messages already in this folder** — evaluated client-side there + and then, because the server only runs Sieve on delivery. +- **Folder renames are tracked**, so a rule that files into a folder keeps + working when the folder moves. +- **Out of office** sits beside it as a JMAP `VacationResponse` — subject, body, + and an optional start and end — rather than a rule you have to write. + +--- + +# Sharing + +Files, calendars and address books share with other accounts on the same server +through JMAP Sharing. Right-click something you own, choose **Share…**, pick +people from the directory, and give each Viewer or Editor — or set the +individual rights by hand. + +- **Shared things appear where they belong**, not behind an account switcher. + Somebody's folder is in Files, their calendar in the calendar, their address + book in Contacts, each under *Shared with me*. There is no account switching; + the switcher that used to exist moved the whole app into someone else's + account, which was the wrong door. +- **Adding is a deliberate step**, for the reason given under Calendar: the + server reports every collection you can reach. +- **Stopping is separate from hiding** — *Stop sharing* on something you own + withdraws access from everyone at once, after asking; *Remove from my view* on + something shared with you changes nothing for anybody else. +- Anything of yours that is shared carries a badge, so you can see at a glance + what is out there. +- Where the server will not remember that you added a share — 0.16 refuses + `isSubscribed` on a read-only address book while accepting it on a calendar — + ihasmail keeps the list in the settings that already follow you between + devices, so the inconsistency does not reach the reader. +- **Sharing a mail folder is not offered.** Stalwart accepts it, stores it, and + never delivers it. A folder shared before that was withdrawn still offers + *Stop sharing*, because a share nobody can see is the one you most want to be + able to clear. + +--- + +# Settings + +## They follow the account, not the browser + +Preferences live in a `settings.json` in the account's own JMAP Files, beside +the signature images. So identity, signatures, locale, date and time formats, +theme, labels, templates, folder colours, trusted image senders and added shares +are the same wherever you sign in, private windows included — and they are +backed up with the mail store, because they *are* in the mail store. ihasmail +still stores nothing of its own. + +Settings that describe *this screen* stay local, deliberately: density, font +size, sidebar state, the two pane sizes, and the notification toggles, which +track a permission the browser grants per device. The list is written as the +exceptions rather than the rule, so a setting added later syncs by default. + +Two limits: 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. + +## Sections + +| Section | Holds | +| --- | --- | +| **General** | Reading pane, mark-as-read delay, auto-advance, remote images, conversation view, snippets, avatars, confirm-before-delete; compose format, undo-send window, quoting, signature placement, attachment reminder, read-receipt policy, spell check; time zone, week start, language & region, date format, time format; `mailto:` handler; export / import / reset | +| **Appearance** | Theme, accent colour, density, font size, sidebar | +| **Identities & signatures** | Addresses, names, Reply-To, HTML signatures, the default, and which to hide from the picker | +| **Filters & rules** | The visual builder and raw Sieve editor | +| **Out of office** | Vacation response | +| **Folders** | Create, rename, colour, subscribe | +| **Labels** | Keyword, display name, colour | +| **Templates** | Named subject + body | +| **Calendar & contacts** | Colour categories, working hours, default view, default duration, default reminder | +| **Notifications** | In-tab notifications, notify-when-closed (Web Push), sound | +| **Security & sessions** | Password, two-factor state, app passwords, active webmail sessions | +| **Keyboard shortcuts** | The full list, grouped | +| **About** | Version, source URL, server, and the capabilities it advertises | + +Settings **export** to a JSON file and **import** back, and reset to defaults. + +## Dates, times and locale + +- **~620 locales** — every tag CLDR has real data for, each named in its own + language and script, generated by probing `Intl` rather than hand-listed. +- The default comes from the locale Stalwart reports for the account, then the + browser's. +- **Date order**: locale default, `22.11.2025`, `22/11/2025`, `11/22/2025`, or + ISO `2025-11-22`. +- **12- or 24-hour clock**, applied everywhere. +- **Numerals follow the locale**, except under ISO 8601, which pins date and + clock to Latin digits. +- Dates are **entered** through ihasmail's own pickers rather than the browser's, + because browsers render `` in their own locale and ignore + the page's. Typing is lenient: `22.11.`, `221125`, `6:23pm` and bare ISO all + parse. +- Editable date boxes are always Gregorian and Latin digits, even where the + display locale uses another calendar: a Buddhist-era year in a text box does + not round-trip against a Gregorian grid. Non-Gregorian calendars are not + implemented. + +## Themes + +Follow the system, light, dark, or **ihasmail** — the palette this project's +site is painted in, and what a new account starts on. It is a theme rather than +an accent because it changes backgrounds, borders and text as well as the +highlight colour. Accent colours sit on top of any of them. The top-bar toggle +remembers which dark theme you came from, so flipping to light and back returns +you to the one you were on. + +--- + +# Live updates and notifications + +- **JMAP push over EventSource**, proxied by ihasmail's server so the browser + never holds credentials. State changes arrive per type, and each store + refreshes only what changed. +- **Polling behind it** for networks that cut long-lived connections, and + reconnection with backoff. The header shows which of the three states it is + in: connected, reconnecting, or off and polling. +- **Unread count in the tab title and painted onto the favicon**, so the tab + tells you before you look. +- **Desktop notifications** while ihasmail is open and the tab is in the + background, with an optional sound. +- **Web Push** for notifications with ihasmail **closed**, where the server + signs with VAPID (RFC 9749). Nothing in that path touches ihasmail's server — + Stalwart talks to the browser's push service directly, so there is no relay to + run. Where the server also implements `emailpush`, the payload carries the + sender, subject and preview; without it the notification says only that mail + arrived. Offered only on a device you said was yours. +- **Stale build reload** — when the server starts serving a build the open tab + did not come from, the tab reloads itself rather than going on talking to a + newer server with older JavaScript. It waits for a moment that is safe: an + unsent draft is not thrown away. + +--- + +# Platform + +- **Installable PWA** with a service worker: the app shell is cached for + installability and fast loads, API requests never are, and navigations are + network-first with the shell as fallback. +- **Manifest shortcuts** for Compose, Calendar and Contacts. +- **`mailto:` handler** — registered from Settings › General for the browser + (needs HTTPS; Safari does not support it), and declared in the manifest so an + installed ihasmail is offered by the operating system wherever something asks + for a mail client. Links arrive with recipients, Cc, Bcc, subject and body + filled in. +- **Deep links**: `/mail/:mailboxId?/:threadId?`, `/search/:threadId?`, + `/calendar/:view?/:date?`, `/contacts/:id?`, `/files/:nodeId?`, + `/settings/:section?` — every view, down to an open conversation, is + addressable, and the back button works. +- **Printing** a message uses the browser's own print. + +## Keyboard shortcuts + +Gmail-style and always on; there is no setting to enable them. `?` shows the +list anywhere. Two-key sequences (`g` then `i`) have a 1.2-second window. +Nothing fires while you are typing, or while a dialog or menu is open, except +three composer bindings that are the point of the exception. Shortcuts register +per view, so the same letter can mean different things in mail and the calendar. +`Ctrl` is `Cmd` on a Mac, and the help dialog shows which it picked. + +| Where | Keys | +| --- | --- | +| Global | `?` help · `/` search · `c` compose · `g i/s/t/d/a` inbox, starred, sent, drafts, all mail · `g l/c/f/k` calendar, contacts, files, settings | +| List | `j`/`k` next/previous · `o` open · `u` back · `Esc` back or clear selection · `x` select · `Ctrl+A` select all | +| Acting | `e` archive · `#` delete · `!` spam · `s` star · `Shift+I`/`Shift+U` read/unread · `v` move · `l` label | +| Conversation | `r` reply · `a` reply all · `f` forward · `n`/`p` next/previous message · `]` archive and open next | +| Composer | `Ctrl+Enter` send · `Ctrl+S` save draft · `Esc` close, saving | +| Calendar | `t` today · `n`/`p` next/previous · `d`/`w`/`m`/`a` day, week, month, agenda · `c` new event | + +Aliases exist and are left out of the in-app list on purpose: `↓`/`↑` for +`j`/`k`, `Enter` for `o`, `y` for `e`, `Delete` for `#`. Contacts and Files +define no shortcuts of their own; the global set still applies. + +The full reference is at [docs.ihasmail.org/shortcuts](https://docs.ihasmail.org/shortcuts/). + +--- + +# Security and privacy + +## The browser never holds a credential + +Sign-in posts the username and password once. The server seals them with a key +derived from the session's own cookie secret combined with `APP_SECRET` +(HKDF-SHA256 → AES-256-GCM), and keeps only the ciphertext plus a hash of the +cookie secret. A stolen session file cannot be turned back into passwords +without also holding the users' cookies. The browser gets an `HttpOnly`, +`SameSite=Lax`, `Secure`-when-HTTPS cookie and nothing else; every JMAP call +goes through `/api/jmap` on the same origin. + +## "This is my own device" + +A tickbox on the sign-in page, **unticked by default**, because the answer that +costs something to get wrong is the one that assumes the machine is yours. + +| | Unticked | Ticked | +| --- | --- | --- | +| Stays signed in | until the browser closes | up to 30 days (`SESSION_REMEMBER_TTL`) | +| Idle sign-out | after 5 minutes | none | +| Kept on the computer | nothing | settings cache, recent addresses, username | +| Background notifications | refused | available | + +Local storage is gated on that answer for **reads** as well as writes — a +machine trusted once still has residue, and honouring it would let a previous +session's data surface in a later untrusted one. Signing out clears the settings +cache and recent addresses and tears down the push subscription, whichever +answer was given. + +The idle timer exists because the alternative does not work: `beforeunload` text +was removed from browsers years ago, and **no event fires at all** for walking +away from a signed-in screen, which is the case that matters. + +## Server hardening + +- **CSP** on the app: `default-src 'self'`, `script-src 'self'`, `object-src + 'none'`, `frame-ancestors 'none'`, `base-uri 'self'`, `form-action 'self'`. + Proxied blobs get a far stricter one — `sandbox; default-src 'none'`. +- `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, + `Referrer-Policy: no-referrer`, a `Permissions-Policy` denying camera, + microphone, geolocation, payment and USB, `Cross-Origin-Opener-Policy: + same-origin`, HSTS over HTTPS, and `Cache-Control: no-store` by default. +- **CSRF**: every API call must carry `X-Requested-With: ihasmail`, and any + request whose `Sec-Fetch-Site` is not same-origin is refused outright. +- **Rate limiting** on sign-in, keyed by IP *and* IP+username, with + `Retry-After`; a separate limiter guards the endpoints that check a password. + Client IP is taken from `X-Forwarded-*` only for peers in `TRUSTED_PROXIES` + (loopback and the private ranges by default) — otherwise anyone could pick + their own key for the limiter. +- **Upload and timeout limits** on the proxy (`MAX_UPLOAD_BYTES`, + `UPSTREAM_TIMEOUT`). + +## The image proxy is SSRF-safe + +Approved remote images are fetched by the server, so every request is checked +before it is made: the hostname is resolved and **every** answer must be +acceptable — one bad record fails the fetch — and private space is refused in +both families (RFC 1918, loopback, link-local, CGNAT, multicast, +IPv4-mapped IPv6, unique-local, and NAT64, which reaches IPv4 space). Responses +are capped at 15 MB, served under the sandbox CSP above, and identified by their +own user agent. + +## Self-service credentials + +Over Stalwart's own registry objects, so there is no administrator in the loop: + +- **Change your password.** Where the account is backed by an external directory + (LDAP, SQL, OIDC) Stalwart refuses, and ihasmail shows the server's own reason + rather than inventing one. +- **App passwords** — create, list and revoke a separate password per mail app + or device. +- **Active webmail sessions** — see them, and revoke every session but this one. +- **Two-factor**: an account that has it can turn it **off** here. Turning it + **on** is not offered, and there is no code field on the sign-in page. + Stalwart accepts a TOTP code only through an OAuth flow and offers no password + grant, so a client holding a username and password cannot exchange them plus a + code for a token. **An account with 2FA signs in with an app password.** A + rejected sign-in that carried a code says exactly that rather than "invalid + credentials". Doing it properly means implementing OAuth; that is in + [ROADMAP.md](ROADMAP.md). + +## Privacy by default + +Remote images blocked, the proxy on, read receipts never automatic, no +telemetry, no third-party requests from the app (the CSP would refuse them), and +no analytics. The only network calls the browser makes are same-origin. + +--- + +# Running it + +## Immutable, in the exact sense + +The server writes to exactly one path, the optional `SESSION_FILE`. Clear it and +there is nothing left to write: + +```bash +docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ... +``` + +`IMMUTABLE=1` is an **assertion the server checks at startup**, not a switch +that changes behaviour. It refuses to boot 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 the mode today, since sessions have +nowhere to live across a restart. Removing it means moving the session upstream +into a token Stalwart issues and can revoke — the OAuth work in the roadmap. + +The image ships no `VOLUME` line: one would make Docker mount an anonymous +volume whether asked for or not, and that mount stays **writable under +`--read-only`**. ihasmail's own image carried exactly that bug until 2.16.117, +found by running the check rather than trusting the flag: + +```bash +docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' +docker inspect --format '{{json .Mounts}}' # the one people skip +``` + +## Configuration + +Every knob is an environment variable; there is no config file and no setup +wizard, because either would be state. + +| Variable | Default | Does | +| --- | --- | --- | +| `STALWART_URL` | — | Where Stalwart is; the JMAP session is discovered at `/.well-known/jmap` | +| `APP_SECRET` | — | Key material for sealing sessions. **Required in production** — the server refuses to start without it | +| `HOST` / `PORT` | `0.0.0.0` / `8080` | Listen address | +| `TRUST_PROXY` | `1` | Believe `X-Forwarded-*` | +| `TRUSTED_PROXIES` | loopback + private ranges | Which peers to believe | +| `SECURE_COOKIES` | `auto` | `Secure` when the request arrived over HTTPS; `1`/`0` to force | +| `SESSION_TTL` | `43200` (12h) | Idle session lifetime | +| `SESSION_REMEMBER_TTL` | `2592000` (30d) | "This is my own device" lifetime | +| `SESSION_FILE` | `./data/sessions.json` | Where sessions persist; empty means memory only | +| `IMMUTABLE` | off | Assert and verify that nothing is writable | +| `UPSTREAM_TIMEOUT` | `30000` | Milliseconds | +| `MAX_UPLOAD_BYTES` | `52428800` | 50 MB | +| `IMAGE_PROXY` | `1` | Privacy proxy for remote images | +| `LOGIN_RATE_LIMIT` | `10` | Attempts per window | +| `COOKIE_NAME` | `ihm_session` | | +| `APP_NAME` | `ihasmail` | Branding | +| `SOURCE_URL` | this repository | Where **your** source is, for the AGPL offer | + +Full documentation, including TLS and reverse proxies: +[Configuring](https://docs.ihasmail.org/configure/). `Caddyfile.example` and +`nginx.example.conf` are in the repository. + +## Rebranding + +`APP_NAME` and `SOURCE_URL` are variables; the logo, icons and palette are +files. See [Rebranding](https://docs.ihasmail.org/rebranding/). If you run a +modified ihasmail, `SOURCE_URL` must point at **your** tree — the AGPL's offer +is for the source of the version being run, and it is shown on the sign-in page +and in Settings › About. + +## Operations + +- **`GET /api/health`** answers name, version and `ok`, and is what the + container health check uses. +- **Versions** read `2026.8.30+pr129`: the date of the commit the build came + from, and the pull request it arrived through (or `+g` for one that did + not). It comes from git at build time; nothing writes a version into the tree. + A build reporting `0.0.0` means nobody passed one, which is meant to look + wrong. The version deliberately says nothing about Stalwart. +- **`deploy.example.sh`** is a single-host Docker deploy: it fetches, refuses + anything held back by `.deploy-hold`, shows what is about to ship and asks, + rebuilds with the right version baked in, replaces the container, waits for + healthy, and prunes all but the newest `IHASMAIL_KEEP_VERSIONS` images — + never the one running. `--yes` skips the prompt but never a hold. +- **Sessions survive a restart** when `SESSION_FILE` is set; an immutable + instance trades that away knowingly. + +## The mock server + +An in-memory fake Stalwart 0.16 — enough JMAP to develop, demo and screenshot +against with no real mailbox. `npm run dev:mock`, then `demo@example.com` / +`demo`. + +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**, `CalendarEvent/set` +speaking Stalwart's vocabulary rather than RFC 8984's, and an override that +moves an occurrence renumbering the ids around it. 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. + +--- + +# What it does not do + +The full list with reasons is [ROADMAP.md](ROADMAP.md). In short: no snooze +(nothing in JMAP or Stalwart supports it, and ihasmail holds no password to act +on a mailbox while you are away), no languages but English, no two-factor +sign-in without an app password, no sharing of mail folders (the server stores +the share and never delivers it), no public links (JMAP shares with accounts on +the same server, and ihasmail has no storage of its own to mint a link from), +and no per-occurrence *this and future* edits (the server refuses them). diff --git a/README.md b/README.md index d255034..acdffb5 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ durable belongs to Stalwart; the container is disposable. | --- | --- | | 🌐 **[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/) | +| 📋 **[FEATURES.md](FEATURES.md)** | Everything it does, feature by feature, with the capability each one needs | | 🧪 **[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 |