The dates were already on the contact cards and nothing ever showed them, so the one thing a birthday is for -- noticing it in time -- was the one thing the app could not do with it. Derived, not stored. The dates stay on the cards: a second copy of the same fact drifts the first time somebody corrects one, and keeping a calendar of its own is exactly what ihasmail does not do. Entries are generated when a view asks for a range and vanish when the contact does. They go through instancesIn like everything else, so no view has to know they are different. Off until switched on. It is derived data, and a calendar that fills itself with dates nobody put there is a surprise rather than a feature. It can also be hidden from the calendar's own sidebar without being turned off, which is the same distinction the shared calendars already draw. They cannot be edited or deleted, and that falls out of the design rather than being special-cased: the virtual calendar reports no write rights, so every control that already asks before offering Edit or Delete declines on its own. updateEvent and destroyEvent refuse a synthesised id as well, so the store is safe whatever calls it -- including anything added later. Two things about the dates themselves. A card that records only a day and month is the common case rather than the exceptional one, and gets a birthday with no age rather than no birthday. And 29 February falls on the 28th in a year that has no 29th: somebody born in February has a birthday in February, and moving it into March is the arithmetic winning over the fact. Both are conventions; these are the ones that keep the fact intact. The mock now carries birthdays on most of its contacts, including one with no year and one on 29 February, so both cases are visible without a real address book.
64 KiB
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 | What ihasmail deliberately does not do, and why |
| KNOWN-ISSUES.md | What was verified live, and where Stalwart departs from a spec |
| 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.
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
dvhrather thanvh, because a phone browser's100vhis 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.htmlasks forviewport-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+Afor 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
myRightssays 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
blobIdis 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
.emlfiles 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'sX-Spamd-Resultare 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/pmove 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-Submittedare 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
futureReleaseis 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. -
Birthdays, as a calendar of its own derived from the birthdays already on your contacts. Off until switched on in Settings › Calendar & contacts, and hideable from the calendar's own sidebar without turning it off.
Nothing is written anywhere. The dates live on the cards; a second copy of the same fact would drift the first time somebody corrected one, and keeping a calendar of its own is exactly what ihasmail does not do. An entry disappears when the contact does, or when the birthday is cleared.
They cannot be edited or deleted, and that falls out of the design rather than being special-cased: the virtual calendar reports no write rights, so every control that asks before offering Edit or Delete already declines. The store refuses a synthesised id as well, whatever calls it.
A card that records only a day and month — the common case — gets a birthday with no age rather than no birthday. And 29 February falls on the 28th in a year that has no 29th: somebody born in February has a birthday in February, and moving it into March is the arithmetic winning over the fact.
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
categoriesso 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 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.
- 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
isSubscribedon 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, conversation view, snippets, avatars; compose format, quoting, signature placement, spell check; time zone, week start, language & region, date format, time format; mailto: handler; export / import / reset |
| Privacy & safety | Remote images and the senders trusted with them, read receipts asked for and answered; the three warnings and the domains they measure against; undo-send window, attachment reminder, confirm-before-delete |
| 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 |
Privacy & safety is separate from Security & sessions, and the line between them is worth stating because two similar words in one nav is how a menu becomes something people hunt through. Security & sessions is credentials and access: password, two-factor state, app passwords, live sessions. Privacy & safety is how the app behaves towards the reader and towards senders: what loads, what leaks, and what asks before it happens. These had been spread through General, which had grown five unrelated headings — remote images filed under "Reading", the read-receipt policy under "Composing", the undo-send window beside the default message format.
Three warnings live there, and all three start switched off. That is not timidity: a client that begins by interrupting is one people learn to click through, and a warning clicked through without reading costs the same attention and buys nothing. The first could not be on by default in any case — it measures against the domains that count as yours, and with nothing configured every message in the mailbox is from outside.
- Messages from outside get a banner naming the sender's domain. Your own
identity domains are always inside and are not configuration; anything listed
is additional, and covers its subdomains. The match is on a dot boundary, so
example.comcoversmail.example.comand notnotexample.com, which is the shape somebody registers on purpose. - Sending outside names the outside recipients and asks, rather than refusing. A rule — "this is going outside" — is not something the sender can check; a list of addresses is.
- Sending to a large group asks once the count crosses a threshold you set, which catches a reply-all onto a long thread. It counts people rather than headers, so one address in To and nine in Cc is a message to ten.
- Opening a link asks before following it, for a destination not on the
trusted list — and always where the link's own text names one domain and
its destination is another, even when that destination is trusted. Being
trusted is not the same as being the place the text claimed. A domain can be
trusted from the dialog, except on that mismatch: what would be trusted there
is the destination, and the destination is not the thing in question. Links
that are not http or https are left alone, since warning about a
mailto:is noise, and noise is how a warning stops being read. Both message bodies are covered — a link in a plain-text mail is linkified by ihasmail and points wherever it likes just as readily as marked-up one.
The senders trusted with remote images are listed there and can be withdrawn one at a time. Previously a sender was added from a message and could only be removed by finding another message from the same sender.
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
Intlrather 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 ISO2025-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:23pmand 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.PluralRulesdecides 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.
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, aPermissions-Policydenying camera, microphone, geolocation, payment and USB,Cross-Origin-Opener-Policy: same-origin, HSTS over HTTPS, andCache-Control: no-storeby default.- CSRF: every API call must carry
X-Requested-With: ihasmail, and any request whoseSec-Fetch-Siteis 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 fromX-Forwarded-*only for peers inTRUSTED_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.
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:
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:
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 |
BASE_PATH |
— (the domain root) | Subpath to serve from, e.g. /mail. Must be set for the build as well as the run — see below |
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. Caddyfile.example and
nginx.example.conf are in the repository.
Serving from a subpath
BASE_PATH mounts the whole app under a prefix, for a host that is not
ihasmail's alone:
docker build --build-arg BASE_PATH=/mail -t ihasmail .
docker run -e BASE_PATH=/mail ... ihasmail
/mail, mail and /mail/ all mean the same mount; unset means the domain
root, which is exactly what it has always been. Everything moves together —
/mail/api/health, every deep link, the icons, the manifest, the service
worker's scope and the session cookie's Path.
Two things are worth knowing before you reach for it.
The prefix must arrive intact. Point the proxy at ihasmail without
stripping it: proxy_pass http://127.0.0.1:8080; with no trailing slash in
nginx, reverse_proxy without a uri strip_prefix in Caddy. A proxy that
strips the prefix is talking to an app at the root, and should be paired with
no BASE_PATH at all.
It is baked in at build time, not only at run time. This is the one setting
that cannot wait for the process to start: the web bundle writes its own
<script src> into index.html when it is built, so a build that does not
know the prefix produces a shell that cannot load itself under one. Hence the
--build-arg above. Get it wrong and the page comes up blank — so the server
checks the built shell against its own BASE_PATH at the first request and
says so in the log rather than leaving you with an empty page and a 404.
The manifest and the service worker need neither: a manifest's URLs resolve against the manifest's own address, and the worker's own address tells it where it was mounted. Both follow the prefix with nothing substituted into them.
Rebranding
APP_NAME and SOURCE_URL are variables; the logo, icons and palette are
files. See 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/healthanswers name, version andok, and is what the container health check uses. Under aBASE_PATHit moves with everything else, to/mail/api/health; the image's health check follows it.- 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 reporting0.0.0means nobody passed one, which is meant to look wrong. The version deliberately says nothing about Stalwart. deploy.example.shis 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 newestIHASMAIL_KEEP_VERSIONSimages — never the one running.--yesskips the prompt but never a hold.- Sessions survive a restart when
SESSION_FILEis 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 [email protected] /
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. 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).