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