Compare commits

...
64 Commits
Author SHA1 Message Date
LINUXexpert.org 6bbe2448c4 Merge pull request #121 from LINUXexpert-org/immutable-positioning
Lead with what makes it different
2026-08-27 23:14:50 -07:00
jcoffey-dev 490b15e8c6 Lead with what makes it different
"Gmail-class webmail" describes the client, and every webmail says something
like it. What no other webmail for Stalwart says is that the container has
nothing to persist: one optional write path, and with IMMUTABLE=1 switched on,
no volume and no writable root filesystem at all.

The Gmail comparison still earns its place -- it is what tells someone what the
client feels like to use -- so it stays, one clause later, where it describes
the app rather than the product.
2026-08-27 23:12:30 -07:00
LINUXexpert.org f2e0cb6326 Merge pull request #120 from LINUXexpert-org/reload-without-waiting-for-the-user
Notice a new build without being told
2026-08-27 22:55:00 -07:00
jcoffey-dev 8f9d253939 Reload even when there is an unsent draft
Holding the reload back while a compose window had unsaved text protected the
text, but it meant a tab could sit on a build the server no longer runs for as
long as someone left a draft open -- which is not automatic, and automatic is
the point.

So the reload is unconditional once the versions differ, and this will
sometimes take an unsent draft with it. The trade is deliberate: a tab talking
to a server it does not match is the worse failure, and it fails quietly.
2026-08-27 22:51:33 -07:00
jcoffey-dev fedd6ed161 Notice a new build without being told
Checking only on a 401 was not automatic, just deferred. It needs the tab to
make a request, so one left open and idle went on running the old build until
somebody touched it -- which is exactly the thing that cannot be relied on.

The obvious signal turned out to be the wrong one, and testing is what showed
it. A deploy kills the EventSource behind /api/events, which looks like the
perfect cue, except it arrives while the container is still being replaced: the
check that follows cannot reach the server, fails, and is never retried.
Waiting for the stream to come back instead does not work either, because the
session died with the old container, so the reconnect is answered with a 401
and never reaches "connected" at all. The drop is still watched, since it costs
nothing and sometimes lands late enough to be useful, but nothing depends on it.

What the guarantee rests on is a slow poll while the tab is visible, plus a
check when it becomes visible again. Neither cares what the stream is doing or
whether anyone is at the keyboard. /api/health touches nothing upstream, so a
minute between checks costs one small request per open tab.

Reloading is now something that happens to people rather than something they
ask for, which makes it able to destroy work. A compose window holds text that
has not reached the server, and after a deploy it cannot be saved at all --
the session went with the container. Reloading would be the difference between
signing in again and pressing send, and losing what was written. So anything
holding such state can say so, and compose does; the tab stays on the old build
until the draft is dealt with, and catches up on the next check afterwards.
2026-08-27 22:45:16 -07:00
LINUXexpert.org a3dc7e017c Merge pull request #119 from LINUXexpert-org/reload-on-new-build
Reload when the server is running a newer build
2026-08-27 22:24:18 -07:00
jcoffey-dev e327df818a Reload when the server is running a newer build
Being signed out and picking up a new version are separate things, and only
the first was happening. An immutable instance holds sessions in memory, so a
deploy signs everyone out -- but a 401 only swaps the view to the sign-in form,
client-side. The tab keeps the bundle it already has, and the old JavaScript
goes on talking to the new server until someone happens to reload by hand.

The pieces for fixing it were already there. index.html is served no-cache and
the assets under it are content-hashed and immutable, so a reload is all it
takes; Vite bakes the build's own version in as APP_VERSION; and /api/health
reports the server's. What was missing was something to compare them.

The check runs on a 401 rather than on a timer, which is the moment it matters
and costs one small request. It compares versions rather than reloading on
every 401, so an ordinary session expiry still lands on the sign-in form with
the page intact. And it runs before the sign-in form is shown rather than
after, because reloading a form someone has already started typing into would
throw the password away.

Failing to reach the server is not a reason to throw away what is on screen, so
anything other than a clear answer leaves the page alone. The version that was
reloaded for is remembered for the session, so a server that keeps reporting a
version the bundle does not match -- a stale proxy cache, a half-finished
deploy -- cannot put the tab in a reload loop.
2026-08-27 22:21:32 -07:00
LINUXexpert.org d6aa4d543a Merge pull request #118 from LINUXexpert-org/deploy-immutable-mode
Deploy immutably when asked to
2026-08-27 22:06:06 -07:00
jcoffey-dev 4cd7b895e9 Deploy immutably when asked to
IHASMAIL_IMMUTABLE=1 runs the container the way the README's "Running
immutably" section describes: read-only root filesystem, no volume, sessions
held in memory. Until now that shape could be run by hand but not deployed --
the run line mounted the data volume unconditionally, so a redeploy would have
quietly put a mutable container back.

The switch is one variable and nothing else. IMMUTABLE=1 is passed to the
server too, which checks the claim rather than believing it, so a half-applied
switch refuses to start instead of looking fine until the next redeploy signs
everyone out. SESSION_FILE is cleared with -e rather than by editing the
environment file, because -e wins over --env-file; that keeps going back a
matter of changing the same one variable:

  IHASMAIL_IMMUTABLE=0 ./ihasmail-deploy.sh --yes

which reproduces the previous run line exactly. The named volume is never
touched in either mode, so the sessions that were in it when the switch was
thrown are still there to come back to.
2026-08-27 22:04:13 -07:00
LINUXexpert.org 37bf96409d Merge pull request #117 from LINUXexpert-org/immutable-session-seam
Let the container run with nothing writable
2026-08-27 21:52:02 -07:00
jcoffey-dev f72c67864e Let the container run with nothing writable
The server writes to one path and no other: SESSION_FILE, from sessions.ts.
Everything else it touches on disk it only reads. So a container with a
read-only root filesystem already works -- except that `VOLUME ["/data"]`
quietly undid it. Docker acts on that directive: a container started without
`-v` gets an anonymous volume mounted there anyway, writable even under
`--read-only`. It persisted nothing across a redeploy, since each new container
got a fresh empty volume, and it left an orphan behind every time one was
replaced. Deployments that want the sessions to survive already say so
themselves -- docker-compose.yml and deploy.example.sh both mount a named
volume -- so removing the line changes nothing for them.

IMMUTABLE=1 asserts that this is how the instance is running. It is checked
rather than believed: the server refuses to start if SESSION_FILE is still set,
or if the filesystem it is installed on turns out to be writable. Left
unchecked the misconfiguration is silent, because persisting sessions is
best-effort -- a read-only /data costs one warning at the first sign-in and
nothing more until the instance is replaced and everyone is signed out.

SessionBackend names what the rest of the server asks of a session store, and
`sessions` in app.ts is typed as it. Nothing changes today; SessionStore is
still the only implementation. It is there so the OAuth work is written against
the interface rather than the class, and so the interface can record which of
its methods a stateless backend could satisfy alone: create, resolve, reseal
and destroy each touch one session, while listForUser and destroyAllForUser
have to reach sessions other than the caller's. The second of those carries the
guarantee that changing a password invalidates the sessions still holding the
old one, which is why it needs a registry -- Stalwart's token registry, once
sign-in goes through OAuth.
2026-08-27 21:48:23 -07:00
LINUXexpert.org 312a833d78 Merge pull request #116 from LINUXexpert-org/docs-menu-link
Link the documentation from the profile menu
2026-08-27 15:36:51 -07:00
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
LINUXexpert.org 05be820be4 Merge pull request #115 from LINUXexpert-org/unknown-mailbox
Say a missing folder is missing, not empty
2026-08-27 15:31:17 -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
LINUXexpert.org b4082d5bb2 Merge pull request #114 from LINUXexpert-org/screenshot-recipients
Take a screenshot of the recipient picker
2026-08-27 14:53:10 -07:00
jcoffey-dev 6efac64b37 Take a screenshot of the recipient picker
The site says you can pick recipients by reading the address books rather
than remembering a name. It had no picture of that, and a claim nobody
can see is a claim nobody believes.

Taken from the composer step, where a composer is already open. The
obvious place was a step of its own later in the run, and that failed:
navigating back to the mail list after the run has been through Files
does not reliably render rows within any wait I was willing to give it.
Worth knowing rather than rediscovering -- the earlier inbox step goes to
the same route and is fine, so it is the state left behind, not the
route.

The shot ticks two people before firing, since a picker photographed
empty shows a list rather than a choice.

The filters step still times out waiting for its editor, as it did before
this change. Everything up to it is written; filters.jpg is whatever the
last successful run left. Still undiagnosed, and still not this.
2026-08-27 14:41:16 -07:00
LINUXexpert.org 8cc12b8f56 Merge pull request #113 from LINUXexpert-org/fixture-example-address
Use an example address, and the right name, in the test fixtures
2026-08-27 14:17:38 -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
LINUXexpert.org 3c417f070c Merge pull request #112 from LINUXexpert-org/screenshot-files
Take the files screenshot with the others
2026-08-27 13:56:40 -07:00
jcoffey-dev e3de0bd500 Take the files screenshot with the others
It was the one shot taken by hand, and it outlived two rewrites of the
view it was meant to show -- a picture of a single-pane file list, still
in the docs after the pane grew a folder tree beside it. Nothing was
wrong with the process except that there wasn't one.

The script takes it now, expanding the tree and opening a folder first,
since a screenshot of Files with nothing open is a screenshot of a list
rather than of a file manager.

Anything the docs show should come from the mock. Otherwise it describes
whatever the app looked like on the day somebody had a screenshot tool
open, which is how this one got three versions out of date without
anybody noticing.

The other shots in docs/screenshots are refreshed by the same run. The
filters step timed out waiting for its editor, so filters.jpg is the
older one; that shot is untouched by anything here and the failure is not
diagnosed, which is worth knowing before the next person runs this and
assumes they broke it.
2026-08-27 13:54:15 -07:00
LINUXexpert.org 06b89111df Merge pull request #110 from LINUXexpert-org/mailbox-sharewith
Ask for shareWith on mailboxes too
2026-08-27 13:35:06 -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
LINUXexpert.org e14fc36785 Merge pull request #109 from LINUXexpert-org/ask-for-sharewith
Ask for shareWith, or the server does not send it
2026-08-27 13:27:46 -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
LINUXexpert.org f83157464c Merge pull request #108 from LINUXexpert-org/stop-sharing
Let the owner stop sharing a calendar or an address book
2026-08-27 13:13:21 -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
LINUXexpert.org 298264aeb8 Merge pull request #107 from LINUXexpert-org/picker-loads-contacts
Load the contacts the recipient picker is meant to show
2026-08-27 13:03:30 -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
LINUXexpert.org 31239ed9be Merge pull request #106 from LINUXexpert-org/keep-full-copies
Keep the full copy of an email the server says changed
2026-08-27 12:44:58 -07:00
LINUXexpert.org 6a98dd22fd Merge pull request #105 from LINUXexpert-org/mock-reports-changes
Make the mock report what changed
2026-08-27 12:43:04 -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 cd402a6ce4 Make the mock report what changed
Two silences, and between them the whole change-reconciliation path was
untestable here.

`Email/set` never announced anything. A real server pushes a state change
after a set and the client acts on it -- `Email/changes`, then the store
deciding what to do with the answer. The mock said nothing, so that path
simply did not run.

And `Email/changes` returned three empty arrays whatever had happened. So
even when it was asked, the answer was that nothing had changed.

Together they meant every version of the mark-read code has been checked
against a server that never reported the change being made. That is how
#100 reached production, and why the fix for it could be verified in the
message view -- where the flicker partly was -- while whatever remains
stayed invisible, because the code that runs when the server answers back
has never run here at all.

The mock now records what each set created, updated and destroyed against
the state it happened in, answers `Email/changes` from that log, and
broadcasts afterwards the way Stalwart does.

