1092 lines
58 KiB
Markdown
1092 lines
58 KiB
Markdown
# 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; iCal import | Calendar hides; invite cards and import need `parse` |
|
||
| `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. Full-screen surfaces measure in `dvh` rather than `vh`, because a
|
||
phone browser's `100vh` is the taller viewport that ignores the address bar —
|
||
a composer sized that way puts Send behind the toolbar. The tab bar, drawer,
|
||
dialogs and compose button all keep clear of the notch and the home
|
||
indicator; `index.html` asks for `viewport-fit=cover`, so the app paints
|
||
under both and the stylesheet is what keeps anything readable out from
|
||
beneath them.
|
||
|
||
### On a touchscreen
|
||
|
||
Five gestures, every one of them decided by `(pointer: coarse)` rather than by
|
||
screen width. The two questions are different and both get asked: a tablet in
|
||
landscape is a wide screen that swipes, and a phone plugged into a mouse is a
|
||
narrow one that should not. A mouse keeps drag-and-drop onto folders, which
|
||
shares the same pointer stream and would otherwise be fighting a swipe for
|
||
every drag.
|
||
|
||
- **Swipe a row** sideways to act on it. Each direction is a setting — right
|
||
archives and left deletes by default, which is what the mail app the phone
|
||
came with already does. Either direction can be set to archive, delete,
|
||
report spam, read/unread, star or move to… — or to nothing, which turns that
|
||
direction off. The coloured strip revealed behind the row names what will
|
||
actually happen *in the folder it is happening in*: "Delete forever" out of
|
||
Deleted Items, "Not spam" inside Junk Mail. Where an action
|
||
is meaningless there — archiving out of the archive, calling your own drafts
|
||
spam — the row will not move that way at all, because a row that slides open
|
||
to reveal a word and then does nothing is worse than one that does not slide.
|
||
- **Hold a row** to select it, which is how selection starts on a phone.
|
||
Checkboxes are visible wherever there is no hover, but a checkbox beside an
|
||
avatar is not what a thumb aims at. Once one row is selected, plain taps
|
||
toggle the rest.
|
||
- **Hold a folder** in the drawer for the menu its ⋮ button opens.
|
||
- **Pull the message list** down to refresh it.
|
||
- **Drag in from the left edge** of a conversation to go back to the list.
|
||
- **Swipe the calendar sideways** in day or month view to step to the next
|
||
period or back — dragging left pulls the next one in from the right, the way
|
||
paper and every phone do it. Week and agenda scroll through a range rather
|
||
than turning to the next one, so a sideways flick would not obviously mean
|
||
anything there and does nothing. A drag that begins on an event is left
|
||
alone, which keeps dragging an event to move it available to be built later
|
||
without having to be untangled from this first.
|
||
|
||
It asks for a longer drag than a row swipe does, and not because the
|
||
consequence is bigger — stepping back undoes it, while a swiped row has
|
||
already been archived. It is because this gesture has no way to change its
|
||
mind: a row slides open as it goes, so the strip underneath names what will
|
||
happen and letting go early calls it off, and a toast offers Undo afterwards.
|
||
Stepping the calendar shows nothing on the way and offers nothing after, so
|
||
the distance is the only chance to not mean it.
|
||
|
||
The toolbar's refresh button and the thread's back arrow both stay. A gesture
|
||
with no visible control is one only the people who already know about it can
|
||
use, and none of these announce themselves.
|
||
|
||
Each threshold crossing taps the vibration motor where there is one, which is
|
||
confirmation for the hand rather than the eye — a swipe fires at the moment the
|
||
finger passes a line it cannot see. iOS supports none of that and never has, so
|
||
it is silently nothing there.
|
||
|
||
The arithmetic lives in `web/src/lib/touch.ts`, away from the components and
|
||
under test, because the numbers are the whole thing. The axis lock is
|
||
deliberately biased towards the vertical: scrolling is what a finger on a
|
||
message list is doing almost every time, and a scroll misread as a swipe grabs
|
||
the list out from under the reader, while a swipe misread as a scroll costs one
|
||
more attempt. A drag that is merely more sideways than not stays a scroll.
|
||
|
||
## 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, and
|
||
a long press on a touchscreen.
|
||
- **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…, *Filter messages like this…*, and *Create event…*.
|
||
- **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.
|
||
|
||
**Archive by date** files into `Archive/<year>` or `Archive/<year>/<month>`,
|
||
creating the folders as needed and reusing them after that — including ones
|
||
made by hand or by another client. The names are numeric and zero-padded
|
||
(`2026`, `2026/09`) rather than month names, because these are real server-side
|
||
mailboxes: every other client sees them, a folder created as "September" by
|
||
someone reading in English stays "September" for the same account read in
|
||
Japanese, and `09` sorts between `08` and `10` where a name does not. The date
|
||
is read in the reader's own timezone, so it agrees with the date shown against
|
||
the message in the list.
|
||
|
||
A selection spanning two months is two destinations, not one, and both are
|
||
written; the menu names the folder where there is a single answer and describes
|
||
the rule where there is not, and the toast afterwards says how many folders it
|
||
touched. One Undo puts the whole selection back wherever it came from.
|
||
|
||
`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**.
|
||
- **Forward as attachment** sends the message itself rather than a quotation of
|
||
it — headers, structure and every attachment intact, which is what a bounce
|
||
or a phishing report needs and what quoting destroys. It costs **no upload at
|
||
all**: a message's `blobId` is its own RFC822 blob and already lives in the
|
||
account, so a 40 MB message attaches by reference as fast as a small one. In
|
||
the message's ⋮ menu, the list's right-click menu, and the overflow on the
|
||
reply strip at the foot of a thread, which is the one a thumb finds on a
|
||
phone.
|
||
- Saved and attached `.eml` files are **named from the subject in whatever
|
||
script it is written in**. The rule keeps letters and drops only what a
|
||
filesystem cannot take — path separators, the names Windows reserves, control
|
||
characters — so a Russian or Japanese subject keeps its own name instead of
|
||
becoming a row of underscores.
|
||
- **Unsubscribe** where the message carries `List-Unsubscribe`.
|
||
- **Sender details** expand to the full From/To/Cc/Reply-To with addresses.
|
||
- **What the spam filter said** sits in those details, read back off the
|
||
message rather than scored here: the verdict, the score, the threshold it was
|
||
measured against, and the rules that moved it, largest mover first and signed
|
||
so which way each pushed is visible. Both the SpamAssassin-shaped `X-Spam-*`
|
||
set that Stalwart's own filter writes and Rspamd's `X-Spamd-Result` are read;
|
||
anything else is left alone rather than guessed at. Two things it will not
|
||
do: a score is always given the threshold it was measured against, because
|
||
6.7 is damning against 5 and unremarkable against 15 and the number alone is
|
||
not something a reader can act on — where no threshold was stated, it says
|
||
so; and where the filter recorded no verdict, none is invented from the score,
|
||
since the filter applies policy ihasmail cannot see. Mail that arrived without
|
||
these headers shows nothing.
|
||
- **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 <ann@x>, bob@y; "C, D" <c@z>`).
|
||
- **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. Both carry **placeholders** — `{{recipientName}}`,
|
||
`{{recipientFirstName}}`, `{{recipientEmail}}`, `{{myName}}`, `{{myEmail}}`,
|
||
`{{subject}}`, `{{date}}` and `{{time}}` — filled at the moment the template
|
||
is inserted, so what they came to is visible and editable before anything is
|
||
sent rather than changing under the message afterwards. Dates and times
|
||
follow the same format settings as the rest of the app. A placeholder that
|
||
cannot be answered yet — a recipient's name on a draft nobody has addressed —
|
||
is **left in the body exactly as written**, because substituting an empty
|
||
string there produces "Hi ,", a greeting that is wrong rather than one that
|
||
is visibly unfinished. A name that is not a placeholder is left alone too.
|
||
- **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.
|
||
- **Compose as new** — the same mail again rather than passed on, for one that
|
||
bounced or went to a misspelled address. Recipients, Reply-To, subject, body
|
||
and attachments come across as they stand; the Message-ID, date and threading
|
||
headers do not, so it sends as a mail that has never been sent, and the
|
||
original is neither altered nor marked. In a message's own menu, in the list's
|
||
right-click menu, and behind the overflow on the reply strip at the foot of a
|
||
thread — which is the one a thumb finds on a phone.
|
||
- **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.
|
||
|
||
- **iCal import** through `CalendarEvent/parse` (a file of any number of
|
||
events), from the calendar's own menu, into that calendar. The events are
|
||
filed rather than scheduled: no invitations go out to anyone named in them.
|
||
|
||
## 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*. Also from a message — see *Create event…* below.
|
||
|
||
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.
|
||
- **Create event…** from a message, in its context menu and its ⋮ menu (and,
|
||
on a phone, in the ⋮ of a held row). The subject becomes the title and the
|
||
body the description; the sender and everyone the message was addressed to
|
||
become guests, minus your own addresses and never a blind copy. The editor
|
||
opens on the next half hour for an hour, because when it happens is the one
|
||
thing the message cannot say — and with *Send invitation emails* off, since
|
||
a guest list you inherited rather than typed should not mail itself on the
|
||
first press.
|
||
- **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`, the event editor grows
|
||
a **scheduling panel**: a row per participant — you first, because scheduling
|
||
around everybody except yourself is how two things end up at the same time —
|
||
over the days the event spans, marked by the hour or by the day depending on how
|
||
wide that is.
|
||
|
||
- **It is somewhere to put the event, not only something to read.** The pointer
|
||
shows the half hour it is over, and clicking moves the event there keeping its
|
||
length.
|
||
- **It steps backwards and forwards** a screenful at a time without touching the
|
||
event, and offers its way back. Clicking while stepped away moves the event to
|
||
where you clicked and brings the view with it.
|
||
- **A week is as far as it stretches.** Something running longer is not an event
|
||
anybody is hunting a free slot in; it says how many days it left out instead.
|
||
|
||
**Whoever cannot be read is drawn hatched, never blank.** Free/busy is answered
|
||
per principal, and only accounts on this server are principals — so for a guest
|
||
at another domain there is nothing to read. Leaving them out, which is what
|
||
ihasmail used to do, is the one presentation that lies: a row with nothing in it
|
||
reads as a diary with nothing in it. A line under the grid says how many and
|
||
why.
|
||
|
||
That limit is the protocol's rather than a gap waiting to be closed. A
|
||
`Principal` exposes no route to its calendars at all, so free/busy is not the
|
||
weaker permission — it is the only channel between two accounts, and it needs no
|
||
sharing to be set up first.
|
||
|
||
## 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`.
|
||
- **LDIF import**, for address books coming from SOGo, Thunderbird or an LDAP
|
||
directory. Nothing on the server reads LDIF, so the file is read here:
|
||
RFC 2849 for the syntax, [Mozilla's address book schema][ldif-schema] for what
|
||
the attributes mean, which is the one such exports almost always use. Work and
|
||
home addresses, every phone kind, second email, organisation and units, job
|
||
title, nickname, web pages and the custom fields all come across. The import
|
||
control takes either format and decides by what is in the file, not by what it
|
||
is called.
|
||
|
||
[ldif-schema]: https://wiki.mozilla.org/MailNews:Mozilla_LDAP_Address_Book_Schema
|
||
- **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.
|
||
- **Nothing is discarded without asking.** Both editors keep their edits until
|
||
you save, so every way out of the page used to throw them away silently — a
|
||
settings link, the app rail, even the Rules/Scripts switch — and with a
|
||
screenful of rules the save bar had already scrolled past the bottom of the
|
||
window. Leaving now asks, offering to save rather than making "leave without
|
||
saving" the easy answer, and the bar is pinned to the foot of the pane so
|
||
"Unsaved changes" is on screen whether or not the rules fit in it.
|
||
- **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.
|
||
|
||
The swipe actions are a deliberate non-exception, and look like one at a
|
||
glance: they only do anything on a touchscreen, so they read as belonging to
|
||
the device. But someone who has decided that a left swipe deletes has decided
|
||
it for their phone and their tablet both, and the desktop that ignores them is
|
||
the odd one out rather than the case to design around.
|
||
|
||
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, swipe actions, interface language |
|
||
| **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 `<input type="date">` 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.
|
||
|
||
## Interface language
|
||
|
||
Ten languages — English and nine translations — chosen in **Appearance →
|
||
Language**, and separate from the date-and-time locale above. Wanting German
|
||
dates on an English interface is a real preference and so is the reverse, which
|
||
is why they are two settings and not one.
|
||
|
||
| | |
|
||
| --- | --- |
|
||
| English | the source language, and what every other catalogue falls back to |
|
||
| Deutsch · Español · Français · Nederlands · Português (Brasil) | Beta |
|
||
| Русский · Українська · 简体中文 · 日本語 | Beta |
|
||
|
||
**All nine translations are marked Beta, and the label is not modesty.**
|
||
The catalogues were produced by AI against standard dictionaries and have not
|
||
been read by anybody who speaks the language. That is stated in Settings, next
|
||
to a link for reporting anything that reads wrongly, because the alternative —
|
||
shipping them quietly — would ask people to trust text nobody has checked. A
|
||
language loses the Beta mark when a speaker has read it and said so, which is a
|
||
deliberate act by a person and not something a percentage earns.
|
||
|
||
Two things follow from the design rather than the translation:
|
||
|
||
- **A missing entry renders its English source.** So deleting a bad line is a
|
||
valid fix, not a regression, and a catalogue is never in a half-broken state.
|
||
- **Plurals are asked for, never assumed.** `Intl.PluralRules` decides the form,
|
||
so Russian and Ukrainian get their three (1 письмо, 2–4 письма, 5+ писем) and
|
||
Japanese and Chinese get the one they actually have — with counters doing the
|
||
work a plural would: 通 for messages, 件 for conversations.
|
||
|
||
The interface language also feeds the *automatic* date locale, so choosing
|
||
日本語 gives Japanese month and weekday names without setting the region too.
|
||
`<html lang>` follows it, which is what stops a browser offering to translate a
|
||
page that is already in the reader's language — and accepting that offer is
|
||
what rewrites the DOM underneath React.
|
||
|
||
Only languages with a catalogue shipped appear in the picker. A language
|
||
offered without strings behind it would leave the page claiming to be in a
|
||
language it is not, which is worse than not offering it: it stops a browser
|
||
offering to translate a page the reader cannot read.
|
||
|
||
## 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.
|
||
- **The subscription is renewed on every app start**, because a JMAP push
|
||
subscription expires — seven days is the ceiling — and re-registering before
|
||
it lapses is the client's job. Renewal can only happen with a page open:
|
||
registering is a JMAP call and the service worker has no session to make one
|
||
with. So the guarantee is that background notifications keep working as long
|
||
as ihasmail is opened now and again, and the two-day renewal window means
|
||
once a week is enough. A browser that dropped or rotated its subscription on
|
||
its own is re-subscribed at the same moment, rather than left with a switch
|
||
that says push is on and a browser that is no longer listening.
|
||
Registration is per browser, not per account: a phone having push does not
|
||
make it on for the desktop, and each device tracks its own.
|
||
- **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 <name> --format '{{.HostConfig.ReadonlyRootfs}}'
|
||
docker inspect <name> --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<sha>` 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 language yet checked by a native speaker,
|
||
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).
|