Three things from using it on two real accounts.
A calendar shared with you never appeared. Nothing was wrong with the
share -- the calendar had nowhere to be shown. Calendars loaded from one
account and one only, so the sharer's were reachable solely by switching
the whole app to their account, which is the door being closed below.
They now sit under "Shared with me" beside the reader's own, in their own
colour, with their events in the grid and a click to hide them like any
other calendar.
Their events go through `instancesIn`, the one funnel every view already
reads, so month, week, day and agenda got them without being touched.
Events and calendars from another account are keyed by account as well as
id, and hiding one is remembered under the same key: an id means nothing
outside the account holding it, and two accounts sharing an id is
ordinary rather than unlucky.
An account that shared nothing was listed in Files as though it had.
Every non-personal account was offered on the reasoning that its folders
could speak for themselves -- but an account whose *calendar* was shared
has no folders to speak with, and appeared as an invitation to open an
empty pane. Each is now asked for one file before being listed, and
silence is taken for an answer.
And the account switcher is gone from the profile menu. It existed to
reach what other people shared and was the wrong door: it moved the whole
app to somebody else's account, and since Stalwart advertises every
capability on a shared account, mail, calendar and contacts went with it
and were refused. Everything it was for is now in the module the share
belongs to, found without anyone needing to know an account was involved.
What this does not prove is that Stalwart delivers a calendar share at
all. The mock says the client handles one, which is the half that was
missing; whether the server behaves like address books, which work, or
like mail folders, which do not, needs the two accounts again.
Address book sharing was withdrawn a few hours ago on a report that it
behaved like mail folder sharing. That was wrong -- it works -- and it is
back, built the way Files is rather than the way it was.
Three things it inherits from Files. Shared books are listed in the app's
own left pane instead of behind an account switch in the profile menu.
The reader's books and other people's sit under separate headings, since
a book belonging to somebody else behaves differently and a single merged
list would be quiet about whose contacts you are reading. And opening
Contacts re-reads the session, so a book shared while the tab was open
turns up without signing out and in again.
The books pane the view kept to itself is gone, and with it the last
module that ignored the sidebar it was given.
The one thing Files does not need: shared contacts have to answer when
somebody types a name into a To field, so they are loaded up front rather
than when a book is opened, and they are offered by `suggest` and found
by `lookupByEmail` alongside the reader's own. Their own cards win a tie,
since a card someone wrote themselves should beat a colleague's copy of
the same person. That is the difference between a shared book you can
look at and one you can use.
Cards from a shared account are held apart from the reader's rather than
merged in, and keyed by account as well as id. Ids are only unique within
an account -- two accounts each having a book `ab1` is ordinary -- and a
flat map would have had one silently replace the other.
The mock grew an address book in its shared account, with contacts in it,
because none of this could be exercised otherwise.
KNOWN-ISSUES records the withdrawal as the mistake it was rather than
leaving it in the history looking like a finding. Mail folder sharing
stays withdrawn: that one really is broken.
A folder somebody shared was reachable only by switching the whole app
to their account from the profile menu -- which nobody would think to
look in for files, and which pointed mail, calendar and contacts at them
as well. The server refused all three, so nothing leaked; it was simply
the app claiming to be somewhere it could not go.
Files now lists shared accounts itself, under "Shared with me", and opens
them in place. Only Files moves: `accountId` in its store is the account
being browsed, `ownAccountId` is the reader's, and nothing else in the
app notices.
Which accounts hold shared files cannot be worked out from capabilities.
Stalwart advertises the whole set on a shared account -- mail, calendars,
contacts, sieve, the lot, identical to a personal one, whatever was
actually shared (checked live on 0.16.19, 2026-08-27). That is why
routing alone could never have fixed this, and why the list offers every
account that is not the reader's own and lets its folders answer for
themselves. The mock's shared account now advertises the same full set,
because a mock that quietly advertised only what it shared would agree
with a fix that cannot work.
Shares also went unseen until the next sign-in. They arrive in the JMAP
session, which is fetched once and refreshed only when a session-state
change is pushed to that tab -- so a share granted while the tab was open
stayed invisible, and one removed stayed on offer. That is the two
browsers disagreeing about whether an account still existed. Opening
Files now re-reads the session, throttled, and the section header carries
a refresh for when someone is waiting on a share they have just been
promised.
The sidebar's button on Files was Compose, which wrote mail from the file
manager. It uploads.
Verified against the mock, which grew a second account to make any of
this testable: "Shared with me" lists it, opening it shows its folders
and not the reader's, the header says whose they are, "Back to my files"
returns, and the profile menu is not involved at any point.
Files had a breadcrumb and a Move to… dialog. Moving anything meant
opening a dialog and walking down the folder you wanted, which is a lot
of ceremony for something every file manager does by dragging, and there
was nowhere to see the shape of the account at all.
There is now a folder tree in the sidebar, beside the mailbox tree it
borrows its look from. Rows in the list and folders in the tree can be
dragged onto any folder in either, and folders dropped from outside are
uploaded with their structure intact.
The tree arrives in a single query. `filter: { nodeType: "directory" }`
returns every folder in the account -- checked against 0.16.19 on
2026-08-27 -- so nothing waits on an expand, and a drag knows every
folder it could land on including ones nobody has opened. It is
deliberately its own request: a filter Stalwart refuses fails with a
request-level 400 that takes every method call in the request with it,
which `{ parentId: null }` does, so a per-level query batched alongside
the listing would blank the whole view rather than just the sidebar.
Two things the writing of this turned up.
The mock ignored the `nodeType` filter the live server applies, so the
tree asked for directories, was handed files as well, and drew them as
folders you could open into nothing. The mock now filters the way 0.16.19
does. The store also filters again on the way in, because a tree that
believes whatever a server sends is a tree that draws files as folders on
the next server that gets this wrong.
And the drag state was per-pane, which cannot work: a drag that starts in
the list has to be recognised by the tree, and the pane that did not
start it never lit up or accepted the drop. Dropping still worked, since
the drop handler re-checks from the drag itself -- which is why this
would have shipped looking fine and been unusable. It lives in the store
now, with the reason written down.
Dropping a folder in goes through `webkitGetAsEntry`, which is
non-standard in name and universal in practice. Its `readEntries` returns
*up to* some entries per call and signals the end with an empty array, so
a single read loses everything past the first batch. Both bounds in there
-- depth, and entries per directory -- exist because a directory tree
from outside the app is not something to take on trust; the test that
covers the second one found the version without it looping for ever.
Verified against the mock: a row dragged onto a folder in the tree lights
the target, is accepted, and moves it on the server; a top-level folder
dragged to All files is refused as the no-op it is; the tree's own menu
creates, renames, shares and deletes; and the tree lists folders only.
The top-bar toggle went to light from anything dark, and back to plain
"dark" -- which quietly moved an ihasmail user onto a theme they had
never chosen, two clicks and no way to tell what had happened. It did
the same to "match system", which the toggle could not restore at all;
the comment above it conceded as much and sent people to Settings.
There is more than one way to be dark now, so the way back is
remembered: lastDarkTheme holds whichever non-light theme was last
chosen, and the toggle returns to that.
The remembering lives in update(), the single path every way of setting
a theme goes through -- the toggle, Appearance, an imported settings
file -- so a fourth way to choose one cannot forget to record it. Light
never overwrites it, since light is the side being toggled away from.
The button's label follows: "Switch to the ihasmail theme", "Switch to
your system theme", rather than claiming everything dark is "dark mode".
Confirmed in a browser, not only in tests: from a fresh profile the
round trip ihasmail -> light -> ihasmail returns to ihasmail, and
system -> light -> system returns to system, with the label naming the
destination each time.
Two cosmetics.
Picking a folder colour left the menu open, which every other action in
it does not. It closes now, the way the calendar's colour menu already
did.
The live-updates indicator was an 8px flat speck in --fg-faint, near
invisible in either theme, and it had two states where the code has
three. The push client only ever said connected or not, which cannot
tell "retrying with a backoff" from "stopped": it now reports
connecting, connected or disconnected, and the retry path says
connecting rather than going dark. pushConnected stays for the callers
that only want the boolean.
The dot is 12px and raised -- a white highlight over a solid colour with
a soft halo, so one bead reads on light and dark alike without a
per-theme variant. Green connected, amber reconnecting with a slow
pulse, red disconnected. The pulse respects prefers-reduced-motion, and
the indicator is labelled for a screen reader rather than hidden from
it, since it carries real information.
The account locale came from `x:Account/get`, which needs `sysAccountGet` — a
permission the built-in `user` role is not given, so the setting silently fell
back to the browser locale for exactly the people most likely to have set it.
Stalwart 0.16 carries the same field on `x:AccountSettings`, whose
`sysAccountSettingsGet` *is* part of that role. Both are now asked for in one
request and whichever answers wins, so admins and older servers keep working.
That pair of replies also says which generation we are talking to: only 0.16+
can parse the method name at all. About now reports that, plus the edition
from /api/account where the server offers it. It does not report a version
number because Stalwart does not publish one to clients — it hardcodes a
public "1.0.0" and keeps the real version to its SMTP internals — so the
screen says what was actually detected rather than inventing precision.
Also adds a light/dark toggle to the top bar, left of the settings button. The
stored setting is three-way, so the button acts on the theme actually on
screen: whichever one you see, a click gives you the other. Choosing "match
system" again stays in Settings › Appearance, where a three-way choice belongs.
Settings > General gains a "Default mail app" section that calls
registerProtocolHandler so mail links anywhere in the browser open ihasmail.
The browser owns the decision and there is no API to read it back, so the UI
says what it can: it records that we asked, offers "Ask again", shows a
Remove button where unregisterProtocolHandler exists, and points at the
browser's own settings. Unsupported browsers (Safari) and insecure contexts
get an explanation instead of a dead button.
The manifest now declares protocol_handlers for mailto, which is the route by
which an *installed* app can be offered by the operating system itself; the
UI says so and links the two ideas rather than promising a system-wide
default the page cannot grant.
Mailto parsing is now one function (parseMailto in lib/address.ts) instead of
three hand-rolled copies in AppShell and MessageView. It follows RFC 6068:
recipients from the path, the to= header or both, case-insensitive headers,
"+" as space, and tolerant of malformed escapes. That fixes Cc and Bcc being
silently dropped, and draftFromMailto escapes the body so a mailto: URL from
an untrusted page reaches the composer as text rather than markup.