Commit Graph
96 Commits
Author SHA1 Message Date
jcoffey-dev 0fe75b280b Link the documentation from the profile menu
docs.ihasmail.org is where installing, configuring and using ihasmail
are explained, and nothing in the app pointed at it. The profile menu is
where someone looks for the things that are about the app rather than
about their mail, so it goes there, above Settings, and opens in a new
tab: reading the docs is something you do beside your mail, not instead
of it.

`MenuItem` renders a real anchor when given an href, rather than a button
calling window.open. The browser's own handling of a link comes with it --
middle-click, a modifier-click, "open in new tab", the address on hover,
copying it -- none of which a button offers however carefully it is
scripted, and all of which someone expects of a menu entry that leaves the
app. Items without an href are the button they always were.

It also needed a line of CSS. The global rule for `a` coloured and
underlined the one entry that is a link, so the menu had a blue underlined
item among four plain ones, which reads as a mistake rather than a
distinction.

Verified against the mock: the entry sits above Settings, is an anchor to
https://docs.ihasmail.org with target=_blank and rel=noopener noreferrer,
and computes to the same colour, size and decoration as Settings beside
it.
2026-08-27 15:34:30 -07:00
jcoffey-dev 5a7cc5cc5a Say a missing folder is missing, not empty
A folder id the account does not have rendered the ordinary empty state --
"Nothing here. This folder is empty." That is a claim about a folder that
is not there, so a stale link read as a folder that had emptied itself
rather than one that was gone (#111).

It now goes to the inbox and says why. Inbox is the kinder landing than a
dead end for a bookmark that has outlived its folder, but swapping one
folder for another without a word would be its own small lie, so it does
not do that either.

The condition worth writing a test around is not the unknown id, it is
the one guarding it. The folder list arrives after the first paint, so for
a moment *every* id is unknown, the right one included. Without that gate
this redirects on every cold load, from the folder the reader actually
asked for, and looks exactly like a flaky link -- a worse bug than the one
being fixed and a harder one to see. `isUnknownMailbox` is a small pure
function so that case can be pinned down rather than reasoned about.

Only ever reachable from outside the app, which is why it went unnoticed:
the sidebar links to ids that exist. A bookmark to a deleted folder, or a
folder link passed between accounts, is where it bites.

Verified against the mock: an unknown id lands on the inbox with the
message and a full list rather than an empty one, and a cold load straight
into a real folder stays in that folder with nothing said.

Closes #111.
2026-08-27 15:28:26 -07:00
jcoffey-dev a2337f6ad8 Use an example address, and the right name, in the test fixtures
Two things, one of them not what it looked like.

An organizer fixture was built from a real, routable address. Every other
fixture in the codebase uses example.org or example.com, and this
repository is public, so that one was a personal address sitting in
public source for no reason -- the test asserts roles and participation
status and never reads either value. It is [email protected] now.

The names were wrong in the other direction. Three fixtures across two
files said "John Ellis", which is not the maintainer's name; it is
John Coffey. Being a name rather than a routable address, it leaked
nothing, but it was simply incorrect, and incorrect in the sort of place
nobody rereads.

The address and the name are separate questions and got separate answers:
the address is fictional because it is an address, and the name is real
because it is right. A message from [email protected] signed John Coffey
is exactly what these tests mean.

Found while checking, at the maintainer's prompting, whether the repo
leaked anything about the host it runs on. It does not -- the nginx and
deploy files here are the generic examples they claim to be, and the real
ones live in a private repository.
2026-08-27 14:15:00 -07:00
jcoffey-dev e4b6413f46 Use an example address in the participants fixture
One test built its organizer from a real, routable address and a real
name. Every other fixture in the codebase uses example.org or
example.com, and this repository is public, so the odd one out was a
personal address sitting in public source for no reason -- the test
asserts roles and participation status and never looks at either value.

Now [email protected], matching what the rest of the tests already use.

Found while checking, at the maintainer's prompting, whether the repo
leaked anything about the host it runs on. It does not: the nginx and
deploy files here are the generic examples they claim to be, and the real
ones live in a private repository. This was the only thing the search
turned up that was worth changing.
2026-08-27 14:09:12 -07:00
jcoffey-dev 4c4821b5db Ask for shareWith on mailboxes too
The third store fetching everything by asking for nothing. Same cause as
the calendars and address books a commit ago: Stalwart does not return
`shareWith` unless a client names it, so mail folders never looked shared
either.

This one has a narrow but real consequence. Sharing a mail folder is
withdrawn, because Stalwart stores the share and never delivers it, and
the only way left to clear one already made is the "Stop sharing" entry
-- which appears only when a folder looks shared. Without the property it
never did. The escape hatch built for exactly that situation could not be
reached from the situation it was built for.

Found by looking for the rest of them rather than waiting for the next
report: `ids: null` with no `properties`, across the app. The others it
turned up -- Sieve scripts, identities, the vacation response, quotas,
participant identities, push subscriptions -- have no `shareWith` to
lose, so mailboxes were the last.

The mock hides it here as well now, so all three are honest.
2026-08-27 13:33:04 -07:00
jcoffey-dev 506865ca67 Ask for shareWith, or the server does not send it
Nothing was ever badged as shared, "Stop sharing" never appeared, and the
share dialog opened on "not shared with anyone yet" over live shares. The
sharing itself was fine. The client simply never learned about it.

Stalwart does not return `shareWith` unless a client names it. A
`Calendar/get` or `AddressBook/get` with no `properties` comes back
without the field at all -- not null, not empty, absent -- confirmed
against the live 0.16.19 on a calendar and an address book that really
were shared with another account. Omit the list and there is no
`shareWith`; name it and the sharee is right there.

Both stores fetched everything by asking for nothing, and got less than
they would have by asking. They name the properties now.

The dialog is the part worth dwelling on. It seeds itself from the
`shareWith` it was handed, so it has been showing an empty sharee list on
collections that were shared -- the one screen whose whole job is
managing sharing, and the one most confidently wrong about it. Someone
looking there to see who had access, or to take it away, was told there
was nobody.

Files never had this: `fileNodeProps` has named the property since file
sharing went in, for the same reason and after the same surprise. The two
stores that fetched with `ids: null` and no properties are the two that
were blind.

The mock now omits it the same way. One that hands `shareWith` over
unasked lets a client that never asks look correct everywhere except
against a real server, which is exactly how this got here.

Verified against that mock: sharing a calendar puts the sharee in the
store, badges the row, adds "Stop sharing", and the dialog lists them --
while a `Calendar/get` with no properties still comes back without the
field, so the mock is now failing the way the server does.
2026-08-27 13:25:00 -07:00
jcoffey-dev 9544fa5f12 Let the owner stop sharing a calendar or an address book
Revoking a share meant opening the share dialog, removing each person
from it in turn, and saving. That is the right tool for changing who has
access and the wrong one for withdrawing it altogether, which is the more
urgent of the two and the one someone is likely to want in a hurry.

Both now offer "Stop sharing" in the context menu, which clears the lot
after a confirmation saying how many people lose access. It appears only
when there is something to revoke, so the menu says whether a thing is
shared as well as offering to change it.

A calendar also says it is shared now. Address books have carried that
badge since they gained sharing; calendars never did, so the only way to
find out was to open the dialog and look -- which for the owner of a
dozen calendars means opening a dozen dialogs.

Both go through the existing update paths, so a server that refuses is
reported rather than swallowed.

Verified against the mock, both kinds: sharing one shows the badge and
adds the entry, confirming clears `shareWith`, the badge goes, and the
entry disappears with it since there is no longer anything to stop.
2026-08-27 13:11:21 -07:00
jcoffey-dev 3a2f60189f Load the contacts the recipient picker is meant to show
The picker opened on "No contacts in this address book" -- about an
address book with contacts in it. Nothing was wrong with the button, and
that is why it read as one: it opened, correctly, onto nothing.

Contacts are fetched on demand. `loadAll` runs when the Contacts view
mounts, and `suggest` kicks it off itself, which is why autocomplete has
always worked from anywhere. The picker did neither, so opening a
composer without having visited Contacts first -- which is most of the
time, and every time in a fresh tab -- showed an empty list over a full
account. Anyone who had been to Contacts that session saw it work, which
is the sort of difference that reads as browser-specific when it is not.

It asks for them now, and says it is loading rather than that there are
none.

While here: the picker decided which shared books to offer on
`isSubscribed` alone. Stalwart refuses that flag on a book shared
read-only, so those are recorded in settings instead -- for an address
book it is the *only* record -- and filtering on the server's flag left
every shared book out of the picker while the sidebar showed it. Both now
ask the same question.

Verified against the mock from a genuinely cold store -- cards emptied,
`loaded` false, opening the picker as the first thing that wants them:
eight rows, from the reader's own book and a shared one, where before
there were none.
2026-08-27 13:01:01 -07:00
jcoffey-dev 25b51069a9 Keep the full copy of an email the server says changed
The reading pane emptied and refilled when a thread was marked read. On
an HTML message that is a flash to the app's own background and out
again, which is what remained of #100 once the message view stopped
rebuilding its body.

`applyChanges` dropped `fullIds` for every email the server reported as
updated, so the next read would fetch it again. But the reading pane
renders only the emails it holds in full. Dropping one took the message
out of the open thread until the refetch at the end of the same function
put it back -- and marking as read causes exactly that, because the
server echoes our own change back as an update. The gap is a round trip,
which is why it is plainly visible against a real server.

Nothing is lost by keeping the copy. RFC 8621 makes every property of an
Email immutable except `keywords` and `mailboxIds` -- the id is derived
from the content, so a body cannot change beneath one -- and both are in
LIST_PROPS, which the refresh immediately below merges over the cached
copy. The eviction only ever cost the message its place in the thread.

On the evidence, since I got this wrong once already by trusting a
reproduction that did not exist. This is reasoned from the code and
matched against the reported symptom -- "the pane empties and comes
back", which is precisely what removing an email from the thread and
refetching it looks like. It is not backed by a local reproduction: the
mock never ran this path at all, because `Email/set` announced nothing
and `Email/changes` always answered empty. That is being fixed
separately, and it is why every check made here has been against a server
that never reported the change being made.
2026-08-27 12:42:38 -07:00
jcoffey-dev c0fc0083ff Stop rebuilding the message body when it is marked read
Marking a thread read redrew the message pane: the mail vanished and came
back, white to dark to white on an HTML message that brings its own
colours, half a second after the reader started reading it. Worst with
auto-mark set to "immediately", where it happens the moment the thread
opens (#100).

The pane was not re-mounting. The *body* was being thrown away and built
again, and the reason is one dependency.

`HtmlBody` writes the message into a shadow root in an effect, and that
effect had the click handler in its dependency list. The handler is a
`useCallback` over `onShowImages`, which the parent passed as an arrow
created inline, so it was a new function on every render -- and therefore
the effect ran on every render, and every render replaced the rendered
message with an identical one. Marking as read is exactly such a render:
the store hands back a new email object and the thread re-renders.

The listener now lives in its own effect. It is attached to the shadow
root rather than to the contents, which survives the rewriting anyway, so
a handler that changes identity costs a listener swap and nothing else.
`onShowImages` is stable now too, but the split is the fix: it is what
makes the body immune to the next handler that changes.

This also stops the quoted-text toggle collapsing. `setQuoteOpen(false)`
lives in the same effect and had been resetting on every render, so
expanding a quote and waiting for the timer put it away again.

Measured rather than watched, since a flicker is exactly the thing an eye
will agree with you about. Holding a node from inside the shadow root
across the transition, on the same three-message thread with the delay at
0: before, 21 childList mutations on the root and the held node detached
and replaced; after, no mutations at all and the same node still
attached. Clicking a blocked image still reveals remote images, which is
what the moved listener is for.

Closes #100.
2026-08-27 12:17:49 -07:00
jcoffey-dev 5e5bec31b7 Remember an added address book when the server will not
"You are not allowed to modify this address book." That is Stalwart's
answer to a sharee subscribing to a book shared read-only, and it is a
fair one: `isSubscribed` lives on the collection rather than on the
reader, so adding one is a write to the *owner's* account. The identical
write on a shared calendar is accepted. The difference is the server's.

So the flag is still asked for first -- a preference the server holds is
one every client agrees about -- and when it is refused the answer goes
in the reader's own synced settings instead, as `addedShares`, keyed by
account and collection. Either record counts as added, and the rule has
a test of its own because three components ask the question and they
must not drift apart.

Two things about how this hid. The refusal arrives as a *successful*
response with the id in `notUpdated`, so the version that ignored it saw
nothing wrong and the button simply did nothing -- fixed a commit ago,
and it is what turned "the + does nothing in Firefox" into a sentence
from the server. And it cannot be seen from the owner's account at all,
where the write succeeds: it took two browsers signed in as two accounts
to find, which is why it survived every check made from one.

The mock refuses the same write for the same reason. One that accepted
it would have gone on agreeing with the belief that shipped.

Verified against it: adding the shared book is refused by the server,
recorded in settings, and the book moves to "Shared with me" with its
contacts reaching the To field; removing undoes all three; and it
survives a full page reload, which is the point of putting it where the
settings live rather than in this tab.
2026-08-27 12:06:18 -07:00
jcoffey-dev 3416a41de9 Say so when the server refuses a subscribe
Adding a shared address book did nothing in one browser and worked in
another. The button was not broken; the refusal was invisible.

Subscribing is the one call in the app that writes to somebody else's
account, so it is the one a perfectly healthy server is entitled to say
no to -- and JMAP says no to a `/set` by answering successfully with the
object listed in `notUpdated`. Neither subscribe method looked. The
promise resolved, the code carried on, the re-read came back unchanged,
and the row stayed exactly where it was with nothing said.

Every other `/set` in this codebase reads `notUpdated` and raises. These
two were written without it, which is the whole defect: not a wrong
answer, an unread one.

Both now check it and say what the server said, which is the thing that
was missing -- whatever the underlying refusal turns out to be, it can be
read off the screen instead of guessed at from which browser was in
front of you.
2026-08-27 11:32:49 -07:00
jcoffey-dev 5f32d3d82c Choose recipients from the address books
Addressing a message worked only if you already knew the name you were
half-way through typing. Autocomplete answers "finish this for me"; there
was no answer to "who is there?", which is the question someone has when
they open a compose window and want the person from the team list whose
surname they cannot summon.

The To row now opens the address books -- from a button beside Cc and
Bcc, where someone thinking about recipients is already looking, and from
the To label itself for anyone who tries that first. Search across every
book or narrow to one, tick as many people as the message needs, and send
them to To, Cc or Bcc. Picking for a field that is hidden opens it, since
a Bcc dropped somewhere invisible is worse than no Bcc.

Every address is its own row rather than every person. Somebody with a
work address and a personal one is a choice the writer has to make, and a
picker that listed the card and quietly took the first address would be
making it for them.

Shared books are in it on the same footing as the reader's own -- that
being the point of having added them -- with the account named on each
row, so it is never a mystery whose list a name came from. Books that
have not been added contribute nothing, the same rule the To field
already follows.

Verified against the mock: the picker lists the reader's contacts and the
shared book's, each row naming its source; ticking one of each and
choosing Cc opens the Cc row with both in it.
2026-08-27 11:19:45 -07:00
jcoffey-dev 0215255280 Add a shared calendar or address book, rather than being given it
An account linked for its files also offered its calendar and its address
book, and neither had been shared. That was not ihasmail inventing them:
asked about the other account, the live 0.16.19 returns every calendar
and every book it holds, each with full rights -- read, write, share,
delete, all true. There is nothing in the rights to tell "shared with me"
from "reachable at all", because the server does not distinguish them.

`isSubscribed` does, and it is the field JMAP has for exactly this: it
came back false on all of them. So a shared calendar or book is listed
under "Shared with me" once the reader has added it, and under "Available
to add" until then, with one button either way.

Nothing unsubscribed contributes anything. A calendar that has not been
added draws no events, and a book that has not been added lends no cards
to the To field -- which is the one that mattered most, since it is the
difference between offering a colleague's contacts and offering a
stranger's without anyone having asked.

The mock's shared calendar and address book now arrive unsubscribed, the
way the real server hands them over, so the adding is exercised rather
than skipped; and its `Calendar/set` and `AddressBook/set` route by
account, since subscribing to somebody else's is a write to their
account and the mock had nowhere to put it.

Verified against the mock: the shared calendar sits under "Available to
add" with no events in the grid, adding it moves it to "Shared with me"
and its events appear, removing it undoes both; and `suggest("katherine")`
finds nothing until the shared book is added, then finds her.
2026-08-27 11:16:30 -07:00
jcoffey-dev 270fb3d32c Shared calendars in the calendar, and no more account switcher
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.
2026-08-27 10:57:27 -07:00
jcoffey-dev 350f4f4197 Put address books in the left pane, other people's included
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.
2026-08-27 10:40:50 -07:00
jcoffey-dev 1e2db95577 Stop offering to share mail folders, and let a share be removed
Sharing a mail folder does nothing. `Mailbox/set` takes the `shareWith`
map, `Mailbox/get` reads it back, and the folder never appears for the
account it was shared with -- confirmed on the live 0.16.19 with a folder
shared read-only to another account on the same server, which never saw
it. Stalwart's sharing documentation lists calendars, address books and
file storage; mail folders are not among them. Nothing anywhere reports a
failure, so a client that trusts what it reads back shows the share as
live for ever, which is what happened.

The entry point is withdrawn. Address book sharing goes with it on a
report that it behaved the same way -- not reproduced, and contradicted
by Stalwart's own docs, so that one is expected back; it is out because
offering a share nobody can verify was worse than the gap. Files and
calendars are untouched.

Removing a share was impossible, for a reason worth writing down. The
dialog rendered the list of who a thing was shared with *inside* the
branch that runs when the directory has principals to offer. A server
with `allowDirectoryQueries` off returns none -- that is the default, and
it is how these shares came to be made in the first place -- so the
dialog showed one line of hint and nothing else. The share was there, and
there was no way to see it, let alone remove it. The list is now rendered
whatever the directory says; only the control for adding somebody new
depends on having somebody to add.

So the withdrawn entry points do not strand what they created: a folder
or book already shared still offers "Stop sharing", which is the one
thing you want when the share is invisible everywhere else.

The API was never the problem, which is worth recording since it was the
first guess: `shareWith: null` is accepted and clears the map, tested
against the live server on the stuck folder, which is now unshared.
2026-08-27 10:28:05 -07:00
jcoffey-dev 52299ce8ef Attach a file that is already in Files
Attaching meant uploading, even when the file was sitting in the account
already -- picking it off disk again to send the server a copy of what it
was holding.

The composer can now attach from Files. A blob the account can already
see needs no upload at all: an attachment carrying a `blobId` is what a
forward produces, so the send path has always known what to do with one.
Attaching a large file the server is already storing now costs nothing
and takes no time.

A file in an account somebody *shared* is different, because blobs belong
to the account they were uploaded to and a draft in yours cannot
reference one in theirs. Those are fetched and uploaded to your account,
and the picker says so before you attach rather than leaving someone
wondering why one file was instant and another was not.

The picker borrows the Files store, so it browses what Files browses,
shared accounts included, and puts the file manager back where it was on
the way out -- a detour through somebody's shared folder to find an
attachment should not leave Files somewhere else afterwards.

Verified against the mock, and worth recording how, because the first
attempt measured nothing: `client.upload` uses XMLHttpRequest, since it
reports progress, so a counter wrapped around `fetch` sees no uploads
whether or not any happen and agrees with you either way. Counted at
XHR instead: attaching one's own file issues no upload, and attaching a
shared one issues exactly one, to the reader's own account.
2026-08-27 10:18:49 -07:00
jcoffey-dev ad94efb65b Reach shared folders from Files, not the profile menu
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.
2026-08-27 10:13:58 -07:00
LINUXexpert.org 9f4c0c3351 Merge pull request #94 from LINUXexpert-org/fix-account-routing
Keep your own settings out of someone else's account
2026-08-27 10:02:11 -07:00
jcoffey-dev e014521fb6 Keep your own settings out of someone else's account
Switching to an account somebody shared pointed the whole app at it. The
rule was "use the selected account if it can do this", and a shared file
account can, by definition, do files.

ihasmail keeps its settings in the account's Files -- that is what makes
them follow you between devices -- so changing any setting while looking
at somebody's shared folder wrote `settings.json` into *their* storage,
creating the `ihasmail` folder there to do it. Signature images went the
same way, and push registration would have gone to whichever account was
on screen. Reading someone else's data by mistake is bad; writing yours
into theirs is worse, and one line was doing both.

There are two questions, and they had one answer:

  - what am I looking at -- follows the switcher, because switching to a
    shared account is how you read what was shared
  - what is mine -- never does

So `accountFor` keeps the first meaning and `ownAccountFor` is the
second, used by settings sync, signature images and push. A `??
accountId` fallback in `loadStoredSignature` went with it: the reader's
own signature, reached through whoever happened to be selected.

A third rule was hiding in the first. A capability the selected account
does not advertise fell back to the selected account anyway, so a session
naming no primary for something aimed it at whoever was selected --
somebody else. It now answers with nothing, which is honest: the feature
is unavailable, rather than pointed at a stranger.

What this does not settle is whether the mail, calendar and contacts the
switcher appeared to offer were ever really reachable, or only asked for
and refused. That depends on what Stalwart advertises on a shared
account, which needs a look at a sharee's session; if it advertises
capabilities nobody shared, more is needed here than routing.
2026-08-27 09:59:00 -07:00
jcoffey-dev 2360e40733 Show the folder tree in a shared account
Switching to an account somebody had shared showed an empty folder tree.
Their files listed perfectly well; the sidebar beside them was blank,
with nothing to say why.

Switching accounts cleared `nodes` and `children` and stopped there. So
`treeLoaded` stayed true from the account before -- the sidebar only asks
for folders when it is false, and it never asked again -- while `dirIds`
still named the previous account's folders, which no longer resolved
against the cleared `nodes`. An empty tree either way, and no error,
because nothing had failed.

The fields that belong to one account are now named in one place,
`emptyForAccount`, and the test asserts the whole set rather than the
ones that come to mind. The bug was not bad logic, it was a field nobody
remembered when two more were added a commit earlier, and asserting the
set is the only guard that survives the next two.

Found by the person it was built for, on a real share between two
accounts, which is where it was always going to show up: the tree is
built from a query that had already run for their own account, so it
only breaks on the switch.
2026-08-27 09:51:41 -07:00
jcoffey-dev f70eb184c2 A folder tree, and dragging things into it
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.
2026-08-27 09:19:54 -07:00
jcoffey-dev 6566f4c2d3 Share files and folders with other people
Calendars and address books have been shareable since JMAP Sharing went
in; Files never was, though Stalwart treats file storage as a first-class
thing to share and ihasmail has carried the types for it all along.
`FilesRights` and `FileNode.shareWith` were already declared -- what was
missing was asking for the property, offering the dialog, and saying so
in the list.

Checked against the live 0.16.19 first, read-only, because building a
picker against a mock that agrees with you proves nothing:

  - `FileNode/get` returns `shareWith`, and `myRights` carries all six
    rights, `mayShare` among them and true on one's own nodes. So the
    menu entry has a real right to gate on -- unlike folder sharing,
    which is offered ungated because `MailboxRights` has no such right
  - `Principal/query` answers now that `allowDirectoryQueries` is on:
    six individuals, no groups
  - `ShareNotification/get` is implemented, which is worth knowing for
    later; nothing here reads it yet

The editor preset grants read, add files and edit contents, and stops
there. Rename and delete stay with whoever shared the folder: someone
given a folder to work in should not be able to rename the thing they
were given, or delete it out from under the person who shared it. Both
are still there to tick by hand.

One finding is worth a test of its own, and has one. Stalwart answers
`shareWith` as `{}` for a node shared with nobody, not `null` -- every
unshared node in a live account came back that way. A truthiness test on
the property is therefore true for every node the server has ever
returned, and the badge driven by it would report the whole account as
shared while being, technically, about the right property. `isShared`
counts keys, and the test says why.

Verified against the mock end to end: sharing Documents with a principal
as Editor persists `mayRead`, `mayAddChildren` and `mayModifyContent` and
nothing else, the badge appears on that folder and not on the file beside
it, and re-opening the dialog shows the saved rights rather than an empty
form -- which is what proves `fileNodeProps` is really asking for the
property.
2026-08-27 09:07:13 -07:00
jcoffey-dev 650ba0020b Hold the opening scroll while the conversation settles
Opening an already-read conversation stopped 39px short of the bottom,
every time (#89). The messages were all there and one scroll fixed it,
but the pane was not where it meant to be.

The scroll runs in an effect, which is too early. Message bodies go into
shadow roots from the child effects underneath it, and the images in
those load later still, so the pane goes on growing after the scroll has
already happened -- and `scrollIntoView` clamps to the scroll range as it
stands the moment it is called. The read-thread fallback aims at the last
message, which no thread has the room to lift to the top, so that clamp
*is* the whole of the range. Measuring it before the images landed
measured it short.

So the target is now held against the top of the pane while the thread
settles: a ResizeObserver over the children of the scroller re-aligns it
whenever one of them changes height.

The hold ends the instant the reader touches the pane -- wheel, pointer,
touch or any key -- and after two seconds regardless. A pane that
re-scrolls under someone who has started reading is far worse than one
that lands short, so it lets go on the first sign of them rather than
waiting for the content to stop changing.

Verified against the mock, on the same already-read seven-message thread,
eight opens each way: before, all eight landed at scrollTop 96 of a 135
range; after, all eight land at 135. The #87 cases are unchanged -- an
unread message mid-thread still comes to rest flush against the top of
the pane, and a thread whose first message is the unread one still stays
at 0 with the subject in view. Scrolling or pressing a key during the
hold leaves the pane exactly where it was put.

One correction to #89 while I am here: it reported the pane sometimes not
moving at all. That was an artifact of measuring in a background tab,
where Chrome suspends rendering and clamps timers -- the behaviour in a
visible tab is the deterministic 39px above. The issue is real; that one
observation in it was not.
2026-08-27 07:11:16 -07:00
jcoffey-dev d64249b46d Open a conversation on its first unread message
Selecting a thread put you at the newest message. Anything unread above
that sat off the top of the pane with nothing to announce it, and the
only way to find out was to scroll up -- by which time the auto-mark-read
timer had marked the whole thread read anyway, so scrolling up meant
scrolling up to mail already counted as seen (#87).

Opening at the bottom is right when there is nothing to catch up on and
wrong the moment there is. The pane now opens on the oldest message that
was unread when the thread was opened, and falls back to the newest when
the thread has already been read.

mbunkus's out-of-order case is the one that rules out guessing at a
position. A participant whose server could not connect for hours
delivers a message long after it was written, and it lands in the middle
of a conversation that has already moved past it -- so "second to last",
or any other fixed offset from the end, finds nothing. Reading the
unread set is the only thing that does.

Two cases leave the pane where it is:

  - a single message, which is already the whole pane
  - the first unread being the first message, where the top of the pane
    shows it anyway, together with the subject; scrolling to it would
    push the subject off for nothing

It reads the set captured when the thread was opened rather than live
`$seen` state, for the same reason expansion does (#69): the mark-read
timer must not change the shape of what you are looking at. That also
makes the landing stable, because everything above the first unread
message is a collapsed row of fixed height -- nothing up there reflows
after the scroll.

The mock grows a thread that reproduces it: seven messages with the
unread one second, four more behind it. Verified against it. Opening the
thread lands the unread message flush against the top of the pane at
scrollTop 158; the old scroll to the newest message put it at 445, with
287px of the message -- header, sender and unread bar included -- above
the fold. On a thread whose first message is the unread one the pane
stays at 0 with the subject in view, where before it would have scrolled
333. Once the thread is read, reopening it goes back to the newest
message.
2026-08-27 06:35:26 -07:00
jcoffey-dev 1527ebffc8 Remove the two-factor entry point from the login form
The field never worked here: Stalwart takes a TOTP code only through an
OAuth flow, so a client posting a username and password had nothing to
send it to. Offering the button advertised a feature the login path
cannot honour, so it comes out until the flow works end to end.

The login store still takes a totp argument and the server still accepts
one; the form now passes an empty string, which the server reads as no
code given. A failed sign-in no longer reveals the field, and the
invalid-credentials message drops its mention of a verification code.
2026-08-26 16:41:51 -07:00
jcoffey-dev c6db19de19 Stop offering to turn two-factor authentication on
Settings > Security & sessions still had the full enrolment flow --
QR code, secret, "Set up" -- for something that cannot be signed in
with. Turning 2FA on there took a working account and made webmail
unreachable from any device not already signed in, because ihasmail
has nowhere to send a TOTP code: Stalwart accepts one through an
OAuth flow alone and offers no password grant (#75). The one mercy
was that enabling reseals the current session onto a fresh app
password, so the browser doing it stayed in -- and the next sign-in
elsewhere did not.

So the enrolment path is gone until sign-in with a code works.

Turning 2FA *off* stays. It is a plain registry write, it was
verified live on 0.16.19, and anyone already enrolled -- here or in
Stalwart's own settings -- needs a way back. That control is now the
whole section, and it appears only for an account that has 2FA on;
everyone else no longer sees the heading at all.

The password form keeps its authenticator-code field on the same
condition, since Stalwart demands a code on every credential write
once 2FA is on.

Nothing changes on the server: /api/account/2fa/begin and /enable
are untouched and still tested, ready for the OAuth work that makes
them usable. The sign-in page's code field is also untouched -- it
already explains itself and points at app passwords.

README no longer advertises enrolment by QR code, and the roadmap
entry says which direction the setting still moves.
2026-08-26 16:21:27 -07:00
LINUXexpert.org 96ec790d58 Merge pull request #82 from LINUXexpert-org/hide-identities
Hide identities from the compose picker
2026-08-26 15:44:55 -07:00
jcoffey-dev 133036a6c5 Hide identities from the compose picker
An account using a unique address per service, on a server with an alias
domain, ends up with every local part twice over and a From picker
nobody can use -- while only ever sending from a handful (#73).

Identities can now be hidden from that picker, from Identities &
signatures. Hiding is presentation only: the identity still exists,
still receives, and stays listed and editable, the way an unsubscribed
folder is still a folder. That framing is mbunkus's own, and it is the
right one -- this is a UI preference, not a change to the account.

Three things it refuses to do, because a sender picker with nothing
usable in it is worse than a cluttered one:

  - it will not hide the identity a draft is already using, which would
    leave the select with no matching option and move the From line
    under the writer
  - it will not hide the default, which is what a new draft starts on;
    the button is disabled there and says why
  - if every identity is somehow hidden -- reachable only through
    settings sync, since the UI will not do it -- they are all offered
    again

The setting syncs, so the picker looks the same on every device, which
follows from DEVICE_KEYS being a list of exceptions rather than a list
of what travels.

Verified against the mock with four identities and one hidden: the
picker offers the other three, the hidden address is gone from
composing, the default's hide button is disabled, and the row says the
identity still receives.
2026-08-26 15:36:35 -07:00
jcoffey-dev d4b39c06d6 Stop the reading view rearranging itself as you read
Opening a conversation with several unread messages showed them all
expanded, each with its unread bar. The moment the auto-mark-read timer
fired, every one of them collapsed except the last, and the bars
vanished -- so the messages you had just been given were taken away
again, and the only record of which ones they were went with them (#69).

Both came from the same place: expansion and the bar were derived from
`$seen`, live. Marking read on the server changed what the view thought
it was looking at.

Marking read is not the problem. Opening a thread is the signal that you
are reading it, and mbunkus was explicit that turning the setting off is
not the answer he wants. What was wrong was letting a change *this view
caused* alter its own shape underneath the reader.

The thread now remembers which messages were unread when it was opened,
and uses that for expansion and for the bar. The set only grows while a
thread is open -- a message arriving unread joins it -- and is discarded
on the way to another thread. The server still gets marked read on the
timer, exactly as before, and the message list still updates.

It is accumulated during render rather than in an effect. It is derived
purely from the messages already in hand and adding an id twice does
nothing, while an effect would repaint a frame later -- which is the
flicker this exists to remove.

Verified against the mock with markReadDelay at 0, the harshest setting,
where the timer fires immediately: six seconds after opening a
three-message thread, the server reports all three seen while the view
still shows all three expanded with their bars. Before, two of the three
would have collapsed in the first instant.
2026-08-26 15:28:51 -07:00
LINUXexpert.org 6e58c22807 Merge pull request #80 from LINUXexpert-org/fix-keyboard-delete
Move focus off a row that has been deleted
2026-08-26 15:18:08 -07:00
LINUXexpert.org 6e59f59d18 Merge pull request #79 from LINUXexpert-org/fix-from-select-dark
Theme the dropdown a native select paints for itself
2026-08-26 15:17:17 -07:00
jcoffey-dev 2e33b4c467 Move focus off a row that has been deleted
Two complaints in #71, one cause. Deleting from the keyboard left
`focusId` pointing at a row that was no longer in the list, and nothing
moved it.

The confirmation appearing "every other message": `targetIds()` falls
back to the focused id, so the second `#` re-targeted the message the
first one had just deleted. The optimistic update had already moved that
message into Deleted Items, so it read as a permanent delete -- and a
permanent delete always confirms, whatever "Confirm before deleting" is
set to. The dialog was correct about the message it was asked about; it
was asked about the wrong one.

`k` jumping to the top: `moveFocus` reads `ids.indexOf(focusId)`, which
was -1 for the departed row, and -1 is treated as "before the first
row". Adding -1 to that clamps to 0.

Both explain why the mouse was fine: clicking sets focus to a row that
exists.

Focus now moves to whatever slid into the deleted row's place, honouring
"After archiving or deleting" -- the row below by default, the one above
when set to newer -- and clears when the folder empties. `moveFocus`
also no longer reads a missing row as index 0; it falls back to where
the list thinks it is.

Verified in the browser against the mock, since arithmetic tests cannot
prove the wiring: with confirmation off, two deletes in a row both go
through silently, focus stepping e4 to e5 to e6 as rows close up; then
`k` moves up exactly one instead of to the top of the list.
2026-08-26 15:15:48 -07:00
jcoffey-dev 087c856a46 Theme the dropdown a native select paints for itself
The From picker's drop-down rendered a light background under light
text, unreadable in any dark theme (#70).

A native <select>'s popup is painted by the browser from the element's
own colours, not the page's. `.from-select` is deliberately transparent
so it sits flush in the composer's From line, which left the popup with
no background of its own: the browser drew a light one while the text
kept the app's light foreground.

Fixed by styling `option` rather than the control, so the popup gets a
background without the closed select gaining a box. Verified: the select
stays transparent, the options are now --bg-elev on --fg, which is
12.5:1 where it was light on light.

Scoped to every select rather than this one. Nothing in the app styled
options anywhere, so this was not one broken dropdown but the first one
anybody happened to open in the dark -- and the next transparent select
would have arrived with the same bug.
2026-08-26 15:09:30 -07:00
jcoffey-dev 9c37af7b07 Never overwrite filters we could not read
Adding a filter from a message reported success while the script on the
server never held more than two rules (#76). Rules were being destroyed,
and the confirmation was a lie.

Three links, each defensible alone:

  1. load() recorded a *failed* blob fetch as `contents[id] = ""`.
  2. sieveToRules("") returns [] -- "this script has no rules", which is
     indistinguishable from "we could not read this script".
  3. Saving rewrites the whole script from that baseline, so every rule
     already in it was deleted. The write itself succeeded, which is why
     the UI said so.

No fetch failure was even required: rules() did `contents[id] ?? ""`, so
a script whose content had not loaded yet read as empty too. And
saveScript cached the content it had just written and then called
load(), which replaced the whole map -- discarding it if the refetch
came back short.

The fix is to keep "unknown" and "empty" apart at every step:

  - a failed fetch leaves the key absent rather than storing ""
  - load() merges rather than replacing, so a reload cannot throw away
    what saveScript just wrote
  - rules() returns null for content it does not have, which every
    caller already treats as "do not touch this script"
  - saveRules refuses outright when the baseline is unknown. Refusing is
    recoverable; overwriting is not.

rules() now also reports whether the script was read, because "written
by hand" and "could not be read" want different advice -- one is
permanent, the other is a reload away, and telling someone the wrong one
sends them hunting for a problem they do not have.

Ruled out on the way: the rule codec round-trips fine, eight rules in
and eight out. sieveToRules reads the `# rule:` JSON comments rather
than parsing Sieve, so the generated script's shape was never the issue.
2026-08-26 15:03:53 -07:00
jcoffey-dev d98c425a9a Stop pretending the two-factor field can work
Signing in with a two-factor code failed with a bare 401 and "Invalid
credentials", which sent the user off to check a password that was
perfectly good (#75). It cannot work, and the app already knew.

Stalwart accepts a TOTP code only through an OAuth flow -- its own web
interface is an OAuth client, which is why signing in *there* succeeds --
and it offers only the authorization-code and device flows. There is no
password grant, so a client holding a username and password has nowhere
to exchange them plus a code for a token. The concatenated
`password$code` form this README claimed was accepted is not a route the
server has, and appears never to have been. What was verified live on
0.16.19 was enabling and disabling 2FA, never signing in with a code.

The contradiction was already in the codebase: turning 2FA *on* mints an
app password and reseals the session onto it, precisely because a plain
password stops working from that moment. The sign-in page was the one
place still assuming otherwise.

Three changes, no new capability:

  - A 401 on a sign-in that carried a code now says what is happening
    and where to go instead, and says the password is probably fine.
    A sign-in without a code is untouched, so an ordinary typo still
    reads as an ordinary typo.

  - The field stays, and is honest about itself. Removing it would leave
    someone with 2FA finding nothing at all, which is worse than finding
    a field that explains the situation and points at app passwords.

  - The README's claim is corrected rather than quietly dropped, and
    real 2FA support is written into the roadmap as what it is: an OAuth
    implementation, handing sign-in to Stalwart and holding a refresh
    token instead of a sealed password.
2026-08-26 14:46:08 -07:00
jcoffey-dev 7726665a48 Say what "even when ihasmail is closed" actually means
Web Push works, confirmed end to end against the live 0.16.19: with
Chrome open and every ihasmail tab closed, a notification arrives
immediately and names the sender and subject.

But "closed" means ihasmail, not the browser, and the switch did not say
so. Web Push is delivered over a connection the browser holds, so
something of it has to be running.

Observed on 2026-08-26, with Chrome fully quit and "Continue running
background apps" off: nothing arrived until Chrome was started again, at
which point the queued notification was delivered. Turning that setting
on keeps a process alive and restores immediate delivery.

Worth knowing that the queue is not indefinite -- a Web Push message
carries a TTL, and one that expires before the browser comes back is
dropped rather than delivered late. Being an installed PWA does not
change any of this on a desktop; it changes the window, not who holds
the connection. On Android it would, since the push service can wake the
browser from cold.

None of this is ihasmail's to fix. It is what Web Push is, and the only
thing worth doing about it is not implying otherwise -- which the
notification switch was quietly doing.
2026-08-26 14:13:19 -07:00
jcoffey-dev 2263aa494f Send a filter the server can read
Enabling background notifications failed with "Invalid filter". The
subscription asked to be notified about mail matching:

    filter: { inMailbox: null, notKeyword: "$seen" }

`inMailbox: null` meant "the inbox" in my head and nothing at all to
Stalwart, which needs a mailbox id there. It refused the whole
subscription, so the feature did not work at all for anyone who tried
it.

The Inbox's id is now passed in and used. Where it is not known the
condition is left out rather than sent empty: notifying more widely is a
worse default than filtering to the Inbox, but it is a working one, and
sending a malformed filter is not a fallback.

Two reasons this got out, both worth fixing rather than just the bug:

  - The tests checked the properties list and its ordering, and never
    looked at the filter. There is now one that walks every condition
    and fails on a null or undefined value, for both the known-inbox and
    unknown-inbox cases.

  - The mock accepted it happily, so nothing local disagreed with the
    code. It now refuses a filter condition with a null value and
    answers "Invalid filter.", which is what the live server said.
    Reproduced: the old payload is rejected, the new one accepted.
2026-08-26 14:01:56 -07:00
jcoffey-dev 96bc7b53d7 Notifications that arrive when ihasmail is closed
ihasmail's notifications came from EventSource, which lives exactly as
long as a tab does -- so "desktop notifications" has always quietly
meant "while you are looking". That switch is now labelled as much, and
a second one does the thing people assumed the first one did.

Stalwart 0.16 signs Web Push with VAPID (RFC 9749) and can put the
message itself in the payload (draft-ietf-jmap-emailpush). The server
pushes straight to the browser's own push service: ihasmail's server is
not in the delivery path, there is no relay to run, and nothing beyond
the browser vendor's endpoint that Web Push requires of everyone.

Checked against the live 0.16.19 before any of this was written, because
an advertised capability is not a configured one:

  - the session publishes a real applicationServerKey, so no key
    generation or server configuration is needed
  - PushSubscription/get answers an ordinary user rather than refusing
  - emailpush is advertised, and its draft defines a filter, an ordered
    properties list and an urgency -- so the payload can carry sender and
    subject, and the server drops properties from the end when it will
    not fit rather than failing the notification

Three things this gets right that are easy to get wrong:

  - The verification handshake. A JMAP subscription delivers nothing
    until the client echoes back a code the server pushed, and the
    service worker cannot answer it -- no credentials in that context.
    It forwards the code to a tab, or leaves it in the cache when no tab
    was open to forward it to.

  - Key encoding. The W3C Push API produces unpadded base64url and
    Stalwart 0.16 was fixed to accept exactly that, so nothing here pads
    on the way out. The VAPID key needs padding on the way *in* for
    atob; getting that backwards fails at subscribe() with an opaque
    error, so it lives in one named function with tests.

  - Sign-out. A subscription belongs to the account, not the session.
    Without tearing it down, a shared machine keeps notifying for a
    mailbox nobody is signed into -- which is somebody else's mail.

The mock models the JMAP half, including refusing padded keys and
non-https endpoints, and creating subscriptions *unverified*. Delivery
cannot be mocked -- it runs through the browser vendor's real push
service -- but a mock that marked a subscription verified on creation
would let a client ship without the handshake, and the symptom in
production is "registered, and silent".

Not verified end to end: an actual notification arriving. That needs a
real browser, a real push service and real delivery, so it is live
testing or nothing.
2026-08-26 13:51:09 -07:00
jcoffey-dev 18e493bcd1 Delete all spam, and call folders what the server calls them
Junk Mail can now be emptied in one action, the way every other mail
client offers it: a banner across the top of the folder, and an item in
both the folder's right-click menu and the list's own menu.

The messages are destroyed rather than moved to Deleted Items. Routing
spam through the bin on its way out leaves you with the same problem in
a different folder, and "delete all spam" means gone everywhere else. So
the dialog says it before you commit, and there is no undo.

emptyMailbox already did the hard part -- walking a folder a page at a
time so it survives maxObjectsInSet, which a Deleted Items of 5192 once
did not. All that changed is which folders it will accept. The guard
stays in the store rather than living only in the menus, so a fourth
caller cannot empty the Inbox by asking nicely.

The three entry points share one helper, because three dialogs warning
about a permanent deletion in three slightly different ways is how one
of them ends up not warning at all. A folder with nothing in it offers
the item greyed out rather than hiding it, so it is where you expect it
to be next time.

Folder naming is fixed in the same commit because it changed the same
file, and because testing this is what surfaced it. Two problems, one
cause:

  - The mock called its folders "Trash" and "Sent". Stalwart's defaults
    follow the Exchange convention -- "Deleted Items", "Sent Items" --
    so anything built from a folder's name read differently against the
    mock than against a real server, and every screenshot in the README
    showed a folder list no user has.

  - Worse, and in shipping code: the undo toast took a hardcoded label
    in preference to the folder's actual name, so deleting a message
    announced "moved to Trash" on a server whose folder is called
    "Deleted Items", and reporting spam said "moved to Spam" where it is
    "Junk Mail". The one message whose job is saying where mail went was
    naming somewhere that does not exist. It now prefers the mailbox's
    own name and keeps the hardcoded word only as a fallback.

Verified against the mock: the banner appears only in Junk and only with
something to delete, the dialog counts and pluralises, the messages are
destroyed and Deleted Items stays empty afterwards, the banner
disappears once the folder is, the item greys out when empty, Archive is
offered neither, and Trash still says "Empty Deleted Items".
2026-08-26 12:00:42 -07:00
jcoffey-dev 95e56303f7 Pin the theme settings as account-level, not per-browser
Both `theme` and `lastDarkTheme` sync, so a theme chosen on one machine
-- and the toggle's way back to it -- are the same everywhere. That is
already true, by the rule that DEVICE_KEYS is a list of exceptions and
anything else syncs by default, but nothing said so.

The existing test cannot say it: it derives what should sync from
DEVICE_KEYS, so moving one of these into that list would move the
expectation with it and still pass. These name the two keys outright.
2026-08-26 11:37:47 -07:00
jcoffey-dev 9689ac8aae Let the toggle come back to the theme you were on
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.
2026-08-26 11:34:45 -07:00
jcoffey-dev 8487f561f6 Add the ihasmail theme, and make it the default
A dark theme carrying ihasmail.org's palette: a teal-navy ground rather
than the blue-slate of the plain dark theme, with the orange the logo's
cat is drawn in doing the work of the star and the warning colour. The
values are the site's own, read from its stylesheet rather than picked
by eye.

It is a theme rather than an accent because it changes backgrounds,
borders and text as well as the highlight -- an accent could not.

It rides on data-theme="dark" and adds data-palette="ihasmail" on top,
so the eleven dark-only rules further down the stylesheet keep applying
without being duplicated for a second dark theme. Specificity then does
something deliberate: the palette block is 0,2,0 and the accent variants
are 0,3,0, so a chosen accent still wins over it -- and because the
default accent ("teal") has no rule of its own, ihasmail.org's accent is
what shows until someone picks another. Verified both ways in a browser.

It is now what a new account starts on, so the app looks like itself
before anyone has chosen anything. Only a default: a stored theme always
wins, which leaves everyone already using ihasmail where they are, since
the setting is saved whether or not they deliberately picked it.

While here, the theme-color meta tag was fixed. There were two, both
carrying media attributes, and applyTheme looks for
:not([media]) -- so it matched neither and the browser chrome had never
followed the chosen theme at all, only what the OS preferred. One tag
now, updated from JS, starting at the default theme's background so the
first paint is right too.

Contrast measured rather than assumed, against the theme's own
background: text 14.5:1, muted 8.6:1, faint 6.4:1, accent 8.0:1, link
9.8:1, star 7.9:1, accent-on-accent 8.4:1. All AA or better.
2026-08-26 11:28:47 -07:00
jcoffey-dev 48805c8072 Split the sign-in footer over two lines
The build and where to find it were running together on one line. The
name and version now stand on their own, with the two links beneath:

    ihasmail v2.16.61
    ihasmail.org · AGPL-3.0 source

"by" is gone with the restructuring. It was doing attribution work that
the line no longer needs -- ihasmail.org is a LINUXexpert.org project
site, so pointing at it says the same thing without the preposition, and
the README badge is where the credit properly lives.

One <p> with a break rather than two paragraphs: .foot carries a 20px
margin-top, which a second one would repeat as a gap under the button.
2026-08-26 10:48:14 -07:00
jcoffey-dev ecc6c42bf1 Point the sign-in footer at ihasmail.org
The link went to linuxexpert.org, which is the organisation rather than
the project. Someone reading a sign-in page and following it wants the
software's own site, not its publisher's.

Checked that https://ihasmail.org answers 200 before putting it in front
of everyone who reaches the instance -- a dead link on a sign-in page is
worse than none.

The README badge still credits LINUXexpert.org, which is the right place
for attribution and was not part of this.
2026-08-26 10:43:11 -07:00
jcoffey-dev 4618f7656e Show the version on the sign-in page too
It was only on the About page, which is behind a sign-in -- so the one
place a version is most often wanted, when something is wrong and nobody
can get in, was the one place it could not be read.

It sits next to the AGPL source link deliberately. Section 13's offer is
for the source of *this* build, and naming the build is what turns that
into something a person can act on rather than a link to whatever main
happens to be. A bug report can name the build without signing in, too.

Worth being deliberate about: this is pre-authentication, so anyone who
can reach the instance can read it. That is a real disclosure -- it tells
an unauthenticated visitor exactly which build to look up. It is being
accepted rather than overlooked: ihasmail is AGPL with its source already
linked from that same line, so the version narrows nothing that reading
the source would not, and the sign-in page already names the software.
2026-08-26 10:16:59 -07:00
jcoffey-dev bf70ba9df0 Give builds a version number
ihasmail called itself "2.0" on the About page and "2.0.0" from
/api/health, both hardcoded, in four places that had drifted from each
other and from anything meaningful. A build now says what it is:

  ihasmail v2.16.57
             |  |  |
             |  |  the pull request the commit came from
             |  the Stalwart generation this build targets -- 0.16
             ihasmail's own major

The first two are the version in the root package.json, so there is a
single place to bump them, and 16 becomes 17 when ihasmail moves to
Stalwart 0.17. Dropping 0.15 is what makes that middle number honest:
while two generations were supported it could not have been either.

The pull request number comes from git at build time and is never
written back into the tree. It cannot be: it does not exist until the
pull request has merged, so a committed version would always describe a
merge that had not happened yet, and every open branch would collide on
the same line. A commit that did not come through a pull request carries
the last number plus its own short SHA -- 2.16.57+g1fa6578 -- which says
it is past that pull request rather than quietly claiming to be it.

.dockerignore excludes .git on purpose, so an image build cannot work
any of this out. It takes --build-arg IHASMAIL_VERSION instead, which
the build stage bakes into the bundle and the runtime stage keeps as an
environment variable for the server. Left out, it falls back to the base
version from package.json rather than failing -- so a version with no PR
number on it means whoever built the image did not pass one.

scripts/ is copied into the runtime image because the server resolves
its version through it. There is no git in there to ask, which is the
fallback's whole purpose.

Verified: 2.16.57 in the bundle and from /api/health on a dev checkout;
the same after a real docker build --build-arg, from inside the
container; and 2.16.0 rather than a crash when the arg is left off.

Note for deploying: ihasmail-deploy.sh on the host builds without the
argument and will produce 2.16.0 until it passes
--build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)".
2026-08-26 10:00:29 -07:00
jcoffey-dev 94bf42cfda Drop Stalwart 0.15 support
ihasmail spoke to two generations of Stalwart that are less alike than
their version numbers suggest: 0.16 replaced the REST management API with
JMAP registry objects, changed the shape of FileNode, split its rights
up, and moved configuration into the store. Carrying both meant 34 branch
points across nine files, a 92-line compatibility shim whose only job was
telling them apart, a parallel REST implementation of every credential
operation, and a mock that had to model both.

The branches were not the real cost. The cost was that a wrong answer
about which generation had answered always had somewhere to fall back to,
so it failed quietly rather than loudly: one capability looked for in the
wrong place downgraded every real 0.16 server onto the 0.15 path, which
posted the current password to an endpoint 0.16 had removed, reported the
wrong generation on About, and ran Files on the older code. It reached
production and was recorded as verified when it was not. The mock
mirrored the same wrong placement, which is why the tests agreed.

Removed: the filenode compatibility shim, the dual "registry" | "legacy"
backend in account.ts, the pre-0.16 generation in AccountInfo and
everything that read it, the mock's LEGACY mode and dev:mock:legacy, and
the three test files that existed only to pin 0.15 behaviour.

Sign-in now refuses an older server by name, once, rather than letting
Files, the account locale and credentials each fail in their own way with
nothing connecting them. It says the credentials were fine -- someone
hitting this has typed a correct password, and telling them otherwise
sends them round in circles -- and names the tag to build from. Four
tests cover it, including that no session cookie is minted and that bad
credentials on such a server are still a plain 401.

Two fallbacks went that were not strictly about 0.15, and both for the
same reason the removal is happening. Files no longer answers a refused
filter or sort by fetching every node in the account, which would hide a
real fault behind a performance cliff nobody would notice. And the app
folder lookups now filter on parentId/isTopLevel alone and match names
client-side, since `name` is not a filter Stalwart is known to implement
and one it does not know fails the whole query rather than being ignored.

The last release that runs on 0.15 is tagged stalwart-0.15-support.

Verified against the mock end to end: sign-in, the Files tree on the 0.16
path with the app folder hidden, and self-service credentials over the
registry. 226 web + 75 server tests pass; typecheck and build clean.
2026-08-26 09:51:03 -07:00
jcoffey-dev 0a9218f622 Keep settings with the account, not the browser
Every setting lived in localStorage, so none of them travelled between
devices. The sharpest edge is the default identity: with none set the
address that sorts first wins, so mail goes out from an address the
recipient may not recognise -- and someone who sets it at work finds it
unset at home, with nothing to say so. Reported in #54.

They now live in a settings.json in the account's own JMAP Files, beside
the signature images already kept there. ihasmail itself stays stateless:
no volume, no database, nothing to back up separately, and the settings
are covered by whatever backs up the mail store.

localStorage stays as a cache rather than the source of truth, so the
first frame is painted from it and the file corrects it a moment later.
A private window has no cache and shows defaults for that one frame,
which is the trade for not gating the whole app on a network round trip.

Not everything should follow the account. A list-pane width picked on a
27" monitor is wrong on a laptop, and the notification toggles track a
permission the browser grants per-device, so claiming it elsewhere would
be a lie. Those stay local, written as a list of exceptions so that a
setting added later syncs by default -- which is what adding one almost
always means.

Writes are coalesced: update() fires on every frame of a splitter drag,
so a change waits 3s and the newest value wins. A tab going away flushes
first, as does signing out, so a setting changed seconds before either
is not lost.

The ihasmail folder is now hidden from the Files view, contents and all.
Hiding the folder alone would have been worse than showing it: the tree
attaches a node whose parent is missing to the root, so the signature
images would have spilled into the top level as if the user had put them
there. Those images have been visible since signatures shipped.

Requires 0.16 -- FileNode/query cannot see directories before that. On
0.15 settings stay local exactly as they were.

Verified against the mock end to end: folder create, blob upload, node
create, read back, update, re-read. Not yet exercised against the live
0.16.19.
2026-08-26 08:19:57 -07:00