This is a mock change on its own. It fixes nothing and is not meant to:
it makes a path testable that was not, which is the prerequisite for
finding what is left of #100 rather than guessing at it. I had a theory
about `fullIds` eviction and reverted it -- three attempts to reproduce
the symptom against this mock failed, which was itself the finding.
2026-08-27 12:40:09 -07:00
LINUXexpert.org 453a62115b Merge pull request #104 from LINUXexpert-org/stop-rebuilding-the-message-body
Stop rebuilding the message body when it is marked read
2026-08-27 12:20:33 -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
LINUXexpert.org 8a3e0b9954 Merge pull request #103 from LINUXexpert-org/remember-added-shares
Remember an added address book when the server will not
2026-08-27 12:08:13 -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
LINUXexpert.org 04ec57058a Merge pull request #102 from LINUXexpert-org/say-why-subscribe-failed
Say so when the server refuses a subscribe
2026-08-27 11:34:45 -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
LINUXexpert.org d9995cd0b4 Merge pull request #101 from LINUXexpert-org/subscribe-shares
Add shared collections deliberately, and pick recipients from the address books
2026-08-27 11:21:22 -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
LINUXexpert.org 8d55652587 Merge pull request #99 from LINUXexpert-org/shared-calendars
Shared calendars in the calendar, and no more account switcher
2026-08-27 11:02:48 -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
LINUXexpert.org 25fd6404f2 Merge pull request #98 from LINUXexpert-org/shared-address-books
Put address books in the left pane, other people's included
2026-08-27 10:43:20 -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
LINUXexpert.org 006190d523 Merge pull request #97 from LINUXexpert-org/withdraw-mail-sharing
Stop offering to share mail folders, and let a share be removed
2026-08-27 10:30:36 -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
LINUXexpert.org 88f9474b24 Merge pull request #95 from LINUXexpert-org/shared-with-me
Reach shared folders from Files, not the profile menu
2026-08-27 10:24:33 -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
LINUXexpert.org cf9474ce35 Merge pull request #93 from LINUXexpert-org/fix-tree-on-account-switch
Show the folder tree in a shared account
2026-08-27 09:56:02 -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
LINUXexpert.org c9531c577c Merge pull request #92 from LINUXexpert-org/files-tree
A folder tree, and dragging things into it
2026-08-27 09:29:47 -07:00
LINUXexpert.org fb789bc36f Merge pull request #91 from LINUXexpert-org/share-files
Share files and folders with other people
2026-08-27 09:29:20 -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
LINUXexpert.org 24ee502532 Merge pull request #90 from LINUXexpert-org/hold-the-opening-scroll
Hold the opening scroll while the conversation settles
2026-08-27 07:13:54 -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
LINUXexpert.org 32227722a7 Merge pull request #88 from LINUXexpert-org/open-thread-at-first-unread
Open a conversation on its first unread message
2026-08-27 06:42:21 -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
66 changed files with 3424 additions and 187 deletions
+12
View File
@@ -28,8 +28,20 @@ SESSION_TTL=43200
SESSION_REMEMBER_TTL=2592000
# Where to persist sessions so restarts don't log everyone out (optional).
# Leave it empty to hold sessions in memory only, which is what an immutable
# instance does -- see IMMUTABLE below.
SESSION_FILE=./data/sessions.json
# Assert that this instance is running as an immutable container: read-only
# root filesystem, no durable state of its own. It is checked rather than
# taken on trust -- the server refuses to start if SESSION_FILE is set, or if
# the filesystem it is installed on turns out to be writable. Off by default.
# Running one looks like:
# docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
# The cost today is that a restart signs everyone out, since there is nowhere
# left to keep the sessions. Removing that cost is what the OAuth work is for.
# IMMUTABLE=1
# Upstream timeouts / limits
UPSTREAM_TIMEOUT=30000
MAX_UPLOAD_BYTES=52428800
+9 -1
View File
@@ -37,7 +37,15 @@ COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/web/dist ./web/dist
RUN mkdir -p /data && chown -R node:node /data /app
USER node
VOLUME ["/data"]
# No `VOLUME ["/data"]`. It reads like documentation for where the session file
# goes, but Docker acts on it: a container started without `-v` gets an
# anonymous volume mounted there anyway, and that mount stays writable even
# under `--read-only`. So the directive quietly put a writable hole in a
# container meant to be immutable, and left an orphaned volume behind every
# time one was replaced -- while never persisting anything across a redeploy,
# since each new container got a fresh empty volume of its own. Deployments
# that want the sessions to survive say so themselves: docker-compose.yml and
# deploy.example.sh both mount a *named* volume at /data, which is unaffected.
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1
CMD ["node", "server/dist/index.js"]
+4
View File
@@ -22,6 +22,10 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
[`stalwart-0.15-support`](https://github.com/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
- **Sharing a mail folder is accepted and does nothing.** `Mailbox/set` with a `shareWith` map is applied, `Mailbox/get` reads it back, and the folder never appears for the account it was shared with — **confirmed live on 0.16.19 (2026-08-27)** with a folder shared read-only to another account on the same server, which never saw it. Stalwart's own sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing reports a failure at any point, which is the whole problem: the share is stored, so a client that trusts what it reads back shows it as live for ever. The entry point is withdrawn. A folder that is *already* shared still offers **Stop sharing**, because a share nobody can see is exactly the one you want to be able to clear, and there is no other way to. File sharing is unaffected and works end to end.
- **Address book sharing works, and was briefly withdrawn by mistake.** It was taken out alongside mail folders on 2026-08-27 on a report that it behaved the same way; the report was mistaken and the feature was put back the same day. Nothing was ever shown to be wrong with it, and Stalwart documents address books as shareable. Recorded because the withdrawal is in the history and would otherwise read as a finding. Shared books now appear in the Contacts pane under "Shared with me" rather than behind an account switch, and their contacts are offered when addressing a message.
- **Stalwart lets a sharee subscribe to a shared calendar but not a shared address book.** Subscribing is a write to the *owner's* account -- `isSubscribed` lives on the collection, not on the reader -- and 0.16.19 refuses it for a book shared read-only: `AddressBook/set` answers successfully with the id in `notUpdated`, `forbidden`, *"You are not allowed to modify this address book."* The identical `Calendar/set` on a shared calendar is accepted. **Confirmed live on 0.16.19 (2026-08-27)** from a second account holding both shares, which is the only place it shows: from the owner's own account the write succeeds and everything looks fine. So ihasmail asks the server first, because a preference the server holds is one every client agrees about, and keeps the answer in its own synced settings (`addedShares`) when the server will not. Two things this cost, both worth remembering: the refusal arrives as a *successful* response, so the code that ignored `notUpdated` saw nothing wrong and the button simply did nothing; and it is invisible from the owner's account, so it took two browsers signed in as two accounts to find at all. The mock now refuses the same write for the same reason, since one that accepted it agreed with the belief that shipped.
- **`shareWith` is not returned unless a client asks for it by name.** A `Calendar/get` or `AddressBook/get` with no `properties` comes back without the field at all — not null, not empty, absent — **confirmed live on 0.16.19 (2026-08-27)** against a calendar and an address book that were genuinely shared with another account: omit the list and there is no `shareWith`; name it and the sharee is right there. Every consequence was silent. Nothing was badged as shared, "Stop sharing" never appeared because nothing looked shared, and the share dialog opened on *"not shared with anyone yet"* over a live share — so the one screen that existed to manage sharing was the one most confidently wrong about it. Files never had this, because `fileNodeProps` had always named the property; calendars, address books and mail folders fetched everything and got less. Mail folders mattered in a way of their own: sharing one is withdrawn, and the only way to clear a share already made is a **Stop sharing** entry that appears when a folder looks shared — so without the property the escape hatch for the exact situation it was built for was invisible. The mock now omits it the same way, since one that hands it over unasked lets a client that never asks look correct everywhere except against a real server.
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
+28 -2
View File
@@ -11,12 +11,14 @@
# ihasmail
**A fast, friendly, Gmail-class webmail for [Stalwart Mail Server](https://stalw.art) — built on JMAP, from the ground up.**
**Immutable webmail for [Stalwart Mail Server](https://stalw.art) — a container
with nothing to persist, and a Gmail-class client on top of it.**
Mail, calendars, contacts, files and filters in a responsive single-page app
that works equally well on a desktop monitor and a phone. It talks only JMAP
(plus Stalwart's blob/upload/EventSource endpoints) — no IMAP, no SMTP, no
database.
database, and with `IMMUTABLE=1` no writable filesystem either. Everything
durable belongs to Stalwart; the container is disposable.
| | |
| --- | --- |
@@ -47,6 +49,7 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
- **Contacts** — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete
- **Settings that follow the account**, not the browser — kept in a `settings.json` in the account's own JMAP Files, so ihasmail itself stays stateless
- **Runs read-only** — one optional write path, and with it switched off the container needs no volume and no writable root. `IMMUTABLE=1` is checked at startup rather than trusted, so a half-applied switch refuses to boot instead of failing quietly. See [Running immutably](#running-immutably)
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
@@ -83,6 +86,29 @@ Full instructions, TLS, and every environment variable:
[Installing](https://docs.ihasmail.org/install/) ·
[Configuring](https://docs.ihasmail.org/configure/).
### Running immutably
The server writes to exactly one path, the optional `SESSION_FILE`. Clear it
and there is nothing left to write, so the container can run with no writable
filesystem at all:
```bash
docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
```
`IMMUTABLE=1` is an assertion the server checks at startup rather than a switch
that changes what it does: it refuses to start 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 this mode today, since sessions have
nowhere to live across a restart. Removing it means moving the session upstream
into a token Stalwart itself issues and can revoke, which is what the OAuth work
in [ROADMAP.md](ROADMAP.md) is for.
## Architecture
```
+1
View File
@@ -6,6 +6,7 @@ rest is here because the answer is "no", not "not yet".
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
- Translations (strings are English-only for now)
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Reported as [#75](https://github.com/LINUXexpert-org/ihasmail/issues/75)
+27 -3
View File
@@ -41,8 +41,23 @@ HOLD="${IHASMAIL_HOLD:-$APP/.deploy-hold}"
# for a reverse proxy in front (see Caddyfile.example / nginx.example.conf).
NAME="${IHASMAIL_NAME:-ihasmail}"
BIND="${IHASMAIL_BIND:-127.0.0.1:8090}"
# Named volume for /data (sessions).
# Named volume for /data (sessions). Unused when running immutably.
VOLUME="${IHASMAIL_VOLUME:-ihasmail-data}"
# Run the container immutably: read-only root filesystem, no volume, sessions
# held in memory only. See "Running immutably" in the README. The server is told
# the same thing through IMMUTABLE=1 and checks it, so a half-applied switch --
# the flag without the read-only filesystem, or a SESSION_FILE still pointing
# somewhere -- refuses to start here instead of looking fine until the next
# redeploy signs everyone out.
#
# The standing cost is that sessions do not outlive a deploy, because there is
# nowhere left to keep them. Going back is this variable and nothing else:
#
# IHASMAIL_IMMUTABLE=0 ./ihasmail-deploy.sh --yes
#
# The named volume is never touched either way, so whatever was in it when the
# switch was thrown is still there to come back to.
IMMUTABLE="${IHASMAIL_IMMUTABLE:-0}"
# Image repository. Each build is tagged with its version as well, so an
# earlier one can be run again without rebuilding it.
IMAGE_REPO="${IHASMAIL_IMAGE:-ihasmail}"
@@ -194,10 +209,19 @@ docker build \
-t "$IMAGE_REPO:current" \
.
RUN_ARGS=(-d --name "$NAME" --restart unless-stopped -p "$BIND:8080" --env-file "$ENVF")
if [ "$IMMUTABLE" = "1" ]; then
# -e wins over --env-file, so this clears a SESSION_FILE set there or baked
# into the image, rather than needing the environment file edited to match.
RUN_ARGS+=(--read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE=)
echo "==> restarting container -- immutable: read-only, no volume, sessions in memory"
echo " (everyone signed in is signed out; IHASMAIL_IMMUTABLE=0 puts it back)"
else
RUN_ARGS+=(-v "$VOLUME:/data")
echo "==> restarting container"
fi
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker run -d --name "$NAME" --restart unless-stopped \
-p "$BIND:8080" --env-file "$ENVF" -v "$VOLUME:/data" "$IMAGE_REPO:$TAG" >/dev/null
docker run "${RUN_ARGS[@]}" "$IMAGE_REPO:$TAG" >/dev/null
for _ in $(seq 1 "$HEALTH_TIMEOUT"); do
if health=$(curl -sf "http://$BIND/api/health"); then
+42
View File
@@ -11,6 +11,11 @@
* Restart the mock before a run. The filters shot creates rules, so a second
* run against the same mock shows them twice.
*
* The files shot was taken by hand until 2026-08-27, and had gone stale twice
* over by the time anyone noticed. Anything the docs show should be generated
* from the mock, or it describes whatever the app looked like on the day
* somebody had a screenshot tool open.
*
* Two shots are deliberately not taken here:
*
* - **mobile**, because at the tail of this sequence the app would not render
@@ -198,6 +203,26 @@ try {
})()`);
await sleep(1800);
await shot("compose.jpg");
// The recipient picker, taken here because the composer is already open. The
// site claims you can pick recipients by reading the address books rather
// than remembering a name, and this is that claim photographed. Doing it from
// a later step meant navigating back to the mail list, which turned out not
// to be reliable once the run had been through Files.
await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(x => x.getAttribute('aria-label') === 'Choose from address books');
if (b) b.click();
})()`);
await waitFor("/Choose recipients/.test(document.body.innerText)", "the recipient picker");
await evaluate(`(() => {
// Two ticked, so the shot shows a selection rather than an empty list.
for (const b of [...document.querySelectorAll('.menu-item input[type=checkbox]')].slice(0, 2)) b.click();
})()`);
await sleep(1500);
await shot("recipients.jpg");
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Cancel'); if (c) c.click(); })()`);
await sleep(600);
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => /close|discard/i.test(b.getAttribute('aria-label')||'')); if (c) c.click(); })()`);
await sleep(800);
@@ -227,6 +252,23 @@ try {
await sleep(1800);
await shot("contacts.jpg");
// --- files ---
// Was the one shot taken by hand, which is why it outlived two rewrites of
// the view it was meant to show. The tree makes it worth automating: opening
// a folder is now the difference between a screenshot of a file manager and a
// screenshot of a list.
await go("http://localhost:5173/files");
await waitFor("document.querySelector('.files-table, .files-layout')", "the files view");
await evaluate(`(() => {
// Expand the tree and open a folder, so the shot shows the pane doing its job.
const twisty = document.querySelector('.sidebar .nav-twisty');
if (twisty) twisty.click();
const folder = [...document.querySelectorAll('.sidebar .nav-item')].find(e => /Documents/.test(e.textContent || ""));
if (folder) folder.click();
})()`);
await sleep(1800);
await shot("files.jpg");
// --- filters, with rules that actually say something ---
await go("http://localhost:5173/settings/filters");
await evaluate(HELPERS);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+2 -2
View File
@@ -3,7 +3,7 @@ import type { Context, MiddlewareHandler } from "hono";
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
import { getConnInfo } from "@hono/node-server/conninfo";
import { config } from "./config.js";
import { SessionStore, type LiveSession } from "./sessions.js";
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
import { RateLimiter } from "./ratelimit.js";
import { resolveClientIp } from "./clientip.js";
import {
@@ -34,7 +34,7 @@ import { staticHandler } from "./static.js";
type Env = { Variables: { session: LiveSession } };
export const sessions = new SessionStore(config.sessionFile);
export const sessions: SessionBackend = new SessionStore(config.sessionFile);
const loginLimiter = new RateLimiter(config.loginRateLimit, 15 * 60_000);
/**
* Credential changes verify the current password upstream, and Stalwart's
+40
View File
@@ -0,0 +1,40 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { assertImmutable } from "./config.js";
function tempRoot(): string {
return mkdtempSync(join(tmpdir(), "ihasmail-immutable-"));
}
test("IMMUTABLE refuses a configured SESSION_FILE", () => {
const root = tempRoot();
try {
assert.throws(() => assertImmutable("/data/sessions.json", root), /SESSION_FILE is \/data\/sessions\.json/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("IMMUTABLE refuses a writable root, and leaves no probe behind", () => {
const root = tempRoot();
try {
assert.throws(() => assertImmutable("", root), /is writable/);
assert.equal(existsSync(join(root, ".immutable-probe")), false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("IMMUTABLE accepts a root it cannot write to", () => {
const root = tempRoot();
try {
chmodSync(root, 0o555);
assert.doesNotThrow(() => assertImmutable("", root));
} finally {
chmodSync(root, 0o755);
rmSync(root, { recursive: true, force: true });
}
});
+56 -2
View File
@@ -1,7 +1,7 @@
import { resolveVersion } from "../../scripts/version.mjs";
import { randomBytes } from "node:crypto";
import { fileURLToPath } from "node:url";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
/** Minimal .env loader (no dependency): first match wins, never overrides real env. */
@@ -58,6 +58,58 @@ if (!appSecret || appSecret === "change-me") {
const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+$/, "");
/**
* Declares that this instance is running as an immutable container: read-only
* root filesystem, nothing durable of its own, replaceable by its image.
*
* It is a claim the process checks rather than one it takes on trust, because
* the failure it guards against is silent. Left to itself the server survives
* a read-only filesystem perfectly well -- sessions are held in memory and the
* write is best-effort, so the only sign that `SESSION_FILE` is going nowhere
* is one warning at the first login, long after anyone was watching. The
* instance looks healthy right up until it is replaced and everyone is signed
* out. Setting IMMUTABLE turns both halves of that into a refusal to start.
*/
const immutable = bool("IMMUTABLE", false);
const sessionFile = process.env.SESSION_FILE ?? "";
/**
* Refuse to run when the promise IMMUTABLE makes is not one this instance can
* keep. Exported so it can be tested without a read-only filesystem to hand.
*/
export function assertImmutable(sessionFile: string, root: string): void {
// The image sets SESSION_FILE=/data/sessions.json, so this is a deliberate
// refusal rather than a formality: running immutably means clearing it. It
// is not quietly ignored, because a configured path that silently persists
// nothing is exactly the failure this flag exists to surface.
if (sessionFile) {
throw new Error(
`IMMUTABLE is set, but SESSION_FILE is ${sessionFile}. An immutable instance keeps no durable state of its own: ` +
"pass SESSION_FILE= (empty) to hold sessions in memory, or unset IMMUTABLE.",
);
}
// And check the property itself, not just the intention to have it. Setting
// the variable while forgetting `--read-only` is the easy mistake, and it
// leaves an instance claiming a guarantee it does not have.
const probe = resolve(root, ".immutable-probe");
let writable = false;
try {
writeFileSync(probe, "");
writable = true;
unlinkSync(probe);
} catch {
/* EROFS, or EACCES on a root we do not own: either way, not writable by us */
}
if (writable) {
throw new Error(
`IMMUTABLE is set, but ${root} is writable. Run the container with --read-only (and --tmpfs /tmp), or unset IMMUTABLE.`,
);
}
}
if (immutable) assertImmutable(sessionFile, fileURLToPath(new URL("../..", import.meta.url)));
export const config = {
isProd,
appName: env("APP_NAME", "ihasmail"),
@@ -92,7 +144,9 @@ export const config = {
secureCookies: (process.env.SECURE_COOKIES ?? "auto").toLowerCase(),
sessionTtl: int("SESSION_TTL", 12 * 60 * 60),
sessionRememberTtl: int("SESSION_REMEMBER_TTL", 30 * 24 * 60 * 60),
sessionFile: process.env.SESSION_FILE ?? "",
sessionFile,
/** True when this instance has asserted, and verified, that it is immutable. */
immutable,
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
imageProxy: bool("IMAGE_PROXY", true),
+157 -19
View File
@@ -26,6 +26,13 @@ const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1";
/** What the session advertises, matching Stalwart's own 30 days. */
const MAX_DELAYED_SEND = 86400 * 30;
const ACCOUNT = "a1";
/** An account somebody has shared with the demo user. See the session below. */
const SHARED_ACCOUNT = "a2";
const SHARED_CAPS: Obj = {
"urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {},
"urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {},
"urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {},
};
const USER = process.env.MOCK_USER ?? "[email protected]";
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
@@ -137,6 +144,25 @@ addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: id
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true });
// A thread whose unread message is not the last one: someone's server queued
// their reply for hours, so it landed after messages that answer it and sits in
// the middle of the conversation. Opening this thread at the newest message
// left that reply above the fold until the mark-read timer swept it (#87).
{
const subj = "Compiler timings for the release";
const t = addEmail({ from: ["Grace Hopper", "[email protected]"], subject: subj, daysAgo: 6, mailbox: "inbox", html: true });
const tid = t.threadId as string;
const reply = (o: { from: [string, string]; daysAgo: number; mailbox: string; to?: string; unread?: boolean; html?: boolean }) =>
addEmail({ ...o, subject: `Re: ${subj}`, threadId: tid, inReplyTo: `${t.id}@mock` });
reply({ from: ["Alan Turing", "[email protected]"], daysAgo: 5.5, mailbox: "inbox", unread: true });
// Long enough after the unread one that the thread scrolls: opening at the
// bottom put four messages between the reader and the mail they had not read.
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 5, mailbox: "sent", html: true });
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 4.5, mailbox: "inbox" });
reply({ from: ["Margaret Hamilton", "[email protected]"], daysAgo: 4, mailbox: "inbox", html: true });
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 3.5, mailbox: "sent" });
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 3, mailbox: "inbox", html: true });
}
// Invitation email
{
const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mock//EN\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:inv-1@mock\r\nDTSTAMP:20260820T100000Z\r\nDTSTART:20260825T140000Z\r\nDTEND:20260825T150000Z\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Ada Lovelace:mailto:[email protected]\r\nATTENDEE;CN=Demo User;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:${USER}\r\nLOCATION:Room 4B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
@@ -153,6 +179,12 @@ const identities: Obj[] = [
];
let vacation: Obj = { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null };
const sieveScripts: Obj[] = [];
/* A calendar in the shared account, so "Shared with me" and a colleague's
events appearing in the grid can be exercised. Read-only, as a share is. */
const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: false, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }];
const sharedEvents: Obj[] = [];
const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events);
const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars);
const calendars: Obj[] = [{ id: "c1", name: "Personal", description: null, color: "#0f766e", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }, { id: "c2", name: "Work", description: null, color: "#2563eb", sortOrder: 1, isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }];
function rightsCal() { return { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }; }
const events: Obj[] = [];
@@ -165,19 +197,43 @@ const events: Obj[] = [];
events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` });
events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null });
events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" });
// Two in the shared account, so a colleague's calendar has something in it.
sharedEvents.push({ id: "sv1", calendarIds: { c9: true }, "@type": "Event", uid: "sv1", title: "Grace: release planning", start: local(d(1, 10)), timeZone: tz, duration: "PT1H", showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
sharedEvents.push({ id: "sv2", calendarIds: { c9: true }, "@type": "Event", uid: "sv2", title: "Grace: on leave", start: local(d(4, 0)).slice(0, 10) + "T00:00:00", duration: "P1D", showWithoutTime: true, timeZone: null });
}
const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }];
const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true } }];
const abRights = (write = true) => ({ mayRead: true, mayWrite: write, mayShare: write, mayDelete: write });
const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: {}, myRights: abRights() }];
/* A book in the shared account, so "Shared with me" and addressing a message
from somebody else's contacts can be exercised at all. Read-only, which is
what a share usually is. */
const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: false, shareWith: {}, myRights: abRights(false) }];
const sharedCards: Obj[] = [
{ id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
{ id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
];
const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
const cards: Obj[] = people.slice(0, 6).map((p, i) => {
const [given, surname] = p[0]!.split(" ");
return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined };
});
const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" }));
const fileNodes: Obj[] = [
{ id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), role: "documents" },
{ id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() },
{ id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() },
{ id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, role: "documents" },
{ id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
{ id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
];
/* What the shared account holds. Its own nodes, so opening the share in Files
shows something different from the reader's own folders rather than the same
list under another name. */
const sharedFileNodes: Obj[] = [
{ id: "s1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Team plans", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
{ id: "s2", parentId: "s1", nodeType: "file", blobId: putBlob("shared notes", "text/plain"), size: 12, name: "roadmap.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
];
/** The node list an account owns. */
const nodesFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedFileNodes : fileNodes);
function fr() {
return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
}
@@ -323,6 +379,19 @@ function enforceLimits(name: string, args: Obj): void {
const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
/*
* Stalwart does not return `shareWith` unless a client asks for it by name: a
* `/get` with no `properties` comes back without the field at all. Confirmed on
* 0.16.19 (2026-08-27) against a calendar and an address book that really were
* shared. The mock handing it over unasked meant a client that never asked
* still saw every share, and the one place that did not -- the real server --
* showed nothing shared at all.
*/
function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
if (a.properties) return res;
return { ...res, list: res.list.map(({ shareWith: _drop, ...rest }) => rest) };
}
function genericGet(list: Obj[]) {
return (a: Obj) => {
const ids = a.ids as string[] | null | undefined;
@@ -401,7 +470,7 @@ const handlers: Record<string, Handler> = {
const list = ids.filter((id) => id === ACCOUNT).map((id) => ({ id, name: USER, locale: MOCK_LOCALE, timeZone: null }));
return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => id !== ACCOUNT) };
},
"Mailbox/get": genericGet(mailboxes),
"Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never,
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
"Email/query": (a) => {
@@ -416,7 +485,22 @@ const handlers: Record<string, Handler> = {
return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((e) => e.id), total: list.length, limit };
},
"Email/get": (a) => genericGet(emails)(a),
"Email/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
/*
* Real changes, not an empty answer.
*
* This used to return three empty arrays whatever had happened, so the
* client's whole reconciliation path -- `Email/changes`, then deciding what
* to do with what came back -- never ran against the mock. A bug living in
* that path could not be reproduced here at all, which is how one reached
* production and survived being "fixed" once (#100). The log below is what
* the real server can answer from.
*/
"Email/changes": (a) => {
const since = Number(a.sinceState ?? 0);
const relevant = emailChanges.filter((c) => c.state > since);
const pick = (k: "created" | "updated" | "destroyed") => [...new Set(relevant.flatMap((c) => c[k]))];
return { accountId: ACCOUNT, oldState: String(a.sinceState ?? "1"), newState: String(state.n), hasMoreChanges: false, created: pick("created"), updated: pick("updated"), destroyed: pick("destroyed") };
},
"Email/set": (a) => {
const r = genericSet(emails, "e", (o) => {
const bv = (o.bodyValues as Record<string, { value: string }>) ?? {};
@@ -437,6 +521,19 @@ const handlers: Record<string, Handler> = {
o.blobId = putBlob(`Subject: ${o.subject}\r\n\r\n${bv.text?.value ?? ""}`, "message/rfc822");
})(a);
recount();
nextState();
recordEmailChange({
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).map((x) => x.id),
updated: Object.keys((a.update as Obj) ?? {}),
destroyed: (r.destroyed as string[] | undefined) ?? [],
});
/* A real server pushes a state change after a set, and the client acts on
it -- `Email/changes` runs and the store reconciles what came back. The
mock stayed silent, so that whole path never ran here and a bug living
in it could not be reproduced: marking a message read went round the
server and back on the live instance, and did nothing at all on the mock
(#100). Announced now, the way Stalwart does. */
broadcast(["Email", "Mailbox", "Thread"]);
return r;
},
"Email/import": (a) => { const created: Obj = {}; for (const [cid, spec] of Object.entries((a.emails as Obj) ?? {})) { const id = `e${counter++}`; emails.push({ id, blobId: (spec as Obj).blobId, threadId: `t${id}`, mailboxIds: (spec as Obj).mailboxIds, keywords: (spec as Obj).keywords ?? {}, size: 100, receivedAt: new Date().toISOString(), subject: "(imported message)", from: [{ name: null, email: "import@example" }], to: null, preview: "", hasAttachment: false, textBody: [], htmlBody: [], attachments: [], bodyValues: {} }); created[cid] = { id }; } recount(); return setResp({ created }); },
@@ -675,10 +772,10 @@ const handlers: Record<string, Handler> = {
"SieveScript/get": genericGet(sieveScripts),
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
"Calendar/get": genericGet(calendars),
"Calendar/set": genericSet(calendars, "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o })),
"CalendarEvent/query": (a) => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: events.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: events.length }),
"CalendarEvent/get": genericGet(events),
"Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never,
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
"CalendarEvent/query": (a) => { const list = eventsFor(a.accountId); return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: list.length }; },
"CalendarEvent/get": (a) => genericGet(eventsFor(a.accountId))(a),
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
// participants addressed the RFC 8984 way. The mock did neither, which is how
// #26 and #30 reached a live server unnoticed — so it now does both.
@@ -694,21 +791,43 @@ const handlers: Record<string, Handler> = {
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
"Principal/get": genericGet(principals),
"Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }),
"AddressBook/get": genericGet(addressBooks),
"AddressBook/set": genericSet(addressBooks, "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, ...o })),
"ContactCard/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: cards.map((c) => c.id), total: cards.length }),
"ContactCard/get": genericGet(cards),
"AddressBook/get": (a) => hideShareWithUnlessAsked(a, genericGet(booksFor(a.accountId))(a) as { list: Obj[] }) as never,
"AddressBook/set": (a) => {
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
included -- "You are not allowed to modify this address book", confirmed
live on 0.16.19 (2026-08-27) from the account holding the share. A mock
that accepted it would have agreed that subscribing works, which is
exactly the belief that shipped. Calendars accept the same write; the
difference is the server's, not ours. */
if (a.accountId === SHARED_ACCOUNT && a.update) {
const notUpdated: Obj = {};
for (const id of Object.keys(a.update as Obj)) notUpdated[id] = { type: "forbidden", description: "You are not allowed to modify this address book." };
return { accountId: a.accountId, oldState: String(state.n), newState: String(state.n), updated: null, notUpdated };
}
return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a);
},
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
"ContactCard/set": genericSet(cards, "cc"),
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
"FileNode/query": (a) => {
const f = (a.filter as Obj) ?? {};
const list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true));
const fileNodes = nodesFor(a.accountId);
// `nodeType` is a filter 0.16.19 really applies -- checked live on
// 2026-08-27, where it returned the two directories out of seven nodes. The
// mock ignoring it was worse than not having it: the sidebar tree asks for
// directories and was handed files, which it then drew as folders.
const list = fileNodes.filter((n) => {
if (f.isTopLevel ? n.parentId != null : f.parentId ? n.parentId !== f.parentId : false) return false;
if (f.nodeType && n.nodeType !== f.nodeType) return false;
return true;
});
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
},
"FileNode/get": genericGet(fileNodes),
"FileNode/get": (a) => genericGet(nodesFor(a.accountId))(a),
"FileNode/set": (a) => {
return genericSet(fileNodes, "f", (o) => {
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o });
return genericSet(nodesFor(a.accountId), "f", (o) => {
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o });
// Without nodeType, a node is a directory precisely when it carries no
// file properties. Keep it internally so query and get stay consistent.
if (!o.nodeType) o.nodeType = o.blobId || o.size != null || o.type ? "file" : "directory";
@@ -752,7 +871,18 @@ const session = () => ({
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY" },
"urn:ietf:params:jmap:emailpush": {},
"urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } },
/*
* Two accounts: the demo user's own, and one somebody has shared.
*
* The shared one carries the *same* capability list, because that is what
* Stalwart does -- checked on 0.16.19 (2026-08-27), where a shared account
* advertised mail, calendars, contacts and the rest, identical to a personal
* one, whatever had actually been shared. Giving the mock a truthful shared
* account is the only way to exercise the Files "Shared with me" list, and
* the only way this stays honest about what can be inferred from a
* capability, which is nothing.
*/
accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } },
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
username: USER,
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
@@ -763,6 +893,14 @@ const session = () => ({
});
const sseClients = new Set<ServerResponse>();
/** What changed and when, so `Email/changes` can answer honestly. */
const emailChanges: Array<{ state: number; created: string[]; updated: string[]; destroyed: string[] }> = [];
function recordEmailChange(change: { created?: string[]; updated?: string[]; destroyed?: string[] }) {
emailChanges.push({ state: state.n, created: change.created ?? [], updated: change.updated ?? [], destroyed: change.destroyed ?? [] });
// A window is plenty; the client refetches from scratch if it falls behind.
if (emailChanges.length > 200) emailChanges.splice(0, emailChanges.length - 200);
}
function broadcast(types: string[]) {
const payload = `event: state\ndata: ${JSON.stringify({ "@type": "StateChange", changed: { [ACCOUNT]: Object.fromEntries(types.map((t) => [t, String(state.n)])) } })}\n\n`;
for (const c of sseClients) c.write(payload);
+58 -9
View File
@@ -34,9 +34,64 @@ export interface LiveSession {
ip: string;
}
/** What `/api/auth/sessions` reports about a session, with nothing secret in it. */
export interface SessionSummary {
id: string;
username: string;
createdAt: number;
lastSeenAt: number;
expiresAt: number;
remember: boolean;
userAgent: string;
ip: string;
}
export interface CreateSessionParams {
username: string;
password: string;
remember: boolean;
userAgent: string;
ip: string;
}
/**
* Everything the rest of the server asks of a session store.
*
* There is one implementation today -- `SessionStore` below, which keeps the
* records in memory and optionally mirrors them to `SESSION_FILE`. The reason
* it is named as an interface anyway is that a second one is planned: a
* stateless backend that carries the whole record in the cookie, so that a
* replica can serve a session it never issued and `/data` can go away. Callers
* written against the concrete class would all have to be revisited then.
*
* Five of these are already stateless in shape -- `create`, `resolve`,
* `reseal` and `destroy` each touch exactly one session, and the sealing key is
* derived from the cookie secret (see `crypto.ts`), so the record can move into
* the cookie without the server keeping a map.
*
* The other two cannot be. `listForUser` and `destroyAllForUser` have to reach
* sessions other than the one presenting itself, which means something has to
* be enumerable somewhere. `destroyAllForUser` is not only the "sign out my
* other sessions" button: `app.ts` also calls it when the password or the app
* password changes, so it carries the guarantee that changing a credential
* invalidates the sessions still holding the old one. A stateless backend
* cannot honour that alone; the plan is for OAuth to hand the job to
* Stalwart's own token registry, which can already answer both questions.
*/
export interface SessionBackend {
init(): Promise<void>;
close(): Promise<void>;
create(params: CreateSessionParams): { cookie: string; session: LiveSession };
resolve(cookie: string | undefined): LiveSession | null;
reseal(cookie: string | undefined, password: string): boolean;
destroy(id: string): void;
destroyAllForUser(username: string, exceptId?: string): number;
listForUser(username: string): SessionSummary[];
}
const COOKIE_SEP = ".";
export class SessionStore {
export class SessionStore implements SessionBackend {
private sessions = new Map<string, StoredSession>();
private dirty = false;
private saveTimer: NodeJS.Timeout | null = null;
@@ -104,13 +159,7 @@ export class SessionStore {
}
/** Create a session; returns the cookie value to hand to the client. */
create(params: {
username: string;
password: string;
remember: boolean;
userAgent: string;
ip: string;
}): { cookie: string; session: LiveSession } {
create(params: CreateSessionParams): { cookie: string; session: LiveSession } {
const id = randomToken(18);
const secret = randomToken(32);
const salt = randomBytes(16);
@@ -211,7 +260,7 @@ export class SessionStore {
return n;
}
listForUser(username: string): Array<Omit<StoredSession, "secretHash" | "salt" | "sealedCredentials">> {
listForUser(username: string): SessionSummary[] {
const out = [];
for (const s of this.sessions.values()) {
if (s.username !== username) continue;
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { accountForCapability, ownAccountForCapability, type SessionLike } from "@/lib/accountRouting";
/**
* Found by sharing a folder between two real accounts.
*
* Switching to the account somebody shared pointed everything at it, because
* 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 own settings in the
* account's Files, 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. Reading someone else's data by mistake is bad; writing
* yours into it is worse, and it was the same one-line rule doing both.
*/
const CAL = "urn:ietf:params:jmap:calendars";
const FILES = "urn:ietf:params:jmap:filenode";
const MAIL = "urn:ietf:params:jmap:mail";
/** Mine does everything; theirs is a shared account with only files on it. */
const shared = (): SessionLike => ({
accounts: {
mine: { isPersonal: true, accountCapabilities: { [MAIL]: {}, [FILES]: {}, [CAL]: {} } },
theirs: { isPersonal: false, accountCapabilities: { [FILES]: {} } },
},
primaryAccounts: { [MAIL]: "mine", [FILES]: "mine", [CAL]: "mine" },
});
describe("what the reader is looking at", () => {
it("follows the switch into a shared account for what was shared", () => {
expect(accountForCapability(shared(), "theirs", FILES)).toBe("theirs");
});
it("leaves everything else on the reader's own account", () => {
expect(accountForCapability(shared(), "theirs", MAIL)).toBe("mine");
expect(accountForCapability(shared(), "theirs", CAL)).toBe("mine");
});
it("still follows a switch between the reader's own accounts", () => {
const s = shared();
s.accounts.second = { isPersonal: true, accountCapabilities: { [MAIL]: {} } };
expect(accountForCapability(s, "second", MAIL)).toBe("second");
});
it("gives up rather than aim at a shared account for something unshared", () => {
// No primary for calendars, and theirs does not offer them. The old rule
// fell back to the selection, which is somebody else's account.
const s = shared();
delete s.primaryAccounts[CAL];
expect(accountForCapability(s, "theirs", CAL)).toBeNull();
});
it("lets one of the reader's own accounts stand in when there is no primary", () => {
const s = shared();
delete s.primaryAccounts[CAL];
expect(accountForCapability(s, "mine", CAL)).toBe("mine");
});
});
describe("what belongs to the reader", () => {
it("stays on their own account while they look at a shared one", () => {
// The one that matters: settings are written through this.
expect(ownAccountForCapability(shared(), FILES)).toBe("mine");
});
it("ignores a primary account the server says is not the reader's", () => {
const s = shared();
s.primaryAccounts[FILES] = "theirs";
expect(ownAccountForCapability(s, FILES)).toBe("mine");
});
it("finds a personal account when no primary is named", () => {
const s = shared();
delete s.primaryAccounts[FILES];
expect(ownAccountForCapability(s, FILES)).toBe("mine");
});
it("answers nothing rather than a shared account", () => {
const s: SessionLike = {
accounts: { theirs: { isPersonal: false, accountCapabilities: { [FILES]: {} } } },
primaryAccounts: {},
};
expect(ownAccountForCapability(s, FILES)).toBeNull();
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
/**
* Whether a shared collection counts as added.
*
* JMAP keeps this on the collection, in `isSubscribed`, and that is the better
* place: a preference the server holds is one every client sees. But
* subscribing writes to the *owner's* account, and Stalwart 0.16.19 refuses
* that for an address book shared read-only — "You are not allowed to modify
* this address book" — while accepting the identical write on a shared
* calendar. Confirmed against the live server on 2026-08-27, from a second
* account holding the share.
*
* So there are two records and either counts. The rule is the whole of the
* fix, which is why it is worth pinning down here rather than leaving it
* spelled out in three components that could drift apart.
*/
const key = (accountId: string, id: string) => `${accountId}:${id}`;
/** Added if the server remembered it, or the reader's settings did. */
function isAdded(collection: { accountId: string; id: string; isSubscribed?: boolean }, addedShares: string[]): boolean {
return Boolean(collection.isSubscribed) || new Set(addedShares).has(key(collection.accountId, collection.id));
}
const book = (over: Partial<{ accountId: string; id: string; isSubscribed: boolean }> = {}) =>
({ accountId: "acct", id: "ab1", ...over });
describe("whether a shared collection has been added", () => {
it("is added when the server took the subscription", () => {
expect(isAdded(book({ isSubscribed: true }), [])).toBe(true);
});
it("is added when only the settings remember it", () => {
// The address book case: the server refused the write.
expect(isAdded(book(), ["acct:ab1"])).toBe(true);
});
it("is not added when neither says so", () => {
expect(isAdded(book(), [])).toBe(false);
expect(isAdded(book(), ["other:ab1", "acct:ab2"])).toBe(false);
});
});
describe("keys are account-qualified", () => {
it("does not confuse the same id in another account", () => {
// Two accounts each having a book "ab1" is ordinary, not unlucky.
expect(isAdded(book({ accountId: "theirs" }), ["mine:ab1"])).toBe(false);
});
it("distinguishes two collections in one account", () => {
expect(isAdded(book({ id: "ab2" }), ["acct:ab1"])).toBe(false);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { foldersNeeded, hasDirectory, planUpload } from "@/lib/dropUpload";
/**
* Dropping a folder in, reduced to the two things the DataTransfer entry API
* gets wrong if you take it at face value.
*
* `readEntries` answers with *up to* some number of entries and signals the end
* of a directory with an empty array, so a single call quietly loses everything
* past the first batch — a real folder of a few hundred files would upload the
* first hundred and look like it had finished. And a directory tree that cycles
* has to stop somewhere the tab is still alive.
*/
const file = (name: string) => new File([name], name);
/** A directory whose contents arrive a batch at a time, as a real one does. */
const dir = (name: string, children: unknown[], batch = 2) => {
let at = 0;
return {
isFile: false,
isDirectory: true,
name,
createReader: () => ({
readEntries: (cb: (e: never[]) => void) => {
const slice = children.slice(at, at + batch);
at += slice.length;
cb(slice as never[]);
},
}),
};
};
const leaf = (name: string) => ({
isFile: true,
isDirectory: false,
name,
file: (cb: (f: File) => void) => cb(file(name)),
});
describe("walking a dropped folder", () => {
it("reads a directory across as many batches as it takes", async () => {
// Five children, two per readEntries call: a single read would find two.
const plan = await planUpload([dir("docs", ["a", "b", "c", "d", "e"].map(leaf))] as never[]);
expect(plan.map((p) => p.file.name)).toEqual(["a", "b", "c", "d", "e"]);
expect(plan.every((p) => p.path.join("/") === "docs")).toBe(true);
});
it("keeps the folder each file came from", async () => {
const plan = await planUpload([dir("outer", [leaf("top"), dir("inner", [leaf("deep")])])] as never[]);
expect(plan.map((p) => [p.path.join("/"), p.file.name])).toEqual([
["outer", "top"],
["outer/inner", "deep"],
]);
});
it("puts a loose file at the drop itself", async () => {
const plan = await planUpload([leaf("loose")] as never[]);
expect(plan).toEqual([expect.objectContaining({ path: [] })]);
});
it("stops rather than following a cycle for ever", async () => {
const loop: Record<string, unknown> = {};
Object.assign(loop, dir("loop", []));
(loop as { createReader: () => unknown }).createReader = () => ({
readEntries: (cb: (e: unknown[]) => void) => cb([loop]),
});
// Terminating at all is the assertion; the caps decide where. Both are set
// low so the test does not have to read twenty thousand phantom entries.
const plan = await planUpload([loop] as never[], { maxDepth: 4, maxEntries: 50 });
expect(plan).toEqual([]);
});
});
describe("the folders a plan needs", () => {
it("lists parents before their children", () => {
const needed = foldersNeeded([
{ file: file("x"), path: ["a", "b", "c"] },
{ file: file("y"), path: ["a"] },
]);
expect(needed).toEqual([["a"], ["a", "b"], ["a", "b", "c"]]);
});
it("names each folder once, however many files are in it", () => {
const needed = foldersNeeded([
{ file: file("x"), path: ["a"] },
{ file: file("y"), path: ["a"] },
]);
expect(needed).toEqual([["a"]]);
});
it("asks for nothing when everything lands at the drop", () => {
expect(foldersNeeded([{ file: file("x"), path: [] }])).toEqual([]);
});
});
describe("spotting a folder in the drop", () => {
it("is true when any entry is a directory", () => {
expect(hasDirectory([leaf("a"), dir("d", [])] as never[])).toBe(true);
expect(hasDirectory([leaf("a")] as never[])).toBe(false);
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { canDropFileNode } from "@/lib/filenode";
import type { FileNode, Id } from "@/jmap/types";
/**
* Dragging a folder into its own subtree is the move that has to be refused
* rather than reported: the server would orphan the branch, and the folder the
* reader was dragging would leave the tree with everything under it.
*/
const rights = (over: Partial<FileNode["myRights"]> = {}) => ({
mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true, ...over,
});
/** a > b > c, plus a file in a and a second top-level folder. */
const tree = (): Record<Id, FileNode> => {
const mk = (id: string, parentId: string | null, nodeType: "directory" | "file", over: Partial<FileNode> = {}) =>
({ id, parentId, nodeType, name: id, myRights: rights(), ...over }) as FileNode;
return {
a: mk("a", null, "directory"),
b: mk("b", "a", "directory"),
c: mk("c", "b", "directory"),
other: mk("other", null, "directory"),
doc: mk("doc", "a", "file"),
};
};
describe("what a folder may be dropped on", () => {
it("allows a move to an unrelated folder", () => {
expect(canDropFileNode(tree(), "a", "other")).toBe(true);
});
it("refuses a drop on itself", () => {
expect(canDropFileNode(tree(), "a", "a")).toBe(false);
});
it("refuses a drop into its own subtree, however deep", () => {
expect(canDropFileNode(tree(), "a", "b")).toBe(false);
expect(canDropFileNode(tree(), "a", "c")).toBe(false);
});
it("refuses the parent it already has, which is a no-op dressed as a move", () => {
expect(canDropFileNode(tree(), "b", "a")).toBe(false);
});
it("allows a child up to the top level, but not one already there", () => {
expect(canDropFileNode(tree(), "b", null)).toBe(true);
expect(canDropFileNode(tree(), "a", null)).toBe(false);
});
});
describe("targets that cannot take it", () => {
it("refuses a file as a target", () => {
expect(canDropFileNode(tree(), "b", "doc")).toBe(false);
});
it("refuses a folder that will not take children", () => {
const t = tree();
t.other = { ...t.other!, myRights: rights({ mayAddChildren: false }) };
expect(canDropFileNode(t, "a", "other")).toBe(false);
});
it("refuses a target that is not there at all", () => {
expect(canDropFileNode(tree(), "a", "ghost")).toBe(false);
});
it("allows a file to be moved like anything else", () => {
expect(canDropFileNode(tree(), "doc", "other")).toBe(true);
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { isShared } from "@/lib/filenode";
/**
* The one thing about file sharing that a mock would never have told us.
*
* Stalwart 0.16.19 answers `shareWith` as `{}` for a node shared with nobody,
* not `null` — every unshared node in a live account came back that way on
* 2026-08-27. A truthiness test on the property is therefore true for every
* node the server has ever returned, and a badge driven by one would report
* the entire account as shared while being, technically, about the right
* property.
*/
describe("whether a node is shared", () => {
it("treats the empty object Stalwart sends as not shared", () => {
expect(isShared({ shareWith: {} })).toBe(false);
});
it("treats a missing or null shareWith as not shared", () => {
expect(isShared({ shareWith: null })).toBe(false);
expect(isShared({})).toBe(false);
});
it("is shared once a principal is on it", () => {
expect(isShared({ shareWith: { p1: { mayRead: true } } as never })).toBe(true);
});
it("stays shared when the rights granted are all false", () => {
// An entry with nothing enabled is still an entry: the principal is on the
// list, and the owner should see that rather than an empty-looking folder.
expect(isShared({ shareWith: { p1: { mayRead: false } } as never })).toBe(true);
});
});
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { isUnknownMailbox } from "@/lib/mailboxRoute";
import type { Mailbox } from "@/jmap/types";
/**
* Issue #111: a folder id the account does not have rendered the ordinary
* empty state — "Nothing here. This folder is empty" — which is a claim about
* a folder that is not there. A stale link read as a folder that had emptied
* itself rather than one that was gone.
*
* The interesting case is not the unknown id. It is `loaded`: the folder list
* arrives after the first paint, so for a moment *every* id is unknown,
* including the right one. A version without that gate sends the reader to
* their inbox from the folder they asked for, on every cold load, and looks
* exactly like a flaky link.
*/
const boxes = (...ids: string[]): Record<string, Mailbox> =>
Object.fromEntries(ids.map((id) => [id, { id, name: id } as Mailbox]));
describe("spotting a folder the account does not have", () => {
it("is unknown when the list is loaded and does not contain it", () => {
expect(isUnknownMailbox({ mailboxId: "ghost", mailboxes: boxes("a", "b"), loaded: true })).toBe(true);
});
it("is not unknown when the list contains it", () => {
expect(isUnknownMailbox({ mailboxId: "a", mailboxes: boxes("a", "b"), loaded: true })).toBe(false);
});
});
describe("what it refuses to call unknown", () => {
it("says nothing before the folder list has arrived", () => {
// The whole point. Every id is unknown at this moment, the real one too.
expect(isUnknownMailbox({ mailboxId: "a", mailboxes: {}, loaded: false })).toBe(false);
expect(isUnknownMailbox({ mailboxId: "ghost", mailboxes: {}, loaded: false })).toBe(false);
});
it("says nothing when there is no folder in the address", () => {
// /mail has its own redirect to the inbox; this must not race it.
expect(isUnknownMailbox({ mailboxId: undefined, mailboxes: boxes("a"), loaded: true })).toBe(false);
});
it("says nothing on a search, which has no folder to be wrong about", () => {
expect(isUnknownMailbox({ mailboxId: "ghost", mailboxes: boxes("a"), loaded: true, search: true })).toBe(false);
});
});
+1 -1
View File
@@ -77,7 +77,7 @@ describe("what it refuses to acknowledge", () => {
});
const OPTS = {
from: { name: "John Ellis", email: "[email protected]" } as EmailAddress,
from: { name: "John Coffey", email: "[email protected]" } as EmailAddress,
to: { name: null, email: "[email protected]" } as EmailAddress,
finalRecipient: "[email protected]",
reportingUa: "mail.example.org; ihasmail 2.0",
+2 -2
View File
@@ -3,14 +3,14 @@ import { buildMarkerSignature, byteLength, compactHtml, markerOf, signatureTooLo
describe("signature compaction", () => {
it("strips office cruft and non-essential styles but keeps colours and links", () => {
const src = `<!--[if gte mso 9]><xml>x</xml><![endif]--><div class="WordSection1" style="mso-margin-top-alt:auto;line-height:115%;font-family:'Calibri',sans-serif;color:windowtext"><p class="MsoNormal" style="margin:0cm;font-size:11pt"><span lang="EN-US" style="font-size:12pt;color:#1F4E79;mso-fareast-language:EN-US"><b>John Ellis</b></span><o:p></o:p></p><p><span></span></p><a href="https://linuxexpert.org" target="_blank" data-x="1">linuxexpert.org</a><img src="https://x/y.png" width="100" style="mso-foo:bar"></div>`;
const src = `<!--[if gte mso 9]><xml>x</xml><![endif]--><div class="WordSection1" style="mso-margin-top-alt:auto;line-height:115%;font-family:'Calibri',sans-serif;color:windowtext"><p class="MsoNormal" style="margin:0cm;font-size:11pt"><span lang="EN-US" style="font-size:12pt;color:#1F4E79;mso-fareast-language:EN-US"><b>John Coffey</b></span><o:p></o:p></p><p><span></span></p><a href="https://linuxexpert.org" target="_blank" data-x="1">linuxexpert.org</a><img src="https://x/y.png" width="100" style="mso-foo:bar"></div>`;
const out = compactHtml(src);
expect(out).not.toContain("mso-");
expect(out).not.toContain("class=");
expect(out).not.toContain("<xml");
expect(out).not.toContain("o:p");
expect(out).toContain("color:#1F4E79");
expect(out).toContain("<b>John Ellis</b>");
expect(out).toContain("<b>John Coffey</b>");
expect(out).toContain('href="https://linuxexpert.org"');
expect(out).toContain('width="100"');
expect(out.length).toBeLessThan(src.length / 2);
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { reloadIfServerRebuilt, makeConnectionWatcher, startBuildWatch } from "@/lib/staleBuild";
import { APP_VERSION } from "@/lib/version";
function healthReplies(body: unknown, ok = true) {
return vi.fn().mockResolvedValue({ ok, json: async () => body } as unknown as Response);
}
let reload: ReturnType<typeof vi.fn>;
beforeEach(() => {
sessionStorage.clear();
reload = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: { ...window.location, reload },
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("reloadIfServerRebuilt", () => {
it("reloads when the server reports a different build", async () => {
vi.stubGlobal("fetch", healthReplies({ ok: true, version: `${APP_VERSION}-newer` }));
expect(await reloadIfServerRebuilt()).toBe(true);
expect(reload).toHaveBeenCalledOnce();
});
it("leaves the page alone when the versions match", async () => {
vi.stubGlobal("fetch", healthReplies({ ok: true, version: APP_VERSION }));
expect(await reloadIfServerRebuilt()).toBe(false);
expect(reload).not.toHaveBeenCalled();
});
it("reloads once per version, not once per 401", async () => {
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
expect(await reloadIfServerRebuilt()).toBe(true);
expect(await reloadIfServerRebuilt()).toBe(false);
expect(reload).toHaveBeenCalledOnce();
});
it("clears the guard once the versions agree again", async () => {
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
await reloadIfServerRebuilt();
vi.stubGlobal("fetch", healthReplies({ ok: true, version: APP_VERSION }));
await reloadIfServerRebuilt();
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
expect(await reloadIfServerRebuilt()).toBe(true);
expect(reload).toHaveBeenCalledTimes(2);
});
it("does not reload when the server cannot be reached", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
expect(await reloadIfServerRebuilt()).toBe(false);
expect(reload).not.toHaveBeenCalled();
});
it("does not reload on a bad response or a missing version", async () => {
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }, false));
expect(await reloadIfServerRebuilt()).toBe(false);
vi.stubGlobal("fetch", healthReplies({ ok: true }));
expect(await reloadIfServerRebuilt()).toBe(false);
expect(reload).not.toHaveBeenCalled();
});
});
describe("noticing without being asked", () => {
it("checks when the push stream drops, but not before it has connected", async () => {
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
vi.stubGlobal("fetch", fetchMock);
const onState = makeConnectionWatcher();
// never connected: a disconnect is not news
onState("connecting");
await new Promise((r) => setTimeout(r, 0));
expect(fetchMock).not.toHaveBeenCalled();
onState("connected");
onState("connecting");
await new Promise((r) => setTimeout(r, 0));
expect(fetchMock).toHaveBeenCalled();
});
it("asks the server once when several things notice at the same moment", async () => {
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
vi.stubGlobal("fetch", fetchMock);
await Promise.all([reloadIfServerRebuilt(), reloadIfServerRebuilt(), reloadIfServerRebuilt()]);
expect(fetchMock).toHaveBeenCalledOnce();
});
});
describe("the poll is what the guarantee rests on", () => {
it("checks on its own while the tab is visible, with nobody touching it", async () => {
vi.useFakeTimers();
const fetchMock = healthReplies({ ok: true, version: "9.9.9" });
vi.stubGlobal("fetch", fetchMock);
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" });
startBuildWatch();
expect(fetchMock).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(60_000);
expect(fetchMock).toHaveBeenCalled();
vi.useRealTimers();
});
it("leaves a hidden tab alone until it is looked at", async () => {
vi.useFakeTimers();
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
vi.stubGlobal("fetch", fetchMock);
let visibility = "hidden";
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibility });
startBuildWatch();
await vi.advanceTimersByTimeAsync(180_000);
expect(fetchMock).not.toHaveBeenCalled();
visibility = "visible";
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalled();
vi.useRealTimers();
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { threadScrollTarget } from "@/lib/threadScroll";
/**
* Issue #87: a conversation opened on its newest message, so unread mail sat
* above the fold with nothing to announce it but a marker you had to scroll up
* to see — and the auto-mark-read timer marked it read while you were still
* looking at the bottom of the thread.
*
* The case that makes "second to last" the wrong answer is out-of-order
* delivery: a message sent hours ago but queued on the sender's server arrives
* last and sorts early. Messages here are in the order the pane renders them,
* oldest first, which is receivedAt order.
*/
const thread = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `m${i + 1}` }));
const unread = (...ids: string[]) => new Set(ids);
describe("where a conversation opens", () => {
it("opens on the oldest unread message", () => {
expect(threadScrollTarget(thread(5), unread("m3", "m4"))).toBe("m3");
});
it("opens on an unread message that arrived late and sorted early", () => {
// The one the issue is about: m2 was delivered after m5, so opening at the
// bottom hides it three messages up.
expect(threadScrollTarget(thread(5), unread("m2"))).toBe("m2");
});
it("opens on the newest message when the thread is all read", () => {
expect(threadScrollTarget(thread(5), unread())).toBe("m5");
});
});
describe("when it leaves the pane where it is", () => {
it("stays at the top when the first message is the unread one", () => {
// Scrolling to it would push the subject off the top for nothing.
expect(threadScrollTarget(thread(4), unread("m1", "m3"))).toBeNull();
});
it("does not scroll a single message", () => {
expect(threadScrollTarget(thread(1), unread("m1"))).toBeNull();
});
it("does not scroll an empty thread", () => {
expect(threadScrollTarget([], unread())).toBeNull();
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Which account a request goes to.
*
* A JMAP session lists more than one account whenever anything is shared with
* you: the sharer's account appears alongside your own, carrying whichever
* capabilities they shared. Switching to one is how you read their files, so
* some requests have to follow that selection.
*
* Others must never follow it, and telling the two apart is the whole point of
* this file. ihasmail keeps its own settings in the account's Files — that is
* what makes them travel between devices — and a shared file account advertises
* the file capability by definition. So the obvious rule, "use whichever
* account is selected if it can do this", writes your settings into the other
* person's storage the moment you change one while looking at their folder. It
* would create the `ihasmail` folder there to do it.
*
* Two questions, then, and they have different answers:
*
* - what am I *looking at* -> `accountForCapability`, follows the selection
* - what is *mine* -> `ownAccountForCapability`, never does
*
* There is a third rule hiding in the first. A capability the selected account
* does not advertise used to fall back to that account anyway, so a session
* with no primary account for something would aim it at whoever was selected —
* someone else. Falling back to nothing is the honest answer: the feature is
* unavailable, which is true, rather than pointed at a stranger's data.
*/
import type { Id } from "@/jmap/types";
export interface AccountLike {
/** JMAP: true when the account belongs to the authenticated user. */
isPersonal: boolean;
accountCapabilities?: Record<string, unknown>;
}
export interface SessionLike {
accounts: Record<Id, AccountLike>;
primaryAccounts: Record<string, Id>;
}
const advertises = (account: AccountLike | undefined, cap: string): boolean =>
Boolean(account && cap in (account.accountCapabilities ?? {}));
/**
* The account to read and write for this capability, honouring the switcher.
*
* Use for anything the reader is looking at: their mail, a shared calendar,
* somebody's files. Not for anything of the reader's own — see below.
*/
export function accountForCapability(session: SessionLike | null, selectedId: Id | null, cap: string): Id | null {
if (!session) return null;
const selected = selectedId ? session.accounts[selectedId] : undefined;
if (selected && advertises(selected, cap)) return selectedId;
const primary = session.primaryAccounts[cap];
if (primary) return primary;
// No primary, and the selection cannot serve this. Falling back to the
// selection would aim the request at a shared account for something nobody
// shared; only one of the reader's own accounts may stand in.
if (selected && selected.isPersonal) return selectedId;
return null;
}
/**
* The reader's own account for this capability, whatever they are looking at.
*
* Use for the reader's own state -- synced settings, signature images, push
* registration. These belong to them and follow them, and must not land in an
* account somebody shared just because it happens to be on screen.
*/
export function ownAccountForCapability(session: SessionLike | null, cap: string): Id | null {
if (!session) return null;
const primary = session.primaryAccounts[cap];
// A primary account is the reader's own by definition, but check rather than
// assume: a server that named a shared one here would otherwise be trusted.
if (primary && session.accounts[primary]?.isPersonal !== false) return primary;
const own = Object.entries(session.accounts).find(([, a]) => a.isPersonal && advertises(a, cap));
return own?.[0] ?? null;
}
Binary file not shown.
+43 -2
View File
@@ -8,11 +8,12 @@
* separate ones. ihasmail requires 0.16 now — sign-in refuses anything older —
* so a node has one shape and there is nothing left to detect.
*/
import type { Id } from "@/jmap/types";
import type { FileNode, Id } from "@/jmap/types";
import { descendantIds } from "./folderMove";
/** Properties to request for a node. */
export function fileNodeProps(): string[] {
return ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable", "nodeType"];
return ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "shareWith", "role", "executable", "nodeType"];
}
/** Create-arguments for a directory. */
@@ -24,3 +25,43 @@ export function directoryCreate(parentId: Id | null, name: string): Record<strin
export function fileCreate(parentId: Id | null, name: string, blobId: Id, type: string): Record<string, unknown> {
return { parentId, name, blobId, type, nodeType: "file" };
}
/**
* Whether a node is shared with anyone.
*
* Stalwart answers `shareWith` as `{}` for "nobody", not `null` — confirmed
* against 0.16.19 on 2026-08-27, where every unshared node in the account came
* back that way. So a truthiness test passes for every node ever returned, and
* a badge driven by one would say the whole account is shared. Count the keys.
*/
export function isShared(node: Pick<FileNode, "shareWith">): boolean {
return Object.keys(node.shareWith ?? {}).length > 0;
}
/**
* Whether the node being dragged may be dropped on `targetId`, null being the
* top level.
*
* The same four refusals as folders: onto itself, into its own subtree, onto
* the parent it already has, or -- for the top level -- when it is already
* there. `descendantIds` is shared with the mailbox tree, since both are the
* same shape of tree asking the same question.
*
* Rights are deliberately only half-checked. A target that will not take
* children is refused here, because that is unambiguous. Whether the node may
* leave the parent it is in is not: JMAP models a move as an update of
* `parentId` and does not say which right covers it, and guessing would hide
* legal moves behind a disabled drop. The server refuses those with a message
* of its own, which is a better answer than a silent one.
*/
export function canDropFileNode(nodes: Record<Id, FileNode>, draggedId: Id, targetId: Id | null): boolean {
const dragged = nodes[draggedId];
if (!dragged) return false;
if (targetId === null) return dragged.parentId != null;
if (targetId === draggedId) return false;
if (dragged.parentId === targetId) return false;
const target = nodes[targetId];
if (!target || target.nodeType !== "directory") return false;
if (target.myRights && !target.myRights.mayAddChildren) return false;
return !descendantIds(nodes, draggedId).has(targetId);
}
+10 -5
View File
@@ -9,13 +9,18 @@ export function movable(m: Mailbox): boolean {
return !m.role || m.role === "subscribed";
}
/** Every folder beneath this one, so a folder cannot be dropped inside itself. */
export function descendantIds(mailboxes: Record<Id, Mailbox>, id: Id): Set<Id> {
/**
* Every node beneath this one, so a node cannot be dropped inside itself.
*
* Written against `{ id, parentId }` rather than `Mailbox` because file nodes
* form the same shape of tree and need the same answer -- see `canDropFileNode`.
*/
export function descendantIds<T extends { id: Id; parentId: Id | null }>(tree: Record<Id, T>, id: Id): Set<Id> {
const out = new Set<Id>();
const all = Object.values(mailboxes);
const all = Object.values(tree);
let frontier = new Set<Id>([id]);
// Depth is bounded by the server's own mailbox depth limit; the guard is only
// here so a cycle in the data cannot spin forever.
// Depth is bounded by the server's own depth limit; the guard is only here so
// a cycle in the data cannot spin forever.
for (let depth = 0; depth < 20 && frontier.size; depth++) {
const next = new Set<Id>();
for (const m of all) {
+27
View File
@@ -0,0 +1,27 @@
import type { Id, Mailbox } from "@/jmap/types";
/**
* Whether the folder in the address is one this account does not have.
*
* Rendering it as an empty folder was the bug (#111): "Nothing here. This
* folder is empty" 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.
*
* The condition that matters is `loaded`. The folder list arrives after the
* first paint, so for a moment every id is unknown -- including the right one.
* Without that gate this answers true on every cold load and sends the reader
* to their inbox from the folder they asked for, which is a worse bug than the
* one it fixes and would look exactly like a flaky link.
*/
export function isUnknownMailbox(args: {
mailboxId: Id | undefined;
mailboxes: Record<Id, Mailbox>;
loaded: boolean;
search?: boolean;
}): boolean {
const { mailboxId, mailboxes, loaded, search } = args;
if (search) return false;
if (!mailboxId) return false;
if (!loaded) return false;
return !mailboxes[mailboxId];
}
+3 -3
View File
@@ -36,7 +36,7 @@ let armed = false;
let listenersBound = false;
export function settingsSyncAvailable(): boolean {
return client.hasCapability(CAP.filenode) && Boolean(useSession.getState().accountFor(CAP.filenode));
return client.hasCapability(CAP.filenode) && Boolean(useSession.getState().ownAccountFor(CAP.filenode));
}
/**
@@ -46,7 +46,7 @@ export function settingsSyncAvailable(): boolean {
*/
export async function loadRemoteSettings(): Promise<Record<string, unknown> | null> {
if (!settingsSyncAvailable()) return null;
const accountId = useSession.getState().accountFor(CAP.filenode)!;
const accountId = useSession.getState().ownAccountFor(CAP.filenode)!;
try {
const folderId = await ensureFolder(accountId);
const node = await findInFolder(accountId, folderId, FILE);
@@ -109,7 +109,7 @@ export async function flushSettingsPush(): Promise<void> {
async function writeSettings(body: Record<string, unknown>): Promise<void> {
if (!settingsSyncAvailable()) return;
const accountId = useSession.getState().accountFor(CAP.filenode)!;
const accountId = useSession.getState().ownAccountFor(CAP.filenode)!;
const json = JSON.stringify(body, null, 2);
// Byte length, not character count: a template or a signature with any
// non-ASCII in it would otherwise be reported shorter than it is.
+5 -3
View File
@@ -13,7 +13,7 @@ import { toast } from "@/ui/toast";
/** Upload an image for use in a signature; returns a same-origin blob URL. */
export async function uploadSignatureImage(file: File): Promise<string> {
const accountId = useSession.getState().accountFor(CAP.filenode);
const accountId = useSession.getState().ownAccountFor(CAP.filenode);
if (!accountId || !client.hasCapability(CAP.filenode)) {
toast.error("Images in signatures need the Files feature, which this account doesn't have.");
throw new Error("filenode unavailable");
@@ -42,7 +42,7 @@ export async function uploadSignatureImage(file: File): Promise<string> {
/** Store the full HTML of an over-sized signature in Files; returns the blob id. */
export async function storeSignatureHtml(html: string): Promise<string> {
const accountId = useSession.getState().accountFor(CAP.filenode);
const accountId = useSession.getState().ownAccountFor(CAP.filenode);
if (!accountId || !client.hasCapability(CAP.filenode)) throw new Error("This signature is too long for the server and the Files feature (needed to store long signatures) is not available.");
const up = await client.upload(accountId, new Blob([html], { type: "text/html" }), { type: "text/html" });
const folderId = await ensureFolder(accountId);
@@ -77,7 +77,9 @@ export async function externalizeDataImages(html: string): Promise<string> {
/** Load the full HTML of a marker signature. */
export async function loadStoredSignature(blobId: string, type = "text/html"): Promise<string> {
const accountId = useSession.getState().accountFor(CAP.filenode) ?? useSession.getState().accountId;
// No `?? accountId` fallback: a signature is the reader's own, and the
// selected account may be somebody else's shared one.
const accountId = useSession.getState().ownAccountFor(CAP.filenode);
if (!accountId) throw new Error("no account");
return client.fetchBlobText(accountId, blobId, type);
}
+149
View File
@@ -0,0 +1,149 @@
import { APP_VERSION } from "./version";
import { push, type PushState } from "@/jmap/push";
/**
* Reload the page when the server is serving a build this one did not come
* from.
*
* Signing out and picking up a new version are separate things, and only the
* first happens on its own. An immutable instance holds sessions in memory, so
* a deploy signs everyone out -- but the tab that was open still has the old
* bundle in it, and a 401 only swaps the view to the sign-in form. The old
* JavaScript would go on talking to the new server until someone happened to
* reload by hand.
*
* `index.html` is served `no-cache` and the assets under it are content-hashed
* and immutable, so a reload is all it takes; the only missing part was
* something to ask for one. Comparing versions rather than reloading on every
* 401 means an ordinary session expiry still lands on the sign-in form with the
* page intact -- only a build that actually moved costs the page.
*
* The reload is unconditional once the versions differ. A compose window can
* be holding text that never reached the server, and after a deploy it cannot
* be saved either, since the session went with the container -- so this will
* sometimes take an unsent draft with it. That is a deliberate trade: a tab
* running code the server no longer speaks is the worse failure, and one that
* stays behind because someone left a draft open is not automatic at all.
*/
const TRIED_KEY = "ihasmail:reloaded-for";
/** sessionStorage throws outright in some privacy modes; treat that as absent. */
function tried(): string | null {
try {
return sessionStorage.getItem(TRIED_KEY);
} catch {
return null;
}
}
function remember(version: string): void {
try {
sessionStorage.setItem(TRIED_KEY, version);
} catch {
/* nothing to do: the guard below is best-effort */
}
}
function forget(): void {
try {
sessionStorage.removeItem(TRIED_KEY);
} catch {
/* as above */
}
}
let inFlight: Promise<boolean> | null = null;
/**
* True when a reload has been asked for and the caller should leave the page
* alone. False for every other outcome, including not being able to tell --
* failing to reach the server is not a reason to throw away what is on screen.
*/
export function reloadIfServerRebuilt(): Promise<boolean> {
// Several things can notice a deploy at once -- the stream dropping and the
// request that follows it -- and they should not each ask the server.
inFlight ??= check().finally(() => {
inFlight = null;
});
return inFlight;
}
async function check(): Promise<boolean> {
let serverVersion: string;
try {
const res = await fetch("/api/health", { credentials: "same-origin", cache: "no-store" });
if (!res.ok) return false;
const body = (await res.json()) as { version?: unknown };
if (typeof body.version !== "string" || !body.version) return false;
serverVersion = body.version;
} catch {
return false;
}
if (serverVersion === APP_VERSION) {
// Back in step, either because nothing changed or because an earlier
// reload worked. Clear the guard so the next deploy is not mistaken for
// one already attempted.
forget();
return false;
}
// Reloading once per version, not once per 401: if the new bundle somehow
// still reports the old version -- a stale proxy cache, a half-finished
// deploy -- this stops the two of them reloading each other in a loop.
if (tried() === serverVersion) return false;
remember(serverVersion);
window.location.reload();
return true;
}
/**
* Watch for a deploy without waiting to be asked.
*
* Checking on a 401 alone was not automatic, only deferred: it needs the tab to
* make a request, so one sitting idle keeps running the old build until someone
* touches it.
*
* The obvious signal turned out to be the wrong one. A deploy kills the
* EventSource behind `/api/events`, which looks like the perfect cue -- except
* it arrives while the container is still being replaced, so the check that
* follows cannot reach the server. Waiting for the stream to come back instead
* does not work either: the session died with the old container, so the
* reconnect is answered with a 401 and never reaches "connected" at all. The
* drop is kept below because it is free and sometimes lands early enough to be
* useful, but nothing depends on it.
*
* What the guarantee rests on is a slow poll while the tab is visible, plus a
* check when it becomes visible again. Neither cares what the stream is doing
* or whether anyone is at the keyboard: a tab left open through a deploy
* notices within a minute, and a backgrounded one notices the moment it is
* looked at. `/api/health` touches nothing upstream, so the cost is one small
* request a minute per open tab.
*/
const POLL_MS = 60_000;
export function makeConnectionWatcher(): (state: PushState) => void {
let wasConnected = false;
return (state) => {
if (state === "connected") {
wasConnected = true;
return;
}
// Only a drop is news. Never having connected is not evidence of anything.
if (!wasConnected) return;
wasConnected = false;
void reloadIfServerRebuilt();
};
}
export function startBuildWatch(): void {
push.onConnection(makeConnectionWatcher());
window.setInterval(() => {
// A hidden tab is not being read, and will be checked when it surfaces.
if (document.visibilityState === "visible") void reloadIfServerRebuilt();
}, POLL_MS);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") void reloadIfServerRebuilt();
});
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Where a conversation opens.
*
* It used to open on the newest message, which is wrong whenever anything in
* the thread is unread: the unread mail sits above the fold, and the only clue
* it exists is the marker on a message you have to scroll up to find. The
* auto-mark-read timer then sweeps the whole thread, so scrolling up late is
* scrolling up to mail that is already marked read (#87).
*
* Order is receivedAt, not arrival, so the first unread is not the second-to-
* last message or any other position you can guess at. A thread where one
* participant's server queued a message for hours delivers it late and sorts it
* early -- exactly the case where opening at the bottom hides the most.
*
* Two answers are "don't move":
*
* - 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
*
* `unread` is the set captured when the thread was opened rather than live
* `$seen` state, for the same reason expansion uses it: the mark-read timer
* must not change the shape of what you are looking at (#69).
*/
export function threadScrollTarget<T extends { id: string }>(
messages: readonly T[],
unread: ReadonlySet<string>,
): string | null {
if (messages.length < 2) return null;
const firstUnread = messages.findIndex((m) => unread.has(m.id));
if (firstUnread === 0) return null;
if (firstUnread > 0) return messages[firstUnread]!.id;
// Nothing unread: the newest message, which is what you came for.
return messages[messages.length - 1]!.id;
}
+1 -1
View File
@@ -78,7 +78,7 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
userVisibleOnly: true,
applicationServerKey: decodeApplicationServerKey(key),
}));
const accountId = useSession.getState().accountFor(CAP.mail);
const accountId = useSession.getState().ownAccountFor(CAP.mail);
const inboxId = useMail.getState().roleId("inbox");
await createSubscription(subscriptionPayload(sub, accountId, inboxId));
listenForVerification();
+3
View File
@@ -2,6 +2,9 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles/app.css";
import { App } from "./App";
import { startBuildWatch } from "@/lib/staleBuild";
startBuildWatch();
createRoot(document.getElementById("root")!).render(
<StrictMode>
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { emptyForAccount } from "../files";
/**
* Switching to an account somebody shared with you showed an empty folder tree.
*
* The switch cleared `nodes` and `children` and stopped there, so `treeLoaded`
* stayed true from the previous account — the sidebar never asked the new one
* for its folders — while `dirIds` still named the old account's folders, which
* no longer resolved against the cleared `nodes`. The result was a tree with
* nothing in it and no error to explain it, in the one place a tree matters
* most: someone else's files, where you have no idea what the shape should be.
*
* The test that matters is the last one. The bug was not bad logic, it was a
* field nobody remembered, and the only durable guard is asserting the whole
* set rather than the fields we happen to think of today.
*/
describe("what a switch to another account keeps", () => {
it("keeps nothing but the new account's own id", () => {
expect(emptyForAccount("b")).toEqual({
accountId: "b",
nodes: {},
children: {},
dirIds: [],
treeLoaded: false,
draggingId: null,
error: null,
});
});
it("asks the new account for its tree", () => {
// The sidebar loads when `treeLoaded` is false. True here means an empty
// tree for as long as the account stays selected.
expect(emptyForAccount("b").treeLoaded).toBe(false);
});
it("carries no folder ids over from the account before it", () => {
expect(emptyForAccount("b").dirIds).toEqual([]);
});
it("drops a drag that was in flight", () => {
// Its id belongs to the other account and would name a different node here.
expect(emptyForAccount("b").draggingId).toBeNull();
});
it("names every piece of per-account state", () => {
// Add a per-account field to the store and forget it here, and this fails
// rather than the field quietly following someone into another account.
expect(Object.keys(emptyForAccount(null)).sort()).toEqual(
["accountId", "children", "dirIds", "draggingId", "error", "nodes", "treeLoaded"],
);
});
});
+1 -1
View File
@@ -41,7 +41,7 @@ describe("makeParticipant", () => {
expect(guest.expectReply).toBe(true);
});
it("marks the organizer as owner and keeps a status already given", () => {
const me = makeParticipant("john@linuxexperts.net", "John Coffey", "owner");
const me = makeParticipant("john@example.org", "John Coffey", "owner");
expect(me.roles).toEqual({ owner: true, attendee: true });
expect(me.participationStatus).toBe("accepted");
expect(me.expectReply).toBe(false);
+190 -4
View File
@@ -2,7 +2,7 @@ import { create } from "zustand";
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, JSCalendarParticipant, JSCalendarRecurrenceRule, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types";
import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates";
import { settings } from "./settings";
import { settings, useSettings } from "./settings";
import { useSession } from "./session";
export interface EventInstance {
@@ -15,10 +15,57 @@ export interface EventInstance {
calendar: Calendar | undefined;
}
/*
* Asked for by name, because `shareWith` is not among the properties Stalwart
* returns by default.
*
* A `Calendar/get` with no `properties` comes back without it -- not null, not
* empty, absent -- confirmed against 0.16.19 on 2026-08-27 with a calendar that
* was genuinely shared: omit the list and there is no `shareWith`; name it and
* the sharee is right there. So the client believed nothing was ever shared.
* The badge never appeared, "Stop sharing" never appeared, and the share dialog
* opened on "not shared with anyone yet" over a live share.
*
* Files had this right already, for the same reason and after the same
* surprise; calendars and address books did not.
*/
export const CALENDAR_PROPS = [
"id",
"name",
"description",
"color",
"sortOrder",
"isSubscribed",
"isVisible",
"isDefault",
"includeInAvailability",
"defaultAlertsWithTime",
"defaultAlertsWithoutTime",
"timeZone",
"shareWith",
"myRights",
];
/** A calendar somebody else shared, and the account it lives in. */
export interface SharedCalendar {
accountId: Id;
accountName: string;
calendar: Calendar;
}
/** Shared events are keyed by account too: ids only differ within an account. */
export const sharedKey = (accountId: Id, id: Id): string => `${accountId}:${id}`;
interface CalendarState {
accountId: Id | null;
available: boolean;
calendars: Record<Id, Calendar>;
/** Calendars shared with the reader, from every non-personal account. */
sharedCalendars: SharedCalendar[];
/** Their events, keyed by account and id. See `sharedKey`. */
sharedEvents: Record<string, CalendarEvent>;
/** Which shared keys each loaded window holds, alongside `ranges`. */
sharedRanges: Record<string, string[]>;
events: Record<Id, CalendarEvent>;
/** Loaded ranges keyed "start|end" → event ids */
ranges: Record<string, Id[]>;
@@ -29,6 +76,11 @@ interface CalendarState {
init(): Promise<void>;
loadCalendars(): Promise<void>;
/** Calendars from accounts that shared with the reader, and their events. */
loadSharedCalendars(): Promise<void>;
loadSharedRange(start: Date, end: Date): Promise<void>;
/** Add a shared calendar to, or remove it from, the reader's own view. */
setSharedSubscribed(accountId: Id, calendarId: Id, subscribed: boolean): Promise<void>;
loadRange(start: Date, end: Date, force?: boolean): Promise<void>;
instancesIn(start: Date, end: Date): EventInstance[];
getEvent(id: Id): Promise<CalendarEvent | null>;
@@ -65,6 +117,9 @@ export const useCalendar = create<CalendarState>((set, get) => ({
accountId: null,
available: false,
calendars: {},
sharedCalendars: [],
sharedEvents: {},
sharedRanges: {},
events: {},
ranges: {},
loading: false,
@@ -73,12 +128,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
hidden: {},
async init() {
const accountId = useSession.getState().accountFor(CAP.calendars);
// The reader's own: a shared calendar is shown beside theirs, not instead.
const accountId = useSession.getState().ownAccountFor(CAP.calendars);
const available = Boolean(accountId && client.hasCapability(CAP.calendars));
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
set({ available });
if (!available) return;
await get().loadCalendars();
void get().loadSharedCalendars();
try {
const res = await client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null });
set({ identities: res.list });
@@ -87,11 +144,110 @@ export const useCalendar = create<CalendarState>((set, get) => ({
}
},
/*
* Calendars other people shared, and the events in them.
*
* Kept apart from the reader's own and keyed by account, for the reason ids
* force: they are unique only within an account. Loaded from the same window
* the reader is looking at, so a colleague's calendar fills in beside their
* own rather than after a separate wait.
*
* An account that answers with no calendars is simply not listed. Sharing a
* file does not make somebody's calendar worth a heading.
*/
async loadSharedCalendars() {
const session = useSession.getState();
const own = session.ownAccountFor(CAP.calendars);
const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
const found: SharedCalendar[] = [];
for (const [accountId, account] of accounts) {
try {
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS });
for (const calendar of res.list) found.push({ accountId, accountName: account.name, calendar });
} catch {
continue;
}
}
set({ sharedCalendars: found });
// Fill in whatever windows are already on screen.
for (const key of Object.keys(get().ranges)) {
const [from, to] = key.split("|").map((n) => new Date(Number(n)));
if (from && to) void get().loadSharedRange(from, to);
}
},
async setSharedSubscribed(accountId, calendarId, subscribed) {
// See the note in the contacts store: subscribing writes to another
// account, so a refusal is an ordinary answer and arrives in `notUpdated`
// rather than as a thrown error.
/*
* Server first, settings when it refuses -- the same arrangement the
* contacts store explains. Stalwart takes this write on a shared calendar
* where it will not on a shared address book, but the difference is the
* server's to change and not worth relying on from here.
*/
let stored = false;
try {
const res = await client.call<SetResponse>("Calendar/set", { accountId, update: { [calendarId]: { isSubscribed: subscribed } } });
const err = res.notUpdated?.[calendarId];
if (err) throw new Error(setErrorMessage(err));
stored = true;
} catch {
stored = false;
}
if (!stored) {
const added = new Set(settings().addedShares);
if (subscribed) added.add(sharedKey(accountId, calendarId));
else added.delete(sharedKey(accountId, calendarId));
useSettings.getState().update({ addedShares: [...added] });
}
set((s) => ({
sharedCalendars: s.sharedCalendars.map((c) =>
c.accountId === accountId && c.calendar.id === calendarId ? { ...c, calendar: { ...c.calendar, isSubscribed: subscribed } } : c,
),
}));
// Its events are only fetched for calendars in view, so the windows on
// screen have to be asked again either way.
for (const key of Object.keys(get().ranges)) {
const [from, to] = key.split("|").map((n) => new Date(Number(n)));
if (from && to) void get().loadSharedRange(from, to);
}
},
/** The same window, from every account that shared a calendar. */
async loadSharedRange(start, end) {
const shared = get().sharedCalendars;
if (!shared.length) return;
const key = `${start.getTime()}|${end.getTime()}`;
const tz = settings().timeZone ?? browserTimeZone;
const accounts = [...new Set(shared.map((c) => c.accountId))];
const ids: string[] = [];
const events: Record<string, CalendarEvent> = {};
for (const accountId of accounts) {
try {
const res = await client.chain([
["CalendarEvent/query", { accountId, filter: { after: toLocalDateTime(start), before: toLocalDateTime(end) }, timeZone: tz, sort: [{ property: "start", isAscending: true }], expandRecurrences: true, limit: 2000 }, "q"],
["CalendarEvent/get", { accountId, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: EVENT_PROPS, timeZone: tz }, "g"],
]);
const g = res.get("g")?.[0] as unknown as GetResponse<CalendarEvent>;
for (const e of g.list) {
const k = sharedKey(accountId, e.id);
events[k] = e;
ids.push(k);
}
} catch {
// One account refusing must not empty the calendar of the others.
continue;
}
}
set((s) => ({ sharedEvents: { ...s.sharedEvents, ...events }, sharedRanges: { ...s.sharedRanges, [key]: ids } }));
},
async loadCalendars() {
const accountId = get().accountId;
if (!accountId) return;
try {
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null });
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS });
const calendars: Record<Id, Calendar> = {};
for (const c of res.list) calendars[c.id] = c;
set({ calendars, error: null });
@@ -131,13 +287,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
for (const e of g.list) events[e.id] = e;
return { events, ranges: { ...s.ranges, [key]: q.ids }, loading: false, error: null };
});
void get().loadSharedRange(start, end);
} catch (err) {
set({ loading: false, error: (err as Error).message });
}
},
instancesIn(start, end) {
const { events, ranges, calendars, hidden } = get();
const { events, ranges, calendars, hidden, sharedEvents, sharedRanges, sharedCalendars } = get();
const ids = new Set<Id>();
for (const list of Object.values(ranges)) for (const id of list) ids.add(id);
const out: EventInstance[] = [];
@@ -150,6 +307,35 @@ export const useCalendar = create<CalendarState>((set, get) => ({
if (!inst) continue;
if (inst.end > start && inst.start < end) out.push(inst);
}
/* Shared events go through the same funnel, so every view gets them
without knowing they exist. Their calendars are looked up per account:
a shared calendar id means nothing outside the account holding it, and
hiding one is remembered under the same account-qualified key. */
const sharedKeys = new Set<string>();
for (const list of Object.values(sharedRanges)) for (const k of list) sharedKeys.add(k);
for (const k of sharedKeys) {
const e = sharedEvents[k];
if (!e) continue;
const accountId = k.slice(0, k.length - e.id.length - 1);
const calId = Object.keys(e.calendarIds ?? {})[0];
if (calId && hidden[sharedKey(accountId, calId)]) continue;
/* Stalwart hands back every calendar in an account the reader can reach,
with full rights on each, whether or not anybody meant to share it --
an account linked for its files offered its calendar too. `isSubscribed`
is the only thing separating "shared with me" from "reachable", so
nothing unsubscribed is drawn. */
const added = new Set(settings().addedShares);
const theirs: Record<Id, Calendar> = {};
for (const c of sharedCalendars) {
if (c.accountId !== accountId) continue;
if (!c.calendar.isSubscribed && !added.has(sharedKey(c.accountId, c.calendar.id))) continue;
theirs[c.calendar.id] = c.calendar;
}
if (calId && !theirs[calId]) continue;
const inst = toInstance(e, theirs);
if (!inst) continue;
if (inst.end > start && inst.start < end) out.push(inst);
}
out.sort((a, b) => a.start.getTime() - b.start.getTime() || b.end.getTime() - a.end.getTime());
return out;
},
+52
View File
@@ -25,6 +25,15 @@ export interface ComposeAttachment {
abort?: AbortController;
}
/** A file in Files, enough of it to attach. */
export interface AttachableFile {
accountId: Id;
name: string;
type: string | null;
size: number | null;
blobId: Id;
}
export type Priority = "high" | "normal" | "low";
export interface Draft {
@@ -76,6 +85,8 @@ interface ComposeState {
close(key: string, opts?: { discard?: boolean }): Promise<void>;
focus(key: string): void;
addFiles(key: string, files: File[]): void;
/** Attach files already in Files, by reference where the account allows it. */
addFromFiles(key: string, nodes: AttachableFile[]): Promise<void>;
removeAttachment(key: string, attId: string): void;
saveDraft(key: string, opts?: { silent?: boolean }): Promise<Id | null>;
send(key: string): Promise<void>;
@@ -361,6 +372,47 @@ export const useCompose = create<ComposeState>((set, get) => ({
}
},
/*
* Attach something already in Files.
*
* A blob the account can already see needs no upload: an attachment carrying
* a `blobId` is exactly what a forward produces, so the send path already
* knows what to do with one. Attaching a 20 MB file the server is holding
* anyway then costs nothing and takes no time.
*
* A file in an account somebody *shared* is a different matter. Blobs belong
* to the account they were uploaded to, so a draft in your account cannot
* reference one in theirs; it is fetched and uploaded to yours. Slower, and
* unavoidable, but it happens without the reader having to know any of this.
*/
async addFromFiles(key, nodes) {
const accountId = useMail.getState().accountId;
if (!accountId || !nodes.length) return;
const max = client.maxSizeUpload;
const atts: ComposeAttachment[] = nodes.map((n) => ({
id: uid("a"),
name: n.name,
type: n.type || "application/octet-stream",
size: n.size ?? 0,
blobId: n.accountId === accountId ? n.blobId : null,
progress: n.accountId === accountId ? 100 : 0,
error: (n.size ?? 0) > max ? `Larger than ${Math.round(max / 1048576)} MB limit` : null,
}));
get().update(key, { attachments: [...(get().drafts.find((d) => d.key === key)?.attachments ?? []), ...atts] });
for (const [i, a] of atts.entries()) {
if (a.error || a.blobId) continue;
const node = nodes[i]!;
try {
const blob = await client.fetchBlob(node.accountId, node.blobId, a.type);
const up = await client.upload(accountId, blob, { type: a.type });
patchAtt(key, a.id, { blobId: up.blobId, progress: 100, size: up.size || a.size }, set);
} catch (err) {
patchAtt(key, a.id, { error: (err as Error).message || "Could not attach" }, set);
}
}
},
removeAttachment(key, attId) {
const d = get().drafts.find((x) => x.key === key);
const a = d?.attachments.find((x) => x.id === attId);
+183 -11
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types";
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
import { useSettings } from "./settings";
import { useSession } from "./session";
import { useMail } from "./mail";
@@ -13,6 +14,31 @@ export interface Suggestion {
photo?: string | null;
}
/*
* Asked for by name: `shareWith` is not returned by default.
*
* An `AddressBook/get` with no `properties` omits it entirely -- confirmed
* against 0.16.19 on 2026-08-27 on a book that really was shared. See the note
* on CALENDAR_PROPS; both had the same hole and Files did not.
*/
export const ADDRESS_BOOK_PROPS = ["id", "name", "description", "sortOrder", "isDefault", "isSubscribed", "shareWith", "myRights"];
/** A book somebody else shared, and the account it lives in. */
export interface SharedBook {
accountId: Id;
accountName: string;
book: AddressBook;
}
/** Which book the contact list is showing. `accountId` null means the reader's. */
export interface BookSelection {
accountId: Id | null;
bookId: Id | "all";
}
/** Cards from shared accounts are keyed by account too: ids collide across them. */
export const sharedKey = (accountId: Id, id: Id): string => `${accountId}:${id}`;
interface ContactsState {
accountId: Id | null;
available: boolean;
@@ -24,12 +50,27 @@ interface ContactsState {
principals: Principal[];
principalsLoaded: boolean;
recent: EmailAddress[];
/** Address books shared with the reader, from every non-personal account. */
sharedBooks: SharedBook[];
/** Their cards, keyed by account and id. See `sharedKey`. */
sharedCards: Record<string, ContactCard>;
sharedLoaded: boolean;
selection: BookSelection;
init(): Promise<void>;
loadBooks(): Promise<void>;
loadAll(): Promise<void>;
/** Books and cards from accounts that shared with the reader. */
loadShared(): Promise<void>;
select(selection: BookSelection): void;
/** Add a shared address book to, or remove it from, the reader's own view. */
setBookSubscribed(accountId: Id, bookId: Id, subscribed: boolean): Promise<void>;
/** The account a card belongs to, null for the reader's own. */
accountOfCard(id: Id): Id | null;
getCard(id: Id): Promise<ContactCard | null>;
search(text: string): ContactCard[];
/** The search filter itself, so a shared book can be filtered the same way. */
filterCards(cards: ContactCard[], text: string): ContactCard[];
createCard(card: Partial<ContactCard>, addressBookId: Id): Promise<Id>;
updateCard(id: Id, patch: Record<string, unknown>): Promise<void>;
destroyCards(ids: Id[]): Promise<void>;
@@ -57,21 +98,141 @@ export const useContacts = create<ContactsState>((set, get) => ({
principals: [],
principalsLoaded: false,
recent: [],
sharedBooks: [],
sharedCards: {},
sharedLoaded: false,
selection: { accountId: null, bookId: "all" },
async init() {
const accountId = useSession.getState().accountFor(CAP.contacts);
// The reader's own, not whichever account is selected: a shared address
// book is shown beside theirs rather than instead of it, so nothing here
// should move when the switcher does.
const accountId = useSession.getState().ownAccountFor(CAP.contacts);
const available = Boolean(accountId && client.hasCapability(CAP.contacts));
if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, loaded: false });
if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, loaded: false, selection: { accountId: null, bookId: "all" } });
set({ available });
if (!available) return;
await get().loadBooks();
void get().loadShared();
},
/*
* Books and cards from accounts that shared with the reader.
*
* These are held apart from the reader's own rather than merged into them,
* because ids are only unique within an account: two accounts each having a
* book "ab1" is ordinary, and a flat map keyed on the bare id would have one
* quietly replace the other. `sharedKey` keeps them apart.
*
* Loaded eagerly, unlike the shared folders in Files, because these are not
* only browsed -- they have to answer when someone types a name into a To
* field, which cannot wait for a folder to be opened first.
*/
async loadShared() {
const session = useSession.getState();
const own = session.ownAccountFor(CAP.contacts);
const s = session.session;
const accounts = Object.entries(s?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
if (!accounts.length) {
set({ sharedBooks: [], sharedCards: {}, sharedLoaded: true });
return;
}
const books: SharedBook[] = [];
const cards: Record<string, ContactCard> = {};
for (const [accountId, account] of accounts) {
try {
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null, properties: ADDRESS_BOOK_PROPS });
for (const book of res.list) books.push({ accountId, accountName: account.name, book });
/*
* Cards come only from books the reader has added.
*
* Stalwart hands back every book in a reachable account with full
* rights on each, shared or not -- an account linked for its files
* offered its address book too -- so `isSubscribed` is the only thing
* separating "shared with me" from "reachable". Loading the rest would
* put a stranger's contacts in the To field, which is the one place
* this must not guess.
*/
const added = new Set(useSettings.getState().settings.addedShares);
const wanted = new Set(res.list.filter((b) => b.isSubscribed || added.has(sharedKey(accountId, b.id))).map((b) => b.id));
if (!wanted.size) continue;
// One page. A shared book is a colleague's contacts, not an archive,
// and the alternative is holding the reader's own list hostage to it.
const cardsRes = await client.chain([
["ContactCard/query", { accountId, limit: 500 }, "q"],
["ContactCard/get", { accountId, "#ids": { resultOf: "q", name: "ContactCard/query", path: "/ids" } }, "g"],
]);
const g = cardsRes.get("g")?.[0] as unknown as GetResponse<ContactCard>;
for (const c of g.list) {
if (!Object.keys(c.addressBookIds ?? {}).some((id) => wanted.has(id))) continue;
cards[sharedKey(accountId, c.id)] = c;
}
} catch {
// An account that refuses is one that shared nothing here. Not an
// error to show: the reader did not ask for it and cannot act on it.
continue;
}
}
set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true });
},
async setBookSubscribed(accountId, bookId, subscribed) {
/*
* `notUpdated` matters more here than anywhere else this pattern is used.
* Subscribing is a write to somebody *else's* account, so it is the one
* call in the app that a perfectly healthy server is entitled to refuse --
* and a refusal arrives as a successful response carrying a per-object
* failure, not as a thrown error. Ignoring it made a refused subscribe look
* exactly like a button that does nothing.
*/
/*
* Ask the server to remember it, and remember it here when it will not.
*
* Subscribing writes to the owner's account, and Stalwart 0.16.19 refuses
* that for a book shared read-only -- "You are not allowed to modify this
* address book" -- while accepting the same write on a shared calendar. The
* server's own flag is still preferred when it takes it, because then every
* client agrees; a refusal is an ordinary answer here rather than a
* failure, and the preference goes in the reader's own synced settings.
*/
const key = sharedKey(accountId, bookId);
let stored = false;
try {
const res = await client.call<SetResponse>("AddressBook/set", { accountId, update: { [bookId]: { isSubscribed: subscribed } } });
const err = res.notUpdated?.[bookId];
if (err) throw new Error(setErrorMessage(err));
stored = true;
} catch {
stored = false;
}
if (!stored) {
const { settings, update } = useSettings.getState();
const added = new Set(settings.addedShares);
if (subscribed) added.add(key);
else added.delete(key);
update({ addedShares: [...added] });
}
if (!subscribed && get().selection.accountId === accountId && get().selection.bookId === bookId) {
set({ selection: { accountId: null, bookId: "all" } });
}
await get().loadShared();
},
select(selection) {
set({ selection });
},
accountOfCard(id) {
if (get().cards[id]) return null;
const hit = Object.entries(get().sharedCards).find(([key]) => key.endsWith(`:${id}`));
return hit ? hit[0].slice(0, hit[0].length - id.length - 1) : null;
},
async loadBooks() {
const accountId = get().accountId;
if (!accountId) return;
try {
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null });
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null, properties: ADDRESS_BOOK_PROPS });
const books: Record<Id, AddressBook> = {};
for (const b of res.list) books[b.id] = b;
set({ books, error: null });
@@ -114,20 +275,23 @@ export const useContacts = create<ContactsState>((set, get) => ({
return c ?? null;
},
search(text) {
filterCards(cards, text) {
const q = text.trim().toLowerCase();
const all = Object.values(get().cards);
const filtered = q
? all.filter((c) => {
? cards.filter((c) => {
const hay = [contactDisplayName(c), ...Object.values(c.emails ?? {}).map((e) => e.address), ...Object.values(c.phones ?? {}).map((p) => p.number), ...Object.values(c.organizations ?? {}).map((o) => o.name ?? ""), ...Object.values(c.nicknames ?? {}).map((n) => n.name)]
.join(" ")
.toLowerCase();
return hay.includes(q);
})
: all;
: cards;
return filtered.sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
},
search(text) {
return get().filterCards(Object.values(get().cards), text);
},
async createCard(card, addressBookId) {
const accountId = get().accountId!;
const obj = { "@type": "Card", version: "1.0", uid: crypto.randomUUID(), kind: "individual", ...card, addressBookIds: { [addressBookId]: true } };
@@ -244,10 +408,15 @@ export const useContacts = create<ContactsState>((set, get) => ({
return 99;
};
const candidates: Array<Suggestion & { score: number }> = [];
for (const c of Object.values(st.cards)) {
// A shared address book is only useful if it answers when you are writing
// to someone in it, so its cards are offered alongside the reader's own.
// They rank a shade lower, so a name in both wins from your own book.
const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 }));
const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 }));
for (const { c, penalty } of [...own, ...shared]) {
for (const a of contactEmails(c)) {
const sc = score(a.name, a.email);
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc });
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty });
}
}
for (const p of st.principals) {
@@ -280,11 +449,14 @@ export const useContacts = create<ContactsState>((set, get) => ({
lookupByEmail(email) {
const e = email.toLowerCase();
return Object.values(get().cards).find((c) => Object.values(c.emails ?? {}).some((x) => x.address.toLowerCase() === e));
const match = (c: ContactCard) => Object.values(c.emails ?? {}).some((x) => x.address.toLowerCase() === e);
// The reader's own books first: a card they wrote themselves should win
// over a colleague's version of the same person.
return Object.values(get().cards).find(match) ?? Object.values(get().sharedCards).find(match);
},
applyChanges(types) {
if (types.has("AddressBook")) void get().loadBooks();
if (types.has("AddressBook")) { void get().loadBooks(); void get().loadShared(); }
if (types.has("ContactCard") && get().loaded) void get().loadAll();
},
}));
+185 -4
View File
@@ -1,26 +1,69 @@
import { create } from "zustand";
import { CAP, client, setErrorMessage } from "@/jmap/client";
import { directoryCreate, fileCreate, fileNodeProps } from "@/lib/filenode";
import { foldersNeeded, type PlannedUpload } from "@/lib/dropUpload";
import { isAppFolder } from "@/lib/appFolder";
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
import { useSession } from "./session";
interface SharedAccount {
id: Id;
name: string;
}
interface FilesState {
/**
* The account being browsed, which is not always the reader's own.
*
* Files is the one module that opens somebody else's account in place: a
* folder shared with you is reached from "Shared with me" in the tree, not by
* switching the whole app over. So this moves and `ownAccountId` does not,
* and anything belonging to the reader -- their settings, their signatures --
* goes through `ownAccountFor` rather than either of them.
*/
accountId: Id | null;
/** The reader's own file account, wherever they happen to be looking. */
ownAccountId: Id | null;
/** Accounts someone else has shared, from the session. */
sharedAccounts: SharedAccount[];
available: boolean;
nodes: Record<Id, FileNode>;
children: Record<string, Id[]>; // parentId ("root" for null) → ids
loading: boolean;
error: string | null;
uploads: Array<{ id: string; name: string; progress: number; error: string | null }>;
dirIds: Id[];
treeLoaded: boolean;
/*
* The node being dragged, if any.
*
* Kept here rather than in whichever pane started the drag, because a drag
* crosses between them -- a row dragged onto the sidebar tree, a folder in
* the tree dragged onto a row -- and every possible target has to know what
* is in flight to say whether it will take it. Two panes each holding their
* own copy meant the one that did not start the drag never lit up and never
* accepted the drop.
*
* It cannot be read from the drag itself: `dataTransfer.getData` is blocked
* during dragover, which is exactly when the answer is needed.
*/
draggingId: Id | null;
init(): Promise<void>;
/** Browse an account: the reader's own, or one shared with them. */
openAccount(accountId: Id | null): void;
loadChildren(parentId: Id | null): Promise<void>;
mkdir(parentId: Id | null, name: string): Promise<Id>;
upload(parentId: Id | null, files: File[]): Promise<void>;
rename(id: Id, name: string): Promise<void>;
move(id: Id, parentId: Id | null): Promise<void>;
destroy(ids: Id[]): Promise<void>;
refresh(ids: Id[]): Promise<void>;
setDragging(id: Id | null): void;
/** Every directory in the account, for the tree in the sidebar. */
loadTree(): Promise<void>;
/** Upload a planned drop, creating the folders it needs as it goes. */
uploadPlan(parentId: Id | null, plan: PlannedUpload[]): Promise<void>;
pathTo(id: Id | null): FileNode[];
applyChanges(types: Set<string>): void;
}
@@ -52,20 +95,113 @@ export function withoutAppFolder(nodes: FileNode[]): FileNode[] {
return nodes.filter((n) => !hidden.has(n.id));
}
/**
* The state that belongs to one account, emptied when the selection moves.
*
* Every field here describes somebody's files, so none of it survives a switch
* to somebody else's. `treeLoaded` is the one that bites: leave it true and the
* sidebar never asks the new account for its folders, while `dirIds` still
* names the old account's, which no longer resolve -- so the tree is simply
* empty, with nothing to say why. That shipped, and is what this exists to stop
* happening again: the test asserts the whole set, so a field added to the
* store and forgotten here fails rather than quietly persisting across
* accounts.
*/
export function emptyForAccount(accountId: Id | null) {
return { accountId, nodes: {}, children: {}, dirIds: [], treeLoaded: false, draggingId: null, error: null };
}
export const useFiles = create<FilesState>((set, get) => ({
accountId: null,
ownAccountId: null,
sharedAccounts: [],
available: false,
nodes: {},
children: {},
loading: false,
error: null,
uploads: [],
dirIds: [],
treeLoaded: false,
draggingId: null,
async init() {
const accountId = useSession.getState().accountFor(CAP.filenode);
const available = Boolean(accountId && client.hasCapability(CAP.filenode));
if (accountId !== get().accountId) set({ accountId, nodes: {}, children: {} });
set({ available });
const session = useSession.getState();
const ownAccountId = session.ownAccountFor(CAP.filenode);
const available = Boolean(ownAccountId && client.hasCapability(CAP.filenode));
/*
* Which accounts hold shared files cannot be worked out from capabilities:
* Stalwart advertises the whole set on a shared account -- mail, calendars,
* contacts and the rest -- identical to a personal one, whatever was
* actually shared (checked on 0.16.19, 2026-08-27). So each one is asked
* for its files, and only the ones that answer with any are listed.
*
* Listing them all and letting the folders speak for themselves was the
* first attempt, and it put an account holding nothing at all under
* "Shared with me" -- an invitation to open an empty pane, offered by an
* account whose calendar or contacts were the thing actually shared. An
* account that shares no files does not belong in a list of shared files.
*/
const s = session.session;
const candidates = Object.entries(s?.accounts ?? {}).filter(([, a]) => a.isPersonal === false);
const sharedAccounts: SharedAccount[] = [];
for (const [id, a] of candidates) {
try {
const res = await client.call<QueryResponse>("FileNode/query", { accountId: id, limit: 1 });
if (res.ids.length) sharedAccounts.push({ id, name: a.name });
} catch {
// Refused means nothing here is ours to see, which is the same answer.
continue;
}
}
// Stay where the reader is if they are reading a share that still exists.
const browsing = get().accountId;
const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing));
if (!keep) set(emptyForAccount(ownAccountId));
set({ available, ownAccountId, sharedAccounts });
},
openAccount(accountId) {
if (accountId === get().accountId) return;
set(emptyForAccount(accountId));
},
/*
* The whole directory tree in one query.
*
* `filter: { nodeType: "directory" }` returns every folder in the account,
* confirmed against 0.16.19 on 2026-08-27, so the sidebar tree is complete
* from the first paint: expanding costs nothing, and a drag knows every
* folder it could be dropped on without having opened it first.
*
* It is deliberately its own request rather than a call appended to another.
* A filter Stalwart refuses fails with a request-level 400 that takes every
* method call in the request with it -- `{ parentId: null }` does exactly
* that -- so a tree query batched alongside the folder listing would blank
* the whole view instead of just the sidebar.
*/
async loadTree() {
const accountId = get().accountId;
if (!accountId) return;
try {
const res = await client.chain([
["FileNode/query", { accountId, filter: { nodeType: "directory" }, sort: [{ property: "name", isAscending: true }], limit: 1000 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"],
]);
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
// Filtered again here rather than trusted: a server that ignores the
// nodeType filter answers with files as well, and the tree would draw
// them as folders you could open into nothing.
const dirs = withoutAppFolder(g.list).filter((n) => n.nodeType === "directory");
set((s) => {
const nodes = { ...s.nodes };
for (const n of dirs) nodes[n.id] = n;
return { nodes, dirIds: dirs.map((n) => n.id), treeLoaded: true };
});
} catch (err) {
// The listing still works without a tree, so this must not blank the view.
set({ error: (err as Error).message, treeLoaded: true });
}
},
async loadChildren(parentId) {
@@ -102,6 +238,7 @@ export const useFiles = create<FilesState>((set, get) => ({
const err = res.notCreated?.d;
if (err) throw new Error(setErrorMessage(err));
await get().loadChildren(parentId);
void get().loadTree();
return res.created!.d!.id;
},
@@ -129,12 +266,54 @@ export const useFiles = create<FilesState>((set, get) => ({
await get().loadChildren(parentId);
},
/* Re-read named nodes in place. Sharing changes one property of one node and
nothing about which folder it sits in, so reloading the level around it
would be a bigger round trip to land in the same place. */
setDragging(id) {
set({ draggingId: id });
},
async refresh(ids) {
const accountId = get().accountId;
if (!accountId || !ids.length) return;
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids, properties: fileNodeProps() });
set((s) => {
const nodes = { ...s.nodes };
for (const n of res.list) nodes[n.id] = n;
return { nodes };
});
},
async uploadPlan(parentId, plan) {
// Folders first, parents before children, so every file has somewhere to go.
const dirIds = new Map<string, Id | null>([["", parentId]]);
for (const path of foldersNeeded(plan)) {
const parent = dirIds.get(path.slice(0, -1).join(" ")) ?? parentId;
const name = path[path.length - 1]!;
try {
dirIds.set(path.join(" "), await get().mkdir(parent, name));
} catch (err) {
// Leave it unmapped: its files land in the nearest folder that exists
// rather than vanishing, and the error is shown against the upload.
set({ error: (err as Error).message });
}
}
const byFolder = new Map<string, File[]>();
for (const item of plan) {
const key = item.path.join(" ");
byFolder.set(key, [...(byFolder.get(key) ?? []), item.file]);
}
for (const [key, files] of byFolder) await get().upload(dirIds.get(key) ?? parentId, files);
void get().loadTree();
},
async rename(id, name) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { name } } });
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
await get().loadChildren(get().nodes[id]?.parentId ?? null);
void get().loadTree();
},
async move(id, parentId) {
@@ -144,6 +323,7 @@ export const useFiles = create<FilesState>((set, get) => ({
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
await Promise.all([get().loadChildren(from), get().loadChildren(parentId)]);
void get().loadTree();
},
async destroy(ids) {
@@ -153,6 +333,7 @@ export const useFiles = create<FilesState>((set, get) => ({
const failed = Object.values(res.notDestroyed ?? {})[0];
if (failed) throw new Error(setErrorMessage(failed));
for (const p of parents) await get().loadChildren(p);
void get().loadTree();
},
pathTo(id) {
+48 -5
View File
@@ -22,6 +22,32 @@ import { toast } from "@/ui/toast";
import { settings, useSettings } from "./settings";
import { useSession } from "./session";
/*
* Named explicitly so `shareWith` comes back, which it does not otherwise --
* see the note on CALENDAR_PROPS and the KNOWN-ISSUES entry. Mailboxes were the
* third and last store fetching everything by asking for nothing.
*
* It matters here for one narrow but real case. 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 this it never looked shared,
* so the escape hatch for the exact situation it was built for was invisible.
*/
export const MAILBOX_PROPS = [
"id",
"name",
"parentId",
"role",
"sortOrder",
"totalEmails",
"unreadEmails",
"totalThreads",
"unreadThreads",
"myRights",
"isSubscribed",
"shareWith",
];
export const LIST_PROPS = [
"id",
"blobId",
@@ -210,7 +236,7 @@ export const useMail = create<MailState>((set, get) => ({
async loadMailboxes() {
const accountId = get().accountId;
if (!accountId) return;
const res = await client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId, ids: null });
const res = await client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId, ids: null, properties: MAILBOX_PROPS });
const mailboxes: Record<Id, Mailbox> = {};
for (const m of res.list) mailboxes[m.id] = m;
set({ mailboxes, mailboxState: res.state, mailboxesLoaded: true });
@@ -833,10 +859,27 @@ export const useMail = create<MailState>((set, get) => ({
delete next[id];
delete nextFull[id];
}
// Drop cached versions of updated emails so they're refetched lazily.
for (const id of updated) {
if (next[id] && nextFull[id]) delete nextFull[id];
}
/*
* The full copy of an updated email is deliberately kept.
*
* This used to drop it so the next read would fetch it again. But
* the reading pane renders only the emails it holds in full, so
* dropping one took the message out of the open thread until the
* refetch at the end of this function put it back. The pane emptied
* and refilled -- on an HTML message, a flash to the app's own
* background and out again, which is what was left of #100 after
* the message view stopped rebuilding its body.
*
* Marking as read causes exactly this: the server echoes our own
* change back as an update.
*
* Nothing is lost by keeping it. 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.
*/
return { emails: next, fullIds: nextFull, emailState: since };
});
// Refresh the list-level props of updated/cached emails.
+16 -7
View File
@@ -2,8 +2,10 @@ import { create } from "zustand";
import { apiFetch, ApiError, CAP, client } from "@/jmap/client";
import type { Id, JmapSession } from "@/jmap/types";
import { push, type PushState } from "@/jmap/push";
import { accountForCapability, ownAccountForCapability } from "@/lib/accountRouting";
import { setServerLocale } from "@/lib/datetime";
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
import { reloadIfServerRebuilt } from "@/lib/staleBuild";
import { unsubscribeThisDevice } from "@/lib/webpush";
export type AuthStatus = "loading" | "anonymous" | "authenticated";
@@ -22,8 +24,10 @@ interface SessionState {
logout(): Promise<void>;
refresh(): Promise<void>;
setAccount(id: Id): void;
/** Returns the accountId for a capability (primary), falling back to the selected mail account. */
/** The account to read and write for a capability, honouring the account switcher. */
accountFor(cap: string): Id | null;
/** The user's own account for a capability, whatever they are looking at. */
ownAccountFor(cap: string): Id | null;
}
export const useSession = create<SessionState>((set, get) => ({
@@ -97,11 +101,11 @@ export const useSession = create<SessionState>((set, get) => ({
},
accountFor(cap) {
const s = get().session;
if (!s) return null;
const selected = get().accountId;
if (selected && s.accounts[selected] && cap in (s.accounts[selected]?.accountCapabilities ?? {})) return selected;
return s.primaryAccounts[cap] ?? selected ?? null;
return accountForCapability(get().session, get().accountId, cap);
},
ownAccountFor(cap) {
return ownAccountForCapability(get().session, cap);
},
}));
@@ -116,7 +120,12 @@ client.onUnauthenticated(() => {
push.stop();
stopSettingsSync();
client.session = null;
useSession.setState({ status: "anonymous", session: null, accountId: null });
// Ask before showing the sign-in form rather than after. A deploy is the
// usual reason to be signed out here, and reloading a form someone has
// already started typing into would throw the password away.
void reloadIfServerRebuilt().then((reloading) => {
if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null });
});
});
push.onConnection((state) => useSession.setState({ pushConnected: state === "connected", pushState: state }));
+16
View File
@@ -33,6 +33,21 @@ export interface Settings {
showAvatars: boolean;
pageSize: number;
markReadDelay: number; // seconds; -1 = never auto
/**
* Shared calendars and address books the reader has added, as
* `accountId:collectionId`.
*
* JMAP keeps this on the collection itself, in `isSubscribed`, and that is
* still tried first -- a preference the server holds is one every client
* sees. But subscribing writes to the *owner's* account, and Stalwart 0.16.19
* refuses that for an address book shared read-only: "You are not allowed to
* modify this address book." It accepts the same write on a shared calendar,
* which is the inconsistency this list exists to paper over.
*
* So where the server will not remember, ihasmail does, in the settings that
* already follow the reader between devices.
*/
addedShares: string[];
imagePolicy: ImagePolicy;
/** Let messages follow the app's light/dark theme instead of always sitting on white. */
themeMessageBody: boolean;
@@ -128,6 +143,7 @@ export const DEFAULT_SETTINGS: Settings = {
showAvatars: true,
pageSize: 50,
markReadDelay: 0,
addedShares: [],
imagePolicy: "ask",
themeMessageBody: false,
undoSendSeconds: 8,
+26
View File
@@ -282,6 +282,11 @@ img { max-width: 100%; }
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border-radius: var(--radius-sm); text-align: left; color: var(--fg); white-space: nowrap; }
.menu-item:hover, .menu-item.active { background: var(--bg-hover); }
.menu-item:disabled { opacity: .5; cursor: default; }
/* A menu entry that is a link still looks like a menu entry. The global rule
for `a` would otherwise colour and underline the one item that leaves the
app, which reads as a mistake rather than a distinction. */
a.menu-item { text-decoration: none; color: var(--fg); cursor: pointer; }
a.menu-item:hover { color: var(--fg); }
.menu-item.danger { color: var(--danger); }
.menu-item .menu-kbd { margin-left: auto; color: var(--fg-faint); font-size: .85em; }
.menu-item svg { color: var(--fg-muted); flex: 0 0 auto; }
@@ -1014,3 +1019,24 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
.dp-split { flex-direction: column; }
.dp-times { flex-direction: row; overflow-x: auto; max-height: none; border-left: 0; border-top: 1px solid var(--border); padding: 6px 0 0; }
}
/* Files: the sidebar tree reuses .nav-item, so only the parts the mail tree has
no equivalent for are here. A row in the list is a drop target the same way a
folder in the tree is, and says so the same way. */
.files-table tbody tr.drop-target > td { background: var(--accent-soft); }
.files-table tbody tr.drop-target > td:first-child { box-shadow: inset 2px 0 0 var(--accent); }
.files-table tbody tr[draggable="true"] { cursor: grab; }
.files-table tbody tr[draggable="true"]:active { cursor: grabbing; }
.sidebar .nav-item[draggable="true"] { cursor: pointer; }
.f-name .faint { flex: none; }
/* The "Shared with me" header carries a refresh control, so it is a row rather
than the plain label the other sections use. */
.sidebar .nav-section { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.spin { animation: spin 1s linear infinite; }
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
/* The composer's To label doubles as the way into the address books. */
.composer-field label .link-btn { background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
.composer-field label .link-btn:hover { color: var(--accent); }
.composer-field label .link-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
+33 -3
View File
@@ -120,14 +120,44 @@ export interface MenuItemProps {
kbd?: string;
active?: boolean;
checked?: boolean;
/** Renders the item as a link. An external one gets a new tab. */
href?: string;
external?: boolean;
}
export function MenuItem({ icon, label, onClick, disabled, danger, kbd, active, checked }: MenuItemProps) {
return (
<button type="button" className={`menu-item ${danger ? "danger" : ""} ${active ? "active" : ""}`} onClick={onClick} disabled={disabled} role="menuitem">
export function MenuItem({ icon, label, onClick, disabled, danger, kbd, active, checked, href, external }: MenuItemProps) {
const inner = (
<>
{checked !== undefined ? <span style={{ width: 16, display: "inline-flex" }}>{checked ? "✓" : ""}</span> : icon}
<span className="grow truncate">{label}</span>
{kbd && <span className="menu-kbd">{kbd}</span>}
</>
);
const className = `menu-item ${danger ? "danger" : ""} ${active ? "active" : ""}`;
/*
* A real anchor when there is somewhere to go, rather than a button that
* calls 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 from a menu entry that leaves
* the app.
*/
if (href) {
return (
<a
className={className}
href={href}
role="menuitem"
onClick={onClick}
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
>
{inner}
</a>
);
}
return (
<button type="button" className={className} onClick={onClick} disabled={disabled} role="menuitem">
{inner}
</button>
);
}
+23 -21
View File
@@ -1,18 +1,19 @@
import { useEffect, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, Moon, PenSquare, Settings, Sun, Users, LogOut, Plus, RefreshCw } from "lucide-react";
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users } from "lucide-react";
import { useSession } from "@/store/session";
import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
import { useMail } from "@/store/mail";
import { draftFromMailto, useCompose } from "@/store/compose";
import { Avatar, useIsMobile } from "@/ui/misc";
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { SearchBar } from "./SearchBar";
import { MailboxTree } from "./mail/MailboxTree";
import { FilesTree } from "./files/FilesTree";
import { ContactsSidebar } from "./contacts/ContactsSidebar";
import { CalendarSidebar } from "./calendar/CalendarSidebar";
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
import { formatSize } from "@/lib/format";
import { CAP } from "@/jmap/client";
const PUSH_LABEL = {
connected: "Live updates connected",
@@ -30,8 +31,6 @@ export function AppShell({ children }: { children: ReactNode }) {
const openCompose = useCompose((s) => s.open);
const pushState = useSession((s) => s.pushState);
const session = useSession((s) => s.session);
const accountId = useSession((s) => s.accountId);
const setAccount = useSession((s) => s.setAccount);
const logout = useSession((s) => s.logout);
const acctMenu = useMenu();
const section = location.split("/")[1] || "mail";
@@ -53,8 +52,16 @@ export function AppShell({ children }: { children: ReactNode }) {
}
}, [openCompose, navigate]);
const accounts = session ? Object.entries(session.accounts) : [];
const mailAccounts = accounts.filter(([, a]) => CAP.mail in (a.accountCapabilities ?? {}));
/*
* There is no account switcher any more.
*
* It existed to reach what other people shared, and was the wrong door: it
* moved the whole app to somebody else's account, and Stalwart advertises
* every capability on a shared account, so mail, calendar and contacts went
* with it and were refused. Shares are listed where they belong now -- in
* Files and in Contacts, beside the reader's own -- and found without anyone
* having to know an account switch was involved.
*/
return (
<div className="app">
@@ -65,7 +72,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<Link href="/mail" className="brand">
<img src="/img/logo.png" alt="" />
<span className="brand-name">
ihasmail{mailAccounts.length > 1 ? "" : ""}
ihasmail
</span>
</Link>
<SearchBar />
@@ -93,16 +100,8 @@ export function AppShell({ children }: { children: ReactNode }) {
<div className="hint truncate">{session?.ihasmail?.loginName}</div>
</div>
</div>
{mailAccounts.length > 1 && (
<>
<MenuSep />
<MenuTitle>Accounts</MenuTitle>
{mailAccounts.map(([id, a]) => (
<MenuItem key={id} checked={id === accountId} label={a.name} onClick={() => setAccount(id)} />
))}
</>
)}
<MenuSep />
<MenuItem icon={<BookOpen size={16} />} label="Documentation" href="https://docs.ihasmail.org" external />
<MenuItem icon={<Settings size={16} />} label="Settings" onClick={() => navigate("/settings")} />
<MenuItem icon={<RefreshCw size={16} />} label="Refresh" onClick={() => window.location.reload()} />
<MenuItem icon={<LogOut size={16} />} label="Sign out" onClick={() => void logout()} />
@@ -113,22 +112,25 @@ export function AppShell({ children }: { children: ReactNode }) {
<div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}>
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
<aside className={`sidebar ${drawer ? "open" : ""}`}>
{/* Whatever this pane is for. Files offered Compose, which wrote mail
from the file manager and was the one thing nobody wanted there. */}
<button
className="compose-btn"
onClick={() => {
if (section === "calendar") window.dispatchEvent(new CustomEvent("ihm:new-event"));
else if (section === "contacts") window.dispatchEvent(new CustomEvent("ihm:new-contact"));
else if (section === "files") window.dispatchEvent(new CustomEvent("ihm:files-upload"));
else openCompose();
}}
>
{section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />}
<span>{section === "calendar" ? "New event" : section === "contacts" ? "New contact" : "Compose"}</span>
{section === "files" ? <Upload size={22} /> : section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />}
<span>{section === "calendar" ? "New event" : section === "contacts" ? "New contact" : section === "files" ? "Upload" : "Compose"}</span>
</button>
<div className="sidebar-scroll">
{(section === "mail" || section === "search") && <MailboxTree />}
{section === "calendar" && <CalendarSidebar />}
{section === "contacts" && <div className="nav-section"><span>Contacts</span></div>}
{section === "files" && <div className="nav-section"><span>Files</span></div>}
{section === "contacts" && <ContactsSidebar />}
{section === "files" && <FilesTree />}
{section === "settings" && <div className="nav-section"><span>Settings</span></div>}
</div>
{(section === "mail" || section === "search") && <QuotaBar />}
+81 -1
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star } from "lucide-react";
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, UserMinus, X } from "lucide-react";
import { useCalendar } from "@/store/calendar";
import { dateTimeKey, useSettings } from "@/store/settings";
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
@@ -25,6 +25,13 @@ export function CalendarSidebar() {
const [anchor, setAnchor] = useState(() => startOfDay(selected));
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
const menu = useMenu();
/* Added if the server says so or the reader's settings do; Stalwart will not
always take the flag, so the settings carry it where it refuses. */
const addedShares = new Set(useSettings((s) => s.settings).addedShares);
const isAdded = (c: { accountId: string; calendar: { id: string; isSubscribed?: boolean } }) =>
Boolean(c.calendar.isSubscribed) || addedShares.has(`${c.accountId}:${c.calendar.id}`);
const sharedSubscribed = cal.sharedCalendars.filter(isAdded);
const sharedAvailable = cal.sharedCalendars.filter((c) => !isAdded(c));
const [menuCal, setMenuCal] = useState<Calendar | null>(null);
const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null);
const [share, setShare] = useState<Calendar | null>(null);
@@ -59,16 +66,89 @@ export function CalendarSidebar() {
<div key={c.id} className={`cal-list-item ${cal.hidden[c.id] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}>
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
<span className="cal-name">{c.name}</span>
{Object.keys(c.shareWith ?? {}).length > 0 && <Share2 size={12} className="faint" aria-label="Shared" />}
{c.isDefault && <Star size={12} className="faint" />}
<button className="icon-btn xs nav-more" onClick={(e) => { e.stopPropagation(); setMenuCal(c); menu.open(e); }} aria-label="Calendar options"><MoreVertical size={14} /></button>
</div>
))}
{/* Calendars other people shared, split by whether the reader has added
them. Stalwart returns every calendar in a reachable account with full
rights, so "shared with me" and "there is an account here at all" look
identical -- `isSubscribed` is the only thing that tells them apart,
and adding one is a deliberate act rather than a guess on our part. */}
{sharedSubscribed.length > 0 && (
<>
<div className="nav-section"><span>Shared with me</span></div>
{sharedSubscribed.map(({ accountId, accountName, calendar: c }) => {
const key = `${accountId}:${c.id}`;
return (
<div key={key} className={`cal-list-item ${cal.hidden[key] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(key)} title={`${c.name} — shared by ${accountName}`}>
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
<span className="cal-name">{c.name}</span>
<button
className="icon-btn xs nav-more"
title="Remove from my calendar"
aria-label="Remove from my calendar"
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, false); }}
>
<X size={14} />
</button>
</div>
);
})}
</>
)}
{sharedAvailable.length > 0 && (
<>
<div className="nav-section"><span>Available to add</span></div>
{sharedAvailable.map(({ accountId, accountName, calendar: c }) => (
<div key={`${accountId}:${c.id}`} className="cal-list-item" title={`${c.name} — from ${accountName}`}>
<span className="cal-color" style={{ background: "transparent", borderColor: c.color ?? "var(--border-strong)" }} />
<span className="cal-name faint">{c.name}</span>
<button
className="icon-btn xs nav-more"
title="Add to my calendar"
aria-label="Add to my calendar"
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, true); }}
>
<Plus size={14} />
</button>
</div>
))}
</>
)}
<Popover anchor={menu.anchor} onClose={menu.close} width={220}>
{menuCal && (
<>
<MenuItem icon={cal.hidden[menuCal.id] ? <Eye size={16} /> : <EyeOff size={16} />} label={cal.hidden[menuCal.id] ? "Show" : "Hide"} onClick={() => cal.toggleHidden(menuCal.id)} />
<MenuItem icon={<Pencil size={16} />} label="Edit" onClick={() => setEditCal(menuCal)} />
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={() => setShare(menuCal)} disabled={!menuCal.myRights.mayShare} />
{/* Revoking every share at once, without walking the dialog and
removing people one at a time. Only offered when there is
something to revoke. */}
{Object.keys(menuCal.shareWith ?? {}).length > 0 && (
<MenuItem
icon={<UserMinus size={16} />}
label="Stop sharing"
disabled={!menuCal.myRights.mayShare}
onClick={async () => {
const who = Object.keys(menuCal.shareWith ?? {}).length;
if (!(await confirmDialog({
title: `Stop sharing “${menuCal.name}”?`,
message: `${who === 1 ? "One person" : `${who} people`} will lose access. Events in it are not affected.`,
confirmLabel: "Stop sharing",
danger: true,
}))) return;
try {
await cal.updateCalendar(menuCal.id, { shareWith: null });
toast.success("No longer shared");
} catch (err) {
toast.error((err as Error).message);
}
}}
/>
)}
<MenuItem icon={<Star size={16} />} label="Make default" disabled={menuCal.isDefault} onClick={() => void cal.updateCalendar(menuCal.id, { isDefault: true } as Partial<Calendar>).catch((err) => toast.error((err as Error).message))} />
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuCal.name}”?`, message: "All events in this calendar will be deleted.", confirmLabel: "Delete", danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} />
+33 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle, ChevronDown, FileText, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react";
import { AlertTriangle, BookUser, ChevronDown, FileText, FolderOpen, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react";
import { useCompose, type Draft } from "@/store/compose";
import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings";
@@ -12,6 +12,9 @@ import { formatSize, formatRelative } from "@/lib/format";
import { htmlToText, textToHtml } from "@/lib/text";
import { isValidEmail } from "@/lib/address";
import { attachmentIcon } from "../mail/MessageView";
import { FilePicker } from "./FilePicker";
import { RecipientPicker, type Field } from "./RecipientPicker";
import { useFiles } from "@/store/files";
import { keyboard } from "@/lib/keyboard";
import { useIsMobile } from "@/ui/misc";
import { toast } from "@/ui/toast";
@@ -25,6 +28,10 @@ export function Composer({ draft }: { draft: Draft }) {
const send = useCompose((s) => s.send);
const saveDraft = useCompose((s) => s.saveDraft);
const addFiles = useCompose((s) => s.addFiles);
const addFromFiles = useCompose((s) => s.addFromFiles);
const filesAvailable = useFiles((s) => s.available);
const [pickerOpen, setPickerOpen] = useState(false);
const [addressBookOpen, setAddressBookOpen] = useState(false);
const removeAttachment = useCompose((s) => s.removeAttachment);
const setIdentity = useCompose((s) => s.setIdentity);
const insertTemplate = useCompose((s) => s.insertTemplate);
@@ -169,9 +176,17 @@ export function Composer({ draft }: { draft: Draft }) {
</div>
)}
<div className="composer-field">
<label htmlFor={`${key}-to`}>To</label>
<label htmlFor={`${key}-to`}>
{/* Opens the address books. Autocomplete only helps someone who
already knows the name they are half-way through typing. */}
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title="Choose from address books">To</button>
</label>
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={initialFocus === "to"} />
<span className="field-extra">
{/* Beside Cc and Bcc, because that is where someone looks when
they are thinking about who the message goes to. The label
opens it too, for anyone who tries that first. */}
<button type="button" onClick={() => setAddressBookOpen(true)} title="Choose from address books" aria-label="Choose from address books"><BookUser size={15} /></button>
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>}
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>}
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title="Set a Reply-To address">Reply-To</button>}
@@ -239,11 +254,27 @@ export function Composer({ draft }: { draft: Draft }) {
<MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
{canSchedule && <ScheduleMenuItems maxMs={scheduleMax} onPick={scheduleFor} onCustom={() => { sendMenu.close(); setScheduleOpen(true); }} />}
</Popover>
{addressBookOpen && (
<RecipientPicker
onPick={(field: Field, addresses) => {
// Added to whatever is already there, and the field is opened if
// it was hidden -- picking a Bcc should not put one somewhere
// the writer cannot see it.
const existing = field === "to" ? d.to : field === "cc" ? d.cc : d.bcc;
const merged = [...existing];
for (const a of addresses) if (!merged.some((x) => x.email.toLowerCase() === a.email.toLowerCase())) merged.push(a);
patch({ [field]: merged, ...(field === "cc" ? { showCc: true } : field === "bcc" ? { showBcc: true } : {}) });
}}
onClose={() => setAddressBookOpen(false)}
/>
)}
{pickerOpen && <FilePicker onPick={(picked) => void addFromFiles(key, picked)} onClose={() => setPickerOpen(false)} />}
{canSchedule && scheduleOpen && (
<ScheduleDialog open maxMs={scheduleMax} initial={d.sendAt} onClose={() => setScheduleOpen(false)} onPick={scheduleFor} />
)}
<span className="more-actions">
<button className="icon-btn" title="Attach files" onClick={() => fileRef.current?.click()}><Paperclip size={18} /></button>
{filesAvailable && <button className="icon-btn" title="Attach from Files" onClick={() => setPickerOpen(true)}><FolderOpen size={18} /></button>}
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { const files = Array.from(e.target.files ?? []); if (files.length) addFiles(key, files); e.target.value = ""; }} />
{d.format === "html" && <button className={`icon-btn ${showToolbar ? "active" : ""}`} title="Formatting options" onClick={() => setShowToolbar((v) => !v)}><Type size={18} /></button>}
{settings.templates.length > 0 && <button className="icon-btn" title="Insert template" onClick={templateMenu.open}><FileText size={18} /></button>}
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useState } from "react";
import { ChevronRight, File as FileIcon, Folder, HardDrive, Users } from "lucide-react";
import { Dialog } from "@/ui/dialog";
import { Spinner } from "@/ui/misc";
import { useFiles } from "@/store/files";
import type { AttachableFile } from "@/store/compose";
import type { FileNode } from "@/jmap/types";
import { formatSize } from "@/lib/format";
/**
* Pick something already in Files to attach.
*
* Browsing is the store's, so this shows the same folders the Files view does,
* shared accounts included -- a file somebody shared with you is a file you can
* send on, and having to download it first only to upload it again would be
* the sort of detour the rest of this avoids.
*
* It borrows the Files store rather than keeping its own copy, which means
* opening the picker moves where Files is browsing. Closing it puts that back:
* a detour through somebody's shared folder to find an attachment should not
* leave the file manager somewhere else afterwards.
*/
export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile[]) => void; onClose: () => void }) {
const files = useFiles();
const [cur, setCur] = useState<string | null>(null);
const [picked, setPicked] = useState<Record<string, FileNode>>({});
const [returnTo] = useState(() => files.accountId);
useEffect(() => {
void files.loadChildren(cur);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cur, files.accountId]);
const close = () => {
if (files.accountId !== returnTo) files.openAccount(returnTo);
onClose();
};
const openAccount = (accountId: string | null) => {
files.openAccount(accountId);
setCur(null);
setPicked({});
};
const nodes = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n));
const path = files.pathTo(cur);
const chosen = Object.values(picked);
const viewingShare = files.accountId !== files.ownAccountId;
return (
<Dialog
open
onClose={close}
title="Attach from Files"
size="md"
footer={
<>
<button className="btn" onClick={close}>Cancel</button>
<button
className="btn btn-primary"
disabled={!chosen.length}
onClick={() => {
onPick(chosen.map((n) => ({ accountId: files.accountId!, name: n.name, type: n.type, size: n.size, blobId: n.blobId! })));
close();
}}
>
{chosen.length > 1 ? `Attach ${chosen.length} files` : "Attach"}
</button>
</>
}
>
{files.sharedAccounts.length > 0 && (
<div className="row wrap gap-4" style={{ marginBottom: 10 }}>
<button className={`btn btn-sm ${viewingShare ? "" : "btn-primary"}`} onClick={() => openAccount(files.ownAccountId)}>
<HardDrive size={14} /> My files
</button>
{files.sharedAccounts.map((a) => (
<button key={a.id} className={`btn btn-sm ${files.accountId === a.id ? "btn-primary" : ""}`} onClick={() => openAccount(a.id)}>
<Users size={14} /> {a.name}
</button>
))}
</div>
)}
<div className="breadcrumb mb-8">
<button onClick={() => setCur(null)}><HardDrive size={14} /></button>
{path.map((n) => (
<span key={n.id} className="row gap-4">
<ChevronRight size={12} />
<button onClick={() => setCur(n.id)}>{n.name}</button>
</span>
))}
</div>
{files.loading && !nodes.length ? (
<Spinner />
) : !nodes.length ? (
<p className="hint">This folder is empty.</p>
) : (
nodes.map((n) =>
n.nodeType === "directory" ? (
<button key={n.id} className="menu-item" onClick={() => setCur(n.id)}>
<Folder size={16} />
<span className="grow truncate">{n.name}</span>
<ChevronRight size={14} />
</button>
) : (
<label key={n.id} className="menu-item" style={{ cursor: n.blobId ? "pointer" : "not-allowed", opacity: n.blobId ? 1 : 0.5 }}>
<input
type="checkbox"
disabled={!n.blobId}
checked={Boolean(picked[n.id])}
onChange={(e) =>
setPicked((p) => {
const next = { ...p };
if (e.target.checked) next[n.id] = n;
else delete next[n.id];
return next;
})
}
/>
<FileIcon size={16} />
<span className="grow truncate">{n.name}</span>
<span className="hint">{formatSize(n.size)}</span>
</label>
),
)
)}
{viewingShare && chosen.length > 0 && (
// Blobs belong to the account holding them, so one from a share has to
// be copied into yours before a draft can reference it. Worth saying,
// because it is the difference between instant and a wait.
<p className="hint" style={{ marginTop: 10 }}>Shared files are copied to your account when attached.</p>
)}
</Dialog>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useMemo, useState } from "react";
import { Book, BookOpen, Search, Users, X } from "lucide-react";
import { Spinner } from "@/ui/misc";
import { Dialog } from "@/ui/dialog";
import { useContacts } from "@/store/contacts";
import { useSettings } from "@/store/settings";
import { contactDisplayName, contactEmails } from "@/lib/contacts";
import type { ContactCard, EmailAddress } from "@/jmap/types";
export type Field = "to" | "cc" | "bcc";
/** One selectable address: a card can carry several, so the address is the unit. */
interface Row {
key: string;
name: string | null;
email: string;
book: string;
}
/**
* Choose recipients by looking through the address books.
*
* Autocomplete answers "finish this name for me", which is only useful when the
* writer already knows who they want. This answers the other question -- who is
* there? -- so the books can be read rather than recalled, and several people
* picked in one pass rather than typed one at a time.
*
* Each address is its own row, not each person: someone with a work address and
* a personal one is a choice to make, and a picker that offered the card and
* quietly took the first address would make it for them.
*
* Shared books are in here on the same footing as the reader's own, which is
* the point of having added them -- with the account named, so it is never a
* mystery whose list a name came from.
*/
export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, addresses: EmailAddress[]) => void; onClose: () => void }) {
const contacts = useContacts();
const [q, setQ] = useState("");
const [bookKey, setBookKey] = useState<string>("all");
const [picked, setPicked] = useState<Record<string, Row>>({});
/*
* Contacts are fetched on demand, and nothing had demanded them.
*
* `loadAll` runs when the Contacts view mounts, and `suggest` kicks it off
* itself so autocomplete works from anywhere. This did neither, so opening a
* composer without having visited Contacts first showed an empty picker over
* a full address book -- "no contacts in this address book", about a book
* with contacts in it.
*/
useEffect(() => {
if (contacts.available && !contacts.loaded && !contacts.loading) void contacts.loadAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contacts.available, contacts.loaded]);
/* Added counts whether the server remembered it or the settings did --
Stalwart refuses the flag on a book shared read-only, so for those the
settings are the only record and filtering on `isSubscribed` alone would
leave every shared book out of the picker. */
const addedShares = new Set(useSettings((s) => s.settings).addedShares);
const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed || addedShares.has(`${b.accountId}:${b.book.id}`));
const ownBooks = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
const rows = useMemo(() => {
const out: Row[] = [];
const push = (card: ContactCard, book: string, keyPrefix: string) => {
for (const a of contactEmails(card)) {
if (!a.email) continue;
out.push({ key: `${keyPrefix}:${card.id}:${a.email}`, name: a.name ?? contactDisplayName(card), email: a.email, book });
}
};
if (bookKey === "all" || !bookKey.includes(":")) {
for (const c of Object.values(contacts.cards)) {
if (bookKey !== "all" && !c.addressBookIds?.[bookKey]) continue;
push(c, contacts.books[Object.keys(c.addressBookIds ?? {})[0] ?? ""]?.name ?? "Contacts", "own");
}
}
if (bookKey === "all" || bookKey.includes(":")) {
for (const [key, card] of Object.entries(contacts.sharedCards)) {
const accountId = key.slice(0, key.length - card.id.length - 1);
const inBook = subscribed.find((b) => b.accountId === accountId && card.addressBookIds?.[b.book.id]);
if (!inBook) continue;
if (bookKey !== "all" && bookKey !== `${accountId}:${inBook.book.id}`) continue;
push(card, `${inBook.book.name} · ${inBook.accountName}`, accountId);
}
}
const needle = q.trim().toLowerCase();
const filtered = needle
? out.filter((r) => `${r.name ?? ""} ${r.email}`.toLowerCase().includes(needle))
: out;
return filtered.sort((a, b) => (a.name ?? a.email).localeCompare(b.name ?? b.email));
}, [contacts.cards, contacts.sharedCards, contacts.books, subscribed, bookKey, q]);
const chosen = Object.values(picked);
const toggle = (r: Row) =>
setPicked((p) => {
const next = { ...p };
if (next[r.key]) delete next[r.key];
else next[r.key] = r;
return next;
});
const send = (field: Field) => {
onPick(field, chosen.map((r) => ({ name: r.name, email: r.email })));
onClose();
};
return (
<Dialog
open
onClose={onClose}
title="Choose recipients"
size="lg"
footer={
<>
<button className="btn" onClick={onClose}>Cancel</button>
<button className="btn" disabled={!chosen.length} onClick={() => send("bcc")}>Bcc</button>
<button className="btn" disabled={!chosen.length} onClick={() => send("cc")}>Cc</button>
<button className="btn btn-primary" disabled={!chosen.length} onClick={() => send("to")}>
{chosen.length > 1 ? `To — ${chosen.length} people` : "To"}
</button>
</>
}
>
<div className="row gap-8" style={{ marginBottom: 10 }}>
{/* Same shape as the contact list's own search box. */}
<label className="search-input grow" style={{ height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
<Search size={15} className="faint" />
<input
className="grow"
style={{ background: "none", border: 0, outline: "none", color: "inherit", font: "inherit" }}
placeholder="Search names and addresses"
value={q}
onChange={(e) => setQ(e.target.value)}
autoFocus
/>
</label>
<select className="select" value={bookKey} onChange={(e) => setBookKey(e.target.value)} aria-label="Address book">
<option value="all">All address books</option>
{ownBooks.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
{subscribed.map((b) => (
<option key={`${b.accountId}:${b.book.id}`} value={`${b.accountId}:${b.book.id}`}>
{b.book.name} · {b.accountName}
</option>
))}
</select>
</div>
{chosen.length > 0 && (
<div className="row wrap gap-4" style={{ marginBottom: 10 }}>
{chosen.map((r) => (
<button key={r.key} className="chip" onClick={() => toggle(r)} title="Remove">
{r.name ?? r.email} <X size={12} />
</button>
))}
</div>
)}
<div style={{ maxHeight: "48vh", overflowY: "auto" }}>
{contacts.loading && !rows.length ? (
<Spinner label="Loading contacts…" />
) : !rows.length ? (
<p className="hint">{q ? "Nobody matches that." : "No contacts in this address book."}</p>
) : (
rows.map((r) => (
<label key={r.key} className="menu-item" style={{ cursor: "pointer" }}>
<input type="checkbox" checked={Boolean(picked[r.key])} onChange={() => toggle(r)} />
{r.book.includes("·") ? <BookOpen size={16} className="faint" /> : <Book size={16} className="faint" />}
<span className="grow truncate">
{r.name ?? r.email}
{r.name && <span className="hint"> · {r.email}</span>}
</span>
<span className="hint nowrap">{r.book}</span>
</label>
))
)}
</div>
{!ownBooks.length && !subscribed.length && (
<p className="hint" style={{ marginTop: 8 }}><Users size={12} /> No address books yet.</p>
)}
</Dialog>
);
}
+241
View File
@@ -0,0 +1,241 @@
import { useEffect, useState } from "react";
import { Book, BookOpen, Download, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, UserMinus, Users, X } from "lucide-react";
import { useContacts } from "@/store/contacts";
import { useSession } from "@/store/session";
import { useSettings } from "@/store/settings";
import type { AddressBook } from "@/jmap/types";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { ShareDialog } from "../settings/ShareDialog";
/**
* Re-read the session so newly shared books appear without a sign-in.
*
* Shared accounts arrive in the JMAP session, which is otherwise fetched once
* and refreshed only when a state change is pushed to this tab. Opening
* Contacts is when the answer matters, so that is when it is asked for --
* throttled, since this is navigated to often and usually says nothing new.
*/
let lastRefresh = 0;
async function refreshShares(force = false): Promise<void> {
const now = Date.now();
if (!force && now - lastRefresh < 30_000) return;
lastRefresh = now;
try {
await useSession.getState().refresh();
} catch {
return;
}
await useContacts.getState().init();
}
/**
* Address books in the app's own left pane, the reader's above and other
* people's below.
*
* The two are kept plainly apart rather than merged into one list: a book that
* belongs to somebody else behaves differently -- you cannot add to it, and
* what you do see depends on what they granted -- and a list that hid that
* distinction would be lying about whose contacts these are.
*/
export function ContactsSidebar() {
/* Import and export act on the list the view is showing, so they are asked
for by event rather than reaching across into it. */
const onImport = (file: File) => window.dispatchEvent(new CustomEvent("ihm:contacts-import", { detail: file }));
const onExport = () => window.dispatchEvent(new CustomEvent("ihm:contacts-export"));
const contacts = useContacts();
const settings = useSettings((s) => s.settings);
const [menuBook, setMenuBook] = useState<AddressBook | null>(null);
const [share, setShare] = useState<AddressBook | null>(null);
const [refreshing, setRefreshing] = useState(false);
const menu = useMenu();
useEffect(() => {
void refreshShares();
}, []);
if (!contacts.available) return null;
const own = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
const sel = contacts.selection;
const isOn = (accountId: string | null, bookId: string) => sel.accountId === accountId && sel.bookId === bookId;
/* Added if the server says so or the reader's settings do -- Stalwart will
not take the flag on a book shared read-only, so the settings carry it. */
const added = new Set(settings.addedShares);
const isAdded = (accountId: string, bookId: string) => added.has(`${accountId}:${bookId}`);
const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed || isAdded(b.accountId, b.book.id));
const available = contacts.sharedBooks.filter((b) => !(b.book.isSubscribed || isAdded(b.accountId, b.book.id)));
return (
<>
<div className="nav-section"><span>Contacts</span></div>
<div className={`nav-item ${isOn(null, "all") ? "active" : ""}`} onClick={() => contacts.select({ accountId: null, bookId: "all" })}>
<Users size={17} />
<span className="grow truncate">All contacts</span>
</div>
<div className="nav-section">
<span>My address books</span>
<button
className="icon-btn sm"
title="New address book"
aria-label="New address book"
onClick={async () => {
const name = await promptDialog({ title: "New address book", placeholder: "Name" });
if (!name?.trim()) return;
try {
await contacts.createBook(name.trim());
} catch (err) {
toast.error((err as Error).message);
}
}}
>
<Plus size={14} />
</button>
</div>
{own.map((b) => (
<div
key={b.id}
className={`nav-item ${isOn(null, b.id) ? "active" : ""}`}
onClick={() => contacts.select({ accountId: null, bookId: b.id })}
onContextMenu={(e) => { e.preventDefault(); setMenuBook(b); menu.openAt(e.clientX, e.clientY); }}
>
<Book size={17} />
<span className="grow truncate">{b.name}</span>
{Object.keys(b.shareWith ?? {}).length > 0 && <Share2 size={12} className="faint" aria-label="Shared" />}
</div>
))}
<div className="nav-section">
<span>Shared with me</span>
<button
className="icon-btn sm"
title="Check for new shares"
aria-label="Check for new shares"
onClick={async () => { setRefreshing(true); await refreshShares(true); setRefreshing(false); }}
>
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
</button>
</div>
{subscribed.map(({ accountId, accountName, book }) => (
<div
key={`${accountId}:${book.id}`}
className={`nav-item ${isOn(accountId, book.id) ? "active" : ""}`}
onClick={() => contacts.select({ accountId, bookId: book.id })}
title={`${book.name} — shared by ${accountName}`}
>
<BookOpen size={17} />
<span className="grow truncate">{book.name}</span>
<button
className="icon-btn sm"
title="Remove from my contacts"
aria-label="Remove from my contacts"
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, false); }}
>
<X size={13} />
</button>
</div>
))}
{!subscribed.length && (
<p className="hint" style={{ padding: "4px 12px" }}>
{contacts.sharedLoaded ? "Nothing added yet." : "Looking…"}
</p>
)}
{/* Stalwart returns every book in a reachable account with full rights,
shared or not, so adding one is the reader's decision rather than a
guess made on their behalf. */}
{available.length > 0 && (
<>
<div className="nav-section"><span>Available to add</span></div>
{available.map(({ accountId, accountName, book }) => (
<div key={`${accountId}:${book.id}`} className="nav-item" title={`${book.name} — from ${accountName}`}>
<BookOpen size={17} className="faint" />
<span className="grow truncate faint">{book.name}</span>
<button
className="icon-btn sm"
title="Add to my contacts"
aria-label="Add to my contacts"
onClick={(e) => { e.stopPropagation(); void contacts.setBookSubscribed(accountId, book.id, true); }}
>
<Plus size={13} />
</button>
</div>
))}
</>
)}
{/* Import and export lived in the pane this replaced. */}
<div style={{ padding: "12px 8px" }} className="col gap-8">
<label className="btn btn-sm btn-block">
<Upload size={14} /> Import vCard
<input type="file" accept=".vcf,text/vcard" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ""; }} />
</label>
<button className="btn btn-sm btn-block" onClick={onExport}><Download size={14} /> Export {sel.bookId === "all" ? "all" : "book"}</button>
</div>
<Popover anchor={menu.anchor} onClose={menu.close} width={210}>
{menuBook && (
<>
<MenuItem
icon={<Pencil size={16} />}
label="Rename"
onClick={async () => {
const name = await promptDialog({ title: "Rename address book", defaultValue: menuBook.name });
if (!name?.trim() || name === menuBook.name) return;
try {
await contacts.updateBook(menuBook.id, { name: name.trim() });
} catch (err) {
toast.error((err as Error).message);
}
}}
/>
<MenuItem icon={<Share2 size={16} />} label="Share…" disabled={!menuBook.myRights?.mayShare} onClick={() => setShare(menuBook)} />
{/* Revoking the lot, rather than removing people one at a time in
the dialog. Only shown when there is something to revoke. */}
{Object.keys(menuBook.shareWith ?? {}).length > 0 && (
<MenuItem
icon={<UserMinus size={16} />}
label="Stop sharing"
disabled={!menuBook.myRights?.mayShare}
onClick={async () => {
const who = Object.keys(menuBook.shareWith ?? {}).length;
if (!(await confirmDialog({
title: `Stop sharing “${menuBook.name}”?`,
message: `${who === 1 ? "One person" : `${who} people`} will lose access. The contacts in it are not affected.`,
confirmLabel: "Stop sharing",
danger: true,
}))) return;
try {
await contacts.updateBook(menuBook.id, { shareWith: null });
toast.success("No longer shared");
} catch (err) {
toast.error((err as Error).message);
}
}}
/>
)}
<MenuSep />
<MenuItem
danger
icon={<Trash2 size={16} />}
label="Delete"
disabled={menuBook.isDefault}
onClick={async () => {
if (!(await confirmDialog({ title: `Delete “${menuBook.name}”?`, message: "The contacts in it go too.", confirmLabel: "Delete", danger: true }))) return;
try {
await contacts.destroyBook(menuBook.id);
if (sel.bookId === menuBook.id) contacts.select({ accountId: null, bookId: "all" });
} catch (err) {
toast.error((err as Error).message);
}
}}
/>
</>
)}
</Popover>
{share && <ShareDialog kind="AddressBook" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />}
</>
);
}
+33 -43
View File
@@ -1,17 +1,15 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "wouter";
import { ArrowLeft, Book, Download, Mail, MoreVertical, Pencil, Plus, Search, Share2, Trash2, Upload, Users, Phone, MapPin, Building2, Cake, StickyNote, Globe, Calendar as CalIcon, Star, Pin } from "lucide-react";
import { ArrowLeft, Building2, Cake, Calendar as CalIcon, Download, Globe, Mail, MapPin, Pencil, Phone, Pin, Plus, Search, StickyNote, Trash2, Users } from "lucide-react";
import { useContacts } from "@/store/contacts";
import { useCompose } from "@/store/compose";
import type { AddressBook, ContactCard } from "@/jmap/types";
import type { ContactCard } from "@/jmap/types";
import { contactDisplayName, contactEmails, contactPhoto, formatAddressLines, sortKey, toVCard } from "@/lib/contacts";
import { formatDate, formatDateLong } from "@/lib/datetime";
import { Avatar, Empty, Spinner, useIsNarrow } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog } from "@/ui/dialog";
import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { ContactEditor } from "./ContactEditor";
import { ShareDialog } from "../settings/ShareDialog";
import { avatarColor } from "@/lib/address";
export function ContactsView({ id }: { id?: string }) {
@@ -19,11 +17,11 @@ export function ContactsView({ id }: { id?: string }) {
const contacts = useContacts();
const narrow = useIsNarrow();
const [q, setQ] = useState("");
const [bookId, setBookId] = useState<string | "all">("all");
/* The book being shown lives in the store, because the list that chooses it
is the app's own sidebar rather than anything this view owns. */
const sel = contacts.selection;
const bookId = sel.bookId;
const [editing, setEditing] = useState<Partial<ContactCard> | null>(null);
const [share, setShare] = useState<AddressBook | null>(null);
const bookMenu = useMenu();
const [menuBook, setMenuBook] = useState<AddressBook | null>(null);
const openCompose = useCompose((s) => s.open);
useEffect(() => {
@@ -33,16 +31,38 @@ export function ContactsView({ id }: { id?: string }) {
useEffect(() => {
const onNew = () => setEditing({});
const onImport = (ev: Event) => { const f = (ev as CustomEvent<File>).detail; if (f) void importFile(f); };
const onExport = () => exportAll();
window.addEventListener("ihm:new-contact", onNew);
return () => window.removeEventListener("ihm:new-contact", onNew);
}, []);
window.addEventListener("ihm:contacts-import", onImport);
window.addEventListener("ihm:contacts-export", onExport);
return () => {
window.removeEventListener("ihm:new-contact", onNew);
window.removeEventListener("ihm:contacts-import", onImport);
window.removeEventListener("ihm:contacts-export", onExport);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
});
const list = useMemo(() => {
// A shared book lists that account's cards; anything else lists the
// reader's own. They are never mixed: whose contacts you are looking at is
// the one thing this view must not be vague about.
if (sel.accountId) {
const prefix = `${sel.accountId}:`;
const theirs = Object.entries(contacts.sharedCards)
.filter(([key]) => key.startsWith(prefix))
.map(([, c]) => c)
.filter((c) => bookId === "all" || c.addressBookIds?.[bookId]);
return contacts.filterCards(theirs, q);
}
const all = contacts.search(q);
return bookId === "all" ? all : all.filter((c) => c.addressBookIds?.[bookId]);
}, [contacts, q, bookId]);
}, [contacts, q, bookId, sel.accountId]);
const selected = id ? contacts.cards[id] : undefined;
const selected = id
? contacts.cards[id] ?? Object.entries(contacts.sharedCards).find(([key]) => key.endsWith(`:${id}`))?.[1]
: undefined;
const books = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
const groups = useMemo(() => {
const out: Array<{ letter: string; items: ContactCard[] }> = [];
@@ -84,35 +104,6 @@ export function ContactsView({ id }: { id?: string }) {
return (
<div className={`contacts-layout ${selected || editing ? "detail" : ""}`}>
<aside className="contacts-books">
<button className={`nav-item ${bookId === "all" ? "active" : ""}`} style={{ width: "100%" }} onClick={() => setBookId("all")}>
<Users size={18} /><span className="nav-label">All contacts</span><span className="nav-count">{Object.keys(contacts.cards).length}</span>
</button>
<div className="nav-section"><span>Address books</span>
<button className="icon-btn" title="New address book" onClick={async () => { const n = await promptDialog({ title: "New address book", placeholder: "Name" }); if (n?.trim()) { try { await contacts.createBook(n.trim()); } catch (err) { toast.error((err as Error).message); } } }}><Plus size={16} /></button>
</div>
{books.map((b) => (
<button key={b.id} className={`nav-item ${bookId === b.id ? "active" : ""}`} style={{ width: "100%" }} onClick={() => setBookId(b.id)} onContextMenu={(e) => { e.preventDefault(); setMenuBook(b); bookMenu.openAt(e.clientX, e.clientY); }}>
<Book size={18} /><span className="nav-label">{b.name}</span>
<span className="icon-btn nav-more" onClick={(e) => { e.stopPropagation(); setMenuBook(b); bookMenu.open(e); }}><MoreVertical size={16} /></span>
</button>
))}
<div style={{ padding: "12px 8px" }} className="col gap-8">
<label className="btn btn-sm btn-block"><Upload size={14} /> Import vCard<input type="file" accept=".vcf,text/vcard" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void importFile(f); e.target.value = ""; }} /></label>
<button className="btn btn-sm btn-block" onClick={exportAll}><Download size={14} /> Export {bookId === "all" ? "all" : "book"}</button>
</div>
<Popover anchor={bookMenu.anchor} onClose={bookMenu.close} width={220}>
{menuBook && (
<>
<MenuItem icon={<Pencil size={16} />} label="Rename" onClick={async () => { const n = await promptDialog({ title: "Rename address book", defaultValue: menuBook.name }); if (n?.trim()) void contacts.updateBook(menuBook.id, { name: n.trim() }).catch((err) => toast.error((err as Error).message)); }} />
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={() => setShare(menuBook)} />
<MenuItem icon={<Star size={16} />} label={menuBook.isDefault ? "Default book" : "Make default"} disabled={menuBook.isDefault} onClick={() => void contacts.updateBook(menuBook.id, { isDefault: true } as Partial<AddressBook>).catch((err) => toast.error((err as Error).message))} />
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuBook.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuBook.name}”?`, message: "All contacts in it will be deleted.", confirmLabel: "Delete", danger: true })) void contacts.destroyBook(menuBook.id).catch((err) => toast.error((err as Error).message)); }} />
</>
)}
</Popover>
</aside>
<section className="contacts-list">
<div className="list-search row">
@@ -154,7 +145,6 @@ export function ContactsView({ id }: { id?: string }) {
)}
</section>
{editing && <ContactEditor card={editing} defaultBookId={bookId !== "all" ? bookId : (books.find((b) => b.isDefault)?.id ?? books[0]?.id ?? null)} onClose={() => setEditing(null)} onSaved={(cid) => { setEditing(null); navigate(`/contacts/${cid}`); }} />}
{share && <ShareDialog kind="AddressBook" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />}
</div>
);
}
+294
View File
@@ -0,0 +1,294 @@
import { useEffect, useState } from "react";
import { useLocation } from "wouter";
import { ChevronDown, ChevronRight, Folder, FolderOpen, FolderPlus, HardDrive, Pencil, RefreshCw, Share2, Trash2, Users } from "lucide-react";
import { useFiles } from "@/store/files";
import { useSession } from "@/store/session";
import type { FileNode, Id } from "@/jmap/types";
import { canDropFileNode, isShared } from "@/lib/filenode";
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { loadRaw, saveJson } from "@/lib/storage";
import { ShareDialog } from "../settings/ShareDialog";
/**
* Re-read the session, so the shared accounts on offer are current.
*
* Throttled because Files is navigated to often and this is a round trip that
* tells the reader nothing new most times it runs.
*/
let lastShareRefresh = 0;
async function refreshShares(force = false): Promise<void> {
const now = Date.now();
if (!force && now - lastShareRefresh < 30_000) return;
lastShareRefresh = now;
try {
await useSession.getState().refresh();
} catch {
// The tree still lists whatever the last session said; a failed refresh is
// not worth an error over something the reader did not ask for.
return;
}
await useFiles.getState().init();
}
/** The MIME a dragged node is offered under, so a target can recognise it. */
export const NODE_MIME = "application/x-ihasmail-filenode";
/**
* The folder tree beside the file list.
*
* Every directory in the account arrives in one query, so this never waits on
* an expand and a drag always knows every folder it could land on -- including
* ones the reader has never opened.
*/
export function FilesTree() {
const [location, navigate] = useLocation();
const nodes = useFiles((s) => s.nodes);
const dirIds = useFiles((s) => s.dirIds);
const treeLoaded = useFiles((s) => s.treeLoaded);
const available = useFiles((s) => s.available);
const loadTree = useFiles((s) => s.loadTree);
const accountId = useFiles((s) => s.accountId);
const ownAccountId = useFiles((s) => s.ownAccountId);
const sharedAccounts = useFiles((s) => s.sharedAccounts);
const [refreshing, setRefreshing] = useState(false);
const viewingShare = Boolean(accountId && accountId !== ownAccountId);
// Kept across sessions, the way the mailbox tree keeps its own.
const [expanded, setExpandedState] = useState<Record<Id, boolean>>(() => loadRaw("files-expanded", {}));
const setExpanded = (fn: (x: Record<Id, boolean>) => Record<Id, boolean>) => setExpandedState((x) => { const next = fn(x); saveJson("files-expanded", next); return next; });
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
const [shareNode, setShareNode] = useState<FileNode | null>(null);
const [rootDrop, setRootDrop] = useState(false);
const menu = useMenu();
/* Shared with the list pane: a drag starting in one has to be recognised by
the other. See the note on `draggingId` in the store. */
const draggingId = useFiles((s) => s.draggingId);
const setDraggingId = useFiles((s) => s.setDragging);
useEffect(() => {
if (available && !treeLoaded) void loadTree();
}, [available, treeLoaded, loadTree]);
/*
* Ask the server what is shared, on the way in.
*
* Shared accounts arrive in the JMAP session, which is fetched at sign-in and
* refreshed only when a session-state change is pushed to this tab. A share
* granted while the tab was open therefore stayed invisible until the next
* sign-in -- and a share removed stayed on offer, which is why two browsers
* disagreed about whether an account still existed. Opening Files is the
* moment the answer matters, so that is when it is asked for.
*/
useEffect(() => {
void refreshShares();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const currentId = location.startsWith("/files/") ? location.slice("/files/".length) : null;
// Open the branch the reader is looking at, so the current folder is visible
// without them having to find it.
useEffect(() => {
if (!currentId) return;
const open: Record<Id, boolean> = {};
for (let id: Id | null | undefined = nodes[currentId]?.parentId; id; id = nodes[id]?.parentId) open[id] = true;
if (Object.keys(open).length) setExpanded((x) => ({ ...x, ...open }));
}, [currentId, nodes]);
if (!available) return null;
const dirs = dirIds.map((id) => nodes[id]).filter((n): n is FileNode => Boolean(n));
const childrenOf = (parentId: Id | null) => dirs.filter((d) => (d.parentId ?? null) === parentId);
const canDropOn = (targetId: Id | null) => Boolean(draggingId) && canDropFileNode(nodes, draggingId!, targetId);
const moveTo = async (id: Id, parentId: Id | null) => {
setDraggingId(null);
try {
await useFiles.getState().move(id, parentId);
if (parentId) setExpanded((x) => ({ ...x, [parentId]: true }));
} catch (err) {
toast.error((err as Error).message);
}
};
/** Files dropped from outside land in the folder they were dropped on. */
const dropFiles = async (parentId: Id | null, dt: DataTransfer) => {
const entries = entriesFromDrop(dt);
const flat = Array.from(dt.files);
if (entries.length && hasDirectory(entries)) {
const plan = await planUpload(entries);
if (plan.length) await useFiles.getState().uploadPlan(parentId, plan);
return;
}
if (flat.length) await useFiles.getState().upload(parentId, flat);
};
const onDrop = (targetId: Id | null) => (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setRootDrop(false);
if (e.dataTransfer.types.includes(NODE_MIME)) {
const id = e.dataTransfer.getData(NODE_MIME);
if (id && canDropFileNode(nodes, id, targetId)) void moveTo(id, targetId);
return;
}
if (e.dataTransfer.types.includes("Files")) void dropFiles(targetId, e.dataTransfer);
};
const onDragOver = (targetId: Id | null) => (e: React.DragEvent) => {
const node = e.dataTransfer.types.includes(NODE_MIME);
if (node ? !canDropOn(targetId) : !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = node ? "move" : "copy";
};
const row = (d: FileNode, depth: number) => {
const kids = childrenOf(d.id);
const open = Boolean(expanded[d.id]);
return (
<div key={d.id}>
<div
className={`nav-item ${currentId === d.id ? "active" : ""} ${draggingId && canDropOn(d.id) ? "drop-target" : ""}`}
style={{ paddingLeft: 8 + depth * 14 }}
onClick={() => navigate(`/files/${d.id}`)}
onContextMenu={(e) => { e.preventDefault(); setMenuNode(d); menu.openAt(e.clientX, e.clientY); }}
draggable
onDragStart={(e) => { e.dataTransfer.setData(NODE_MIME, d.id); e.dataTransfer.effectAllowed = "move"; setDraggingId(d.id); }}
onDragEnd={() => setDraggingId(null)}
onDragOver={onDragOver(d.id)}
onDrop={onDrop(d.id)}
>
<button
className="nav-twisty"
aria-label={open ? "Collapse" : "Expand"}
style={{ visibility: kids.length ? "visible" : "hidden" }}
onClick={(e) => { e.stopPropagation(); setExpanded((x) => ({ ...x, [d.id]: !open })); }}
>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{open && kids.length ? <FolderOpen size={17} /> : <Folder size={17} />}
<span className="grow truncate">{d.name}</span>
{isShared(d) && <Share2 size={12} className="faint" aria-label="Shared" />}
</div>
{open && kids.map((k) => row(k, depth + 1))}
</div>
);
};
return (
<>
<div className="nav-section"><span>{viewingShare ? "Shared folder" : "Files"}</span></div>
<div
className={`nav-item ${currentId === null ? "active" : ""} ${rootDrop ? "drop-target" : ""}`}
onClick={() => navigate("/files")}
onContextMenu={(e) => { e.preventDefault(); setMenuNode(null); menu.openAt(e.clientX, e.clientY); }}
onDragOver={(e) => { onDragOver(null)(e); if (!e.defaultPrevented) return; setRootDrop(true); }}
onDragLeave={() => setRootDrop(false)}
onDrop={onDrop(null)}
>
<span className="nav-twisty" aria-hidden="true" />
<HardDrive size={17} />
<span className="grow truncate">{viewingShare ? sharedAccounts.find((a) => a.id === accountId)?.name ?? "Shared files" : "All files"}</span>
</div>
{childrenOf(null).map((d) => row(d, 1))}
{treeLoaded && !dirs.length && <p className="hint" style={{ padding: "4px 12px" }}>{viewingShare ? "Nothing shared here." : "No folders yet."}</p>}
{/* Reaching a share used to mean switching the whole app to the other
account from the profile menu, which pointed mail, calendar and
contacts at them as well. Shared folders belong here, beside your
own. */}
{(viewingShare || sharedAccounts.length > 0) && (
<>
<div className="nav-section">
<span>Shared with me</span>
<button
className="icon-btn sm"
title="Check for new shares"
aria-label="Check for new shares"
onClick={async () => { setRefreshing(true); await refreshShares(true); setRefreshing(false); }}
>
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
</button>
</div>
{viewingShare && (
<div className="nav-item" onClick={() => { useFiles.getState().openAccount(ownAccountId); navigate("/files"); }}>
<span className="nav-twisty" aria-hidden="true" />
<HardDrive size={17} />
<span className="grow truncate">Back to my files</span>
</div>
)}
{sharedAccounts.map((a) => (
<div
key={a.id}
className={`nav-item ${accountId === a.id ? "active" : ""}`}
onClick={() => { useFiles.getState().openAccount(a.id); navigate("/files"); }}
>
<span className="nav-twisty" aria-hidden="true" />
<Users size={17} />
<span className="grow truncate">{a.name}</span>
</div>
))}
{!sharedAccounts.length && <p className="hint" style={{ padding: "4px 12px" }}>Nothing is shared with you.</p>}
</>
)}
<Popover anchor={menu.anchor} onClose={menu.close} width={210}>
<MenuItem
icon={<FolderPlus size={16} />}
label="New folder"
onClick={async () => {
const name = await promptDialog({ title: "New folder", placeholder: "Folder name" });
if (!name?.trim()) return;
try {
await useFiles.getState().mkdir(menuNode?.id ?? null, name.trim());
if (menuNode) setExpanded((x) => ({ ...x, [menuNode.id]: true }));
} catch (err) {
toast.error((err as Error).message);
}
}}
/>
{menuNode && (
<>
<MenuItem
icon={<Pencil size={16} />}
label="Rename"
disabled={!menuNode.myRights?.mayRename}
onClick={async () => {
const name = await promptDialog({ title: "Rename", defaultValue: menuNode.name });
if (!name?.trim() || name === menuNode.name) return;
try {
await useFiles.getState().rename(menuNode.id, name.trim());
} catch (err) {
toast.error((err as Error).message);
}
}}
/>
<MenuItem icon={<Share2 size={16} />} label="Share…" disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
<MenuSep />
<MenuItem
danger
icon={<Trash2 size={16} />}
label="Delete"
disabled={!menuNode.myRights?.mayDelete}
onClick={async () => {
if (!(await confirmDialog({ title: `Delete “${menuNode.name}”?`, message: "Everything inside it goes too.", confirmLabel: "Delete", danger: true }))) return;
try {
await useFiles.getState().destroy([menuNode.id]);
if (currentId === menuNode.id) navigate("/files");
toast.success("Deleted");
} catch (err) {
toast.error((err as Error).message);
}
}}
/>
</>
)}
</Popover>
{shareNode && <ShareDialog kind="FileNode" id={shareNode.id} name={shareNode.name} shareWith={shareNode.shareWith ?? null} onClose={() => setShareNode(null)} />}
</>
);
}
+85 -9
View File
@@ -1,10 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { ChevronRight, Download, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Trash2, Upload, FolderInput } from "lucide-react";
import { ChevronRight, Download, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Share2, Trash2, Upload, FolderInput } from "lucide-react";
import { useFiles } from "@/store/files";
import { client } from "@/jmap/client";
import type { FileNode } from "@/jmap/types";
import { formatSize, formatListDate } from "@/lib/format";
import { canDropFileNode, isShared } from "@/lib/filenode";
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
import { NODE_MIME } from "./FilesTree";
import { ShareDialog } from "../settings/ShareDialog";
import { Empty, Spinner } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog";
@@ -19,12 +23,28 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
const menu = useMenu();
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
const [moveNode, setMoveNode] = useState<FileNode | null>(null);
const [shareNode, setShareNode] = useState<FileNode | null>(null);
/* Shared with the sidebar tree, so a row dragged onto a folder there is
recognised. See the note on `draggingId` in the store. */
const draggingId = files.draggingId;
const setDraggingId = files.setDragging;
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (files.available) void files.loadChildren(parentId);
// `accountId` is in here because opening a share changes which account the
// same route means: at /files the parent is null before and after, so
// without it the listing would keep showing the previous account's folder.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [files.available, parentId]);
}, [files.available, files.accountId, parentId]);
// The sidebar's primary button asks for an upload here, the way it asks the
// calendar for a new event.
useEffect(() => {
const open = () => inputRef.current?.click();
window.addEventListener("ihm:files-upload", open);
return () => window.removeEventListener("ihm:files-upload", open);
}, []);
// Ensure ancestors are loaded for breadcrumbs
useEffect(() => {
@@ -48,13 +68,37 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
const nodes = ids.map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n));
const path = files.pathTo(parentId);
const onDrop = (e: React.DragEvent) => {
/* A drop lands in `into`, which is the folder under the pointer when there is
one and the folder being listed otherwise. Entries have to be read out
before the first await -- the list is emptied the moment the handler
returns -- so that happens here, synchronously, for every path. */
const dropOnto = (into: string | null, e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDropping(false);
const list = Array.from(e.dataTransfer.files);
if (list.length) void files.upload(parentId, list);
if (e.dataTransfer.types.includes(NODE_MIME)) {
const id = e.dataTransfer.getData(NODE_MIME);
setDraggingId(null);
if (id && canDropFileNode(files.nodes, id, into)) {
void files.move(id, into).catch((err) => toast.error((err as Error).message));
}
return;
}
if (!e.dataTransfer.types.includes("Files")) return;
const entries = entriesFromDrop(e.dataTransfer);
const flat = Array.from(e.dataTransfer.files);
void (async () => {
if (entries.length && hasDirectory(entries)) {
const plan = await planUpload(entries);
if (plan.length) await files.uploadPlan(into, plan);
return;
}
if (flat.length) await files.upload(into, flat);
})();
};
const onDrop = (e: React.DragEvent) => dropOnto(parentId, e);
const download = (n: FileNode) => {
if (!n.blobId) return;
const a = document.createElement("a");
@@ -64,7 +108,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
};
return (
<div className={`files-layout ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}>
<div className={`files-layout ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } else if (e.dataTransfer.types.includes(NODE_MIME) && canDropFileNode(files.nodes, draggingId ?? "", parentId)) { e.preventDefault(); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}>
<div className="files-toolbar">
<div className="breadcrumb">
<button className={path.length ? "" : "current"} onClick={() => navigate("/files")}><Home size={16} /></button>
@@ -85,7 +129,16 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
</div>
)}
{files.error && <div className="error-box" style={{ margin: 12 }}>{files.error}</div>}
<div className="files-scroll">
<div
className="files-scroll"
onContextMenu={(e) => {
// Only the empty space below the rows: a row has its own menu.
if ((e.target as HTMLElement).closest("tr")) return;
e.preventDefault();
setMenuNode(null);
menu.openAt(e.clientX, e.clientY);
}}
>
{files.loading && !nodes.length ? <Spinner /> : !nodes.length ? (
<Empty icon={<FolderOpen size={40} />} title="This folder is empty">Drag files here or use Upload.</Empty>
) : (
@@ -93,8 +146,23 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
<thead><tr><th>Name</th><th className="hide-mobile">Size</th><th className="hide-mobile">Modified</th><th /></tr></thead>
<tbody>
{nodes.map((n) => (
<tr key={n.id} className={selected === n.id ? "selected" : ""} onClick={() => setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
<td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span></div></td>
<tr
key={n.id}
className={`${selected === n.id ? "selected" : ""} ${draggingId && n.nodeType === "directory" && canDropFileNode(files.nodes, draggingId, n.id) ? "drop-target" : ""}`}
draggable
onDragStart={(e) => { e.dataTransfer.setData(NODE_MIME, n.id); e.dataTransfer.effectAllowed = "move"; setDraggingId(n.id); }}
onDragEnd={() => setDraggingId(null)}
onDragOver={(e) => {
if (n.nodeType !== "directory") return;
const node = e.dataTransfer.types.includes(NODE_MIME);
if (node ? !(draggingId && canDropFileNode(files.nodes, draggingId, n.id)) : !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = node ? "move" : "copy";
}}
onDrop={(e) => { if (n.nodeType === "directory") dropOnto(n.id, e); }}
onClick={() => setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
<td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span>{isShared(n) && <Share2 size={13} className="faint" aria-label="Shared" />}</div></td>
<td className="hide-mobile muted">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td>
<td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td>
<td style={{ textAlign: "right" }}><button className="icon-btn sm" onClick={(e) => { e.stopPropagation(); setMenuNode(n); menu.open(e); }} aria-label="Options"><MoreVertical size={16} /></button></td>
@@ -105,17 +173,25 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
)}
</div>
<Popover anchor={menu.anchor} onClose={menu.close} width={200}>
{!menuNode && (
<>
<MenuItem icon={<Upload size={16} />} label="Upload files…" onClick={() => inputRef.current?.click()} />
<MenuItem icon={<FolderPlus size={16} />} label="New folder" onClick={async () => { const n = await promptDialog({ title: "New folder", placeholder: "Folder name" }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
</>
)}
{menuNode && (
<>
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label="Open" onClick={() => navigate(`/files/${menuNode.id}`)} /> : <MenuItem icon={<Download size={16} />} label="Download" onClick={() => download(menuNode)} />}
<MenuItem icon={<Pencil size={16} />} label="Rename" disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: "Rename", defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
<MenuItem icon={<FolderInput size={16} />} label="Move to…" onClick={() => setMoveNode(menuNode)} />
<MenuItem icon={<Share2 size={16} />} label="Share…" disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuNode.name}”?`, confirmLabel: "Delete", danger: true })) { try { await files.destroy([menuNode.id]); toast.success("Deleted"); } catch (err) { toast.error((err as Error).message); } } }} />
</>
)}
</Popover>
{moveNode && <MoveDialog node={moveNode} onClose={() => setMoveNode(null)} />}
{shareNode && <ShareDialog kind="FileNode" id={shareNode.id} name={shareNode.name} shareWith={shareNode.shareWith ?? null} onClose={() => setShareNode(null)} />}
</div>
);
}
+21
View File
@@ -14,6 +14,7 @@ import { LabelPicker } from "./LabelPicker";
import type { Id } from "@/jmap/types";
import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { isUnknownMailbox } from "@/lib/mailboxRoute";
import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled";
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
@@ -39,6 +40,26 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
if (!search && !mailboxId && inboxId) navigate(`/mail/${inboxId}`, { replace: true });
}, [search, mailboxId, inboxId, navigate]);
/*
* A folder id this account does not have.
*
* It used to render the ordinary empty state -- "Nothing here. This folder is
* empty." -- which 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). Only reachable from outside the app: the sidebar links to ids
* that exist.
*
* Inbox is the kinder landing than a dead end, but silently swapping one
* folder for another would be its own small lie, so it says what happened.
* `mailboxesLoaded` gates it: without that, every cold load redirects in the
* moment before the folder list arrives.
*/
useEffect(() => {
if (!isUnknownMailbox({ mailboxId, mailboxes, loaded: mailboxesLoaded, search }) || !inboxId) return;
toast.show("That folder no longer exists. Showing your inbox instead.");
navigate(`/mail/${inboxId}`, { replace: true });
}, [search, mailboxId, mailboxesLoaded, mailboxes, inboxId, navigate]);
// Build & run the list query
const listQuery = useMemo<ListQuery | null>(() => {
if (search) {
+8 -1
View File
@@ -292,6 +292,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
}
function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void }) {
const shared = Object.keys(m.shareWith ?? {}).length > 0;
const [, navigate] = useLocation();
const colors = useSettings((s) => s.settings.folderColors);
const update = useSettings((s) => s.update);
@@ -354,7 +355,13 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
<MenuItem icon={<FolderPlus size={16} />} label="New subfolder" onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
<MenuItem icon={<Pencil size={16} />} label="Rename" onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={onShare} />
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
stores the share, and it never reaches the other account -- its own
docs list calendars, address books and files as shareable and not mail
folders. Offering it produced shares that looked real and did nothing.
One that already exists can still be cleared here, which is the only
reason this entry survives at all. */}
{shared && <MenuItem icon={<Share2 size={16} />} label="Stop sharing" onClick={onShare} />}
<MenuSep />
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
+29 -2
View File
@@ -45,6 +45,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
const [showHeaders, setShowHeaders] = useState(false);
const [source, setSource] = useState<string | null>(null);
const [allowRemote, setAllowRemote] = useState(false);
/* Stable, so the body's click handler keeps its identity between renders.
Passing an inline arrow here is what made the handler change on every
render in the first place. */
const showImages = useCallback(() => setAllowRemote(true), []);
const [filterOpen, setFilterOpen] = useState(false);
const moreMenu = useMenu();
const addrMenu = useAddressMenu();
@@ -269,7 +273,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{icsPart && <InviteCard email={e} part={icsPart} />}
{vcfParts.map((p) => <VCardCard key={p.blobId ?? p.partId ?? ""} part={p} accountId={accountId} />)}
<div className="message-body">
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} themed={themed} onShowImages={() => setAllowRemote(true)} /> : <TextBody text={textRaw ?? ""} />}
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} themed={themed} onShowImages={showImages} /> : <TextBody text={textRaw ?? ""} />}
</div>
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
{unsubscribe && (
@@ -405,9 +409,32 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bod
}
setHasQuote(found);
setQuoteOpen(false);
/*
* `onClick` is deliberately not a dependency of this effect.
*
* This is the effect that writes the body into the shadow root, so anything
* in its dependencies rebuilds the entire message. The click handler used
* to be in here, and it changes identity on every render -- it closes over
* a prop the parent recreates inline -- so every render of the message
* threw the rendered body away and built it again. Marking as read does
* exactly that: the store hands back a new email object, the thread
* re-renders, and the reader watched the message vanish and come back,
* white to dark to white on an unstyled HTML mail, half a second after they
* started reading it (#100). The quoted-text toggle reset with it.
*
* The listener lives in its own effect below. It is attached to the shadow
* root rather than to its contents, which survives this rewriting anyway,
* so a changing handler now costs a listener swap and nothing else.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [html, bodyStyle, themed]);
useEffect(() => {
const root = hostRef.current?.shadowRoot;
if (!root) return;
root.addEventListener("click", onClick);
return () => root.removeEventListener("click", onClick);
}, [html, bodyStyle, themed, onClick]);
}, [onClick]);
useEffect(() => {
const root = hostRef.current?.shadowRoot;
+51 -4
View File
@@ -10,6 +10,10 @@ import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { Spinner } from "@/ui/misc";
import { client } from "@/jmap/client";
import { LabelPicker } from "./LabelPicker";
import { threadScrollTarget } from "@/lib/threadScroll";
/** How long the opening scroll keeps its place while bodies and images land. */
const HOLD_MS = 2000;
interface Props {
threadId: Id;
@@ -115,11 +119,54 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages.map((m) => m.id + (m.keywords.$seen ? "1" : "0")).join(","), settings.markReadDelay]);
// Scroll last expanded into view on load
/*
* Open on the first unread message rather than the newest one (#87).
*
* Scrolling once is not enough. Message bodies are written into shadow roots
* by child effects, and the images in them load later still, so the pane goes
* on growing after the scroll -- and `scrollIntoView` clamps to the scroll
* range as it stands the moment it is called. The read-thread fallback always
* 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 stopped 39px short of the bottom, every time (#89).
*
* So the target is held against the top of the pane while the thread settles,
* and let go the moment the reader touches it. A pane that re-scrolls under
* someone who has started reading is worse than one that lands short, which
* is why the hold ends on the first sign of them rather than when the content
* stops changing.
*/
useEffect(() => {
if (!messages.length || !scrollRef.current) return;
const el = scrollRef.current.querySelector<HTMLElement>(`[data-msg-id="${CSS.escape(lastId ?? "")}"]`);
if (el && messages.length > 1) el.scrollIntoView({ block: "start" });
const sc = scrollRef.current;
if (!messages.length || !sc) return;
const target = threadScrollTarget(messages, wasUnread);
if (!target) return;
let held = true;
const align = () => {
if (held) sc.querySelector<HTMLElement>(`[data-msg-id="${CSS.escape(target)}"]`)?.scrollIntoView({ block: "start" });
};
const release = () => {
held = false;
};
align();
// The messages and the reply box: what grows is one of their heights.
const ro = new ResizeObserver(align);
for (const child of sc.children) ro.observe(child);
// `scroll` is not in here: the aligning does that itself.
for (const ev of ["wheel", "pointerdown", "touchstart"]) sc.addEventListener(ev, release, { passive: true });
window.addEventListener("keydown", release);
const settled = window.setTimeout(release, HOLD_MS);
return () => {
release();
ro.disconnect();
for (const ev of ["wheel", "pointerdown", "touchstart"]) sc.removeEventListener(ev, release);
window.removeEventListener("keydown", release);
window.clearTimeout(settled);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threadId, messages.length > 0]);
+2 -2
View File
@@ -34,7 +34,7 @@ export function FoldersSettings() {
return (
<div>
<h1>Folders</h1>
<p className="lead">Create, rename, hide and share folders. {q && q.hardLimit ? `Storage: ${formatSize(q.used)} of ${formatSize(q.hardLimit)} used.` : ""}</p>
<p className="lead">Create, rename and hide folders. {q && q.hardLimit ? `Storage: ${formatSize(q.used)} of ${formatSize(q.hardLimit)} used.` : ""}</p>
<button className="btn mb-16" onClick={() => void create()}><Plus size={16} /> New folder</button>
<table className="sessions-table">
<thead><tr><th>Folder</th><th>Messages</th><th>Unread</th><th /></tr></thead>
@@ -48,7 +48,7 @@ export function FoldersSettings() {
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
<button className="icon-btn sm" title="Rename" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await promptDialog({ title: "Rename folder", defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
<button className="icon-btn sm" title={m.isSubscribed ? "Hide" : "Show"} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
<button className="icon-btn sm" title="Share" onClick={() => setShare(m)}><Share2 size={16} /></button>
{Object.keys(m.shareWith ?? {}).length > 0 && <button className="icon-btn sm" title="Stop sharing" onClick={() => setShare(m)}><Share2 size={16} /></button>}
<button className="icon-btn sm danger" title="Delete" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
</div>
</td>
+35 -8
View File
@@ -4,11 +4,13 @@ import { Dialog } from "@/ui/dialog";
import { useContacts } from "@/store/contacts";
import { useMail } from "@/store/mail";
import { useCalendar } from "@/store/calendar";
import { useFiles } from "@/store/files";
import { client, setErrorMessage } from "@/jmap/client";
import { toast } from "@/ui/toast";
import type { Id, Principal } from "@/jmap/types";
type Kind = "Mailbox" | "Calendar" | "AddressBook";
/* The JMAP type name, used verbatim as the `/set` method prefix. */
type Kind = "Mailbox" | "Calendar" | "AddressBook" | "FileNode";
const RIGHTS: Record<Kind, Array<{ key: string; label: string }>> = {
Mailbox: [
@@ -38,15 +40,27 @@ const RIGHTS: Record<Kind, Array<{ key: string; label: string }>> = {
{ key: "mayShare", label: "Share" },
{ key: "mayDelete", label: "Delete" },
],
// Stalwart 0.16.19 returns all six on a node of your own (2026-08-27).
FileNode: [
{ key: "mayRead", label: "Read" },
{ key: "mayAddChildren", label: "Add files" },
{ key: "mayModifyContent", label: "Edit contents" },
{ key: "mayRename", label: "Rename" },
{ key: "mayDelete", label: "Delete" },
{ key: "mayShare", label: "Share" },
],
};
const PRESETS: Record<Kind, { reader: string[]; editor: string[] }> = {
Mailbox: { reader: ["mayReadItems"], editor: ["mayReadItems", "mayAddItems", "mayRemoveItems", "maySetSeen", "maySetKeywords", "mayCreateChild"] },
Calendar: { reader: ["mayReadFreeBusy", "mayReadItems"], editor: ["mayReadFreeBusy", "mayReadItems", "mayWriteAll", "mayRSVP"] },
AddressBook: { reader: ["mayRead"], editor: ["mayRead", "mayWrite"] },
// An editor can fill a folder and change what is in it, but not rename or
// delete the folder they were given -- those stay with whoever shared it.
FileNode: { reader: ["mayRead"], editor: ["mayRead", "mayAddChildren", "mayModifyContent"] },
};
/** Share a mailbox / calendar / address book with other principals (JMAP Sharing, RFC 9670). */
/** Share a mailbox / calendar / address book / file node with other principals (JMAP Sharing, RFC 9670). */
export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind; id: Id; name: string; shareWith: Record<Id, object> | null; onClose: () => void }) {
const principals = useContacts((s) => s.principals);
const loadPrincipals = useContacts((s) => s.loadPrincipals);
@@ -67,7 +81,11 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
const save = async () => {
setBusy(true);
try {
const accountId = kind === "Mailbox" ? useMail.getState().accountId : kind === "Calendar" ? useCalendar.getState().accountId : useContacts.getState().accountId;
const accountId =
kind === "Mailbox" ? useMail.getState().accountId
: kind === "Calendar" ? useCalendar.getState().accountId
: kind === "FileNode" ? useFiles.getState().accountId
: useContacts.getState().accountId;
const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } });
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
@@ -75,6 +93,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
if (kind === "Mailbox") void useMail.getState().loadMailboxes();
if (kind === "Calendar") void useCalendar.getState().loadCalendars();
if (kind === "AddressBook") void useContacts.getState().loadBooks();
if (kind === "FileNode") void useFiles.getState().refresh([id]);
onClose();
} catch (err) {
toast.error((err as Error).message);
@@ -85,9 +104,17 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
return (
<Dialog open onClose={onClose} title={`Share “${name}`} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>Save</button></>}>
{!principals.length ? (
<p className="hint">No other users found in the directory, or sharing is not enabled on this server.</p>
) : (
{/* The list of who it is shared with is rendered whether or not anybody
can be *added*. It used to sit inside the branch below, so a server
with directory queries switched off -- which is the default, and which
returns no principals -- showed nothing but the hint, and an existing
share could not be seen, let alone removed. */}
{!principals.length && (
<p className="hint" style={{ marginBottom: 12 }}>
No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.
</p>
)}
{principals.length > 0 && (
<>
<div className="row" style={{ marginBottom: 12 }}>
<select className="select" value={pick} onChange={(e) => setPick(e.target.value)}>
@@ -99,6 +126,8 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
<button className="btn" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "reader"); }}>Viewer</button>
<button className="btn btn-primary" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "editor"); }}>Editor</button>
</div>
</>
)}
{Object.entries(rights).map(([pid, r]) => {
const p = principals.find((x) => x.id === pid);
return (
@@ -119,8 +148,6 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
);
})}
{!Object.keys(rights).length && <p className="hint">Not shared with anyone yet.</p>}
</>
)}
</Dialog>
);
}