Compare 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
LINUXexpert.org 2d5bda086f Merge pull request #86 from LINUXexpert-org/docs/readme-quick-start
docs: put the Docker quick start back in the README
2026-08-26 19:18:03 -07:00
jcoffey-dev d15f64ada6 docs: put the Docker quick start back in the README
Slimming it left no way to try ihasmail without leaving GitHub. Restored in
short form -- the four commands, the 2FA app-password note, and links to the
install and configure guides for TLS and the full environment.

ROADMAP.md's 2FA entry points back at that section again, rather than at the
install docs it was redirected to when the section was gone.
2026-08-26 19:16:04 -07:00
LINUXexpert.org 906d17a48b Merge pull request #85 from LINUXexpert-org/docs/readme-slim
docs: slim the README, split out known issues and roadmap
2026-08-26 19:08:16 -07:00
jcoffey-dev 9618a0278c docs: slim the README, split out known issues and roadmap
The README had grown to 330 lines and was carrying three audiences at once:
installing, using, and working on ihasmail. docs.ihasmail.org covers the first
two now, and ihasmail.org covers the feature tour, so the README links there
instead of restating them.

- Known issues / pending QA → KNOWN-ISSUES.md, verbatim
- Roadmap / not yet → ROADMAP.md, verbatim (its "see Quick start" pointer now
  aims at the install docs, since that section is gone)
- Dropped the env-var table (docs.ihasmail.org/configure/), the shortcut list
  (/shortcuts/), the Docker quick start (/install/) and the long feature list
  (ihasmail.org/#features), leaving a nav table at the top and a six-line
  summary of what's in it
- Kept and tightened what is only true of this tree: architecture, dev
  commands, the mock, version numbers, deploying
- Version examples refreshed from 2.16.57 to the current 2.16.84
2026-08-26 19:02:04 -07:00
LINUXexpert.org 486ab2f0d0 Merge pull request #84 from LINUXexpert-org/remove-2fa-login-entry
Remove the two-factor entry point from the login form
2026-08-26 16:44:07 -07:00
jcoffey-dev 1527ebffc8 Remove the two-factor entry point from the login form
The field never worked here: Stalwart takes a TOTP code only through an
OAuth flow, so a client posting a username and password had nothing to
send it to. Offering the button advertised a feature the login path
cannot honour, so it comes out until the flow works end to end.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three links, each defensible alone:

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

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

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

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

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

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

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

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

Three changes, no new capability:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Not verified end to end: an actual notification arriving. That needs a
real browser, a real push service and real delivery, so it is live
testing or nothing.
2026-08-26 13:51:09 -07:00
LINUXexpert.org e2f17f6f49 Merge pull request #66 from LINUXexpert-org/deploy-image-retention
Keep the last few images, and record delete-all-spam as verified live
2026-08-26 12:17:48 -07:00
jcoffey-dev e4915dd496 Keep the last few images, and record delete-all-spam as verified live
Every deploy tags an image with its version, which is what makes a
rollback a `docker run` rather than a rebuild -- and what has quietly
put 7.7 GB of them on the host, 4.3 GB of it reclaimable. `docker image
prune` will not help: they are tagged, which is the whole point of them.

The script that creates them now removes them, keeping the newest
IHASMAIL_KEEP_VERSIONS (three by default, 0 to keep everything).

Two things it is careful about, both learned from what could go wrong
rather than from something that did:

  - it runs only after the new container reports healthy, so a rollback
    target is never dropped while the thing meant to replace it is still
    unproven.

  - it excludes the image the container is actually running, by asking
    docker what that is rather than assuming it sorts newest. After a
    rollback it does not: the running image is an old one, and naive
    keep-the-newest-N would delete the image in use. Docker would refuse,
    but being refused is not the same as not having tried.

Exercised against a stubbed docker on PATH, so the pipeline and xargs
are the real ones: keeps 3 and removes the oldest, keeps 1 and removes
three, keeps everything at 0, never lists :current, and spares the
running image in a rollback where it is the fourth-newest.

Also records delete-all-spam as confirmed live on 0.16.19 today: Junk
Mail emptied and Deleted Items stayed empty afterwards, which is the
half a mock cannot prove and the half the feature exists for.
2026-08-26 12:15:51 -07:00
LINUXexpert.org e45c43100f Merge pull request #65 from LINUXexpert-org/junk-delete-all
Delete all spam, and call folders what the server calls them
2026-08-26 12:04:18 -07:00
jcoffey-dev 18e493bcd1 Delete all spam, and call folders what the server calls them
Junk Mail can now be emptied in one action, the way every other mail
client offers it: a banner across the top of the folder, and an item in
both the folder's right-click menu and the list's own menu.

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

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

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

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

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

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

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

The existing test cannot say it: it derives what should sync from
DEVICE_KEYS, so moving one of these into that list would move the
expectation with it and still pass. These name the two keys outright.
2026-08-26 11:37:47 -07:00
jcoffey-dev 9689ac8aae Let the toggle come back to the theme you were on
The top-bar toggle went to light from anything dark, and back to plain
"dark" -- which quietly moved an ihasmail user onto a theme they had
never chosen, two clicks and no way to tell what had happened. It did
the same to "match system", which the toggle could not restore at all;
the comment above it conceded as much and sent people to Settings.

There is more than one way to be dark now, so the way back is
remembered: lastDarkTheme holds whichever non-light theme was last
chosen, and the toggle returns to that.

The remembering lives in update(), the single path every way of setting
a theme goes through -- the toggle, Appearance, an imported settings
file -- so a fourth way to choose one cannot forget to record it. Light
never overwrites it, since light is the side being toggled away from.

The button's label follows: "Switch to the ihasmail theme", "Switch to
your system theme", rather than claiming everything dark is "dark mode".

Confirmed in a browser, not only in tests: from a fresh profile the
round trip ihasmail -> light -> ihasmail returns to ihasmail, and
system -> light -> system returns to system, with the label naming the
destination each time.
2026-08-26 11:34:45 -07:00
jcoffey-dev 8487f561f6 Add the ihasmail theme, and make it the default
A dark theme carrying ihasmail.org's palette: a teal-navy ground rather
than the blue-slate of the plain dark theme, with the orange the logo's
cat is drawn in doing the work of the star and the warning colour. The
values are the site's own, read from its stylesheet rather than picked
by eye.

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

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

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

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

Contrast measured rather than assumed, against the theme's own
background: text 14.5:1, muted 8.6:1, faint 6.4:1, accent 8.0:1, link
9.8:1, star 7.9:1, accent-on-accent 8.4:1. All AA or better.
2026-08-26 11:28:47 -07:00
LINUXexpert.org 16351abdbf Merge pull request #63 from LINUXexpert-org/login-ihasmail-org
Rework the sign-in footer
2026-08-26 10:50:07 -07:00
LINUXexpert.org d90270b204 Merge pull request #62 from LINUXexpert-org/readme-live-verified
Nothing left pending in QA
2026-08-26 10:49:15 -07:00
jcoffey-dev 48805c8072 Split the sign-in footer over two lines
The build and where to find it were running together on one line. The
name and version now stand on their own, with the two links beneath:

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

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

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

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

The README badge still credits LINUXexpert.org, which is the right place
for attribution and was not part of this.
2026-08-26 10:43:11 -07:00
jcoffey-dev d0fbbbc44e Nothing left pending in QA
The four things this section still had outstanding were all exercised
against the live 0.16.19 on 2026-08-26:

  - read receipts, end to end -- assembled, uploaded, imported, submitted,
    landed in Sent, and $mdnsent set so a second look does not offer to
    send another. Only the mock had seen this before.

  - the rest of the scheduled-send journey: a hold expiring and being
    delivered, and the Scheduled folder reconciling on the way in -- a
    released message to Sent, a cancelled one back to Drafts. The hold
    itself was already confirmed on 2026-08-25.

  - Files rename, move and delete on the 0.16 path. What had been
    confirmed on 0.15.5 was the older code path, and that no longer
    exists, so this closes the entry rather than adding to it.

  - the settings file on the deployed instance, rather than only on a
    build that had not shipped yet.

The section keeps saying what was checked and when, and now says why:
it has been wrong before, when the 0.16 registry path was recorded as
verified live while a capability looked for in the wrong place meant it
had never run at all. What is left here is no longer a list of unknowns
but of things worth knowing -- where Stalwart departs from a spec, where
a setting has to be on for a feature to work, and what ihasmail
deliberately does not do.
2026-08-26 10:38:34 -07:00
LINUXexpert.org fd0fe43ef7 Merge pull request #61 from LINUXexpert-org/deploy-self-rewrite
Stop the deploy script rewriting itself mid-run
2026-08-26 10:29:56 -07:00
jcoffey-dev 82108ebd97 Stop the deploy script rewriting itself mid-run
Moving the deploy script into the repo put it inside the checkout it
resets, and bash does not read a script all at once -- it reads as it
goes, by byte offset. `git reset --hard` replacing the file underneath a
running shell makes it stop wherever it had reached.

Silently, and with exit status 0. A three-line demonstration:

    echo "line A"
    cat > "$0" <<'NEW'
    echo "REWRITTEN"
    NEW
    echo "line B"

prints "line A" and nothing else, and exits 0. In a deploy that means
building the image, then stopping before the container is replaced, and
reporting success -- so the old container keeps serving while everything
says the new one shipped.

It only bites when a deploy carries a change to the deploy script
itself, which is rare enough to be baffling when it happens and exactly
the sort of quiet failure this project keeps paying for.

The script now re-execs from a copy outside the tree before touching
git, so the file being run cannot change while it runs, and removes the
copy on exit. Running it from outside the checkout -- as the host did
before this moved into the repo -- skips all of that.

The trap is an `if` rather than `[ -n ... ] && trap`, which would leave
the not-re-exec'd path resting on errexit ignoring a failed left operand
of &&. It does ignore it, but a deploy script is a poor place to depend
on knowing that.

Verified: with the guard, a script that overwrites itself mid-run
completes every later line and cleans up its copy; without it, the lines
after the rewrite never run.
2026-08-26 10:26:58 -07:00
LINUXexpert.org ea83631609 Merge pull request #60 from LINUXexpert-org/deploy-example
Add a deploy script, with no host in it
2026-08-26 10:23:02 -07:00
LINUXexpert.org 0613b5bf29 Merge pull request #59 from LINUXexpert-org/version-numbers
Give builds a version number
2026-08-26 10:22:28 -07:00
jcoffey-dev 2d72e870c4 Add a deploy script, with no host in it
The live deploy script has never been under version control, which makes
it the one part of the pipeline that can silently fall out of step with
the repo -- as it just did: it builds without --build-arg
IHASMAIL_VERSION, so every deployment would report 2.16.0 no matter what
was actually built.

This is that script with the host taken out of it. Every path, name,
port and volume is a variable with a default that describes the shape of
a deployment rather than any particular one, so what is published is the
logic and none of the topology. It follows Caddyfile.example and
nginx.example.conf, which are here for the same reason.

Nothing sensitive was in the original either -- it never reads the
environment file, only hands the path to docker run --env-file -- but
the absolute paths named a user and a directory layout, and there is no
reason for those to be public to buy version control over the guards.

Two things it does that the original did not:

  - passes the version to the build, which is the whole reason this came
    up. A Docker tag may not contain "+", which a version for a commit
    that arrived outside a pull request does (2.16.57+g1fa6578), so the
    tag turns it into "-" while the build is told the real form. About
    and /api/health still report it correctly.

  - tags each build with its own version as well as :current, so rolling
    back is running the previous tag rather than rebuilding it. The
    failure path lists what is there to go back to.

Exercised against a throwaway clone, container, volume and image:
the hold guard refuses a held commit that production does not already
carry and lets one through that it does; the confirmation guard refuses
to run over a pipe without --yes; a dry run stops before building; and a
full run built, replaced the container and reported healthy at
2.16.57+g0549a09 -- from an image tagged 2.16.57-g0549a09, which is the
sanitising working.
2026-08-26 10:18:44 -07:00
jcoffey-dev 4618f7656e Show the version on the sign-in page too
It was only on the About page, which is behind a sign-in -- so the one
place a version is most often wanted, when something is wrong and nobody
can get in, was the one place it could not be read.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified against the mock end to end: sign-in, the Files tree on the 0.16
path with the app folder hidden, and self-service credentials over the
registry. 226 web + 75 server tests pass; typecheck and build clean.
2026-08-26 09:51:03 -07:00
LINUXexpert.org f41e3d2631 Merge pull request #58 from LINUXexpert-org/ci-workflow-dispatch
Let CI be started by hand
2026-08-26 09:47:28 -07:00
LINUXexpert.org d739ad625d Merge pull request #56 from LINUXexpert-org/readme-settings-sync-verified
Record the settings file as verified live
2026-08-26 09:46:44 -07:00
jcoffey-dev 8e02300000 Let CI be started by hand
CI triggers on a push to main and on pull requests, and on nothing else.
That left no way to put a check on 7a1e5ee -- the commit production is
running -- after GitHub's Actions outage on 2026-08-26 orphaned every run
created during it.

Those runs are not merely slow. GitHub accepted them, allocated zero
jobs, and left them in a state its own API contradicts itself about:

  gh run rerun   ->  "cannot be rerun; This workflow is already running"
  gh run cancel  ->  "Cannot cancel a workflow run that is completed"
  gh api         ->  status=queued, conclusion=null, jobs=0

Neither recoverable nor clearable, and with no manual trigger the only
remaining option would have been an empty commit pushed to main to move
the ref -- which is both a junk commit and against how changes land here.

workflow_dispatch also covers the ordinary case of wanting a check on a
commit that predates a CI change.
2026-08-26 09:45:33 -07:00
jcoffey-dev 326c122231 Record the settings file as verified live
Settings moved into the account's JMAP Files in #55, which shipped to the
live 0.16.19 today. The README carried the feature but not what had
actually been checked against a real server, which is the distinction
the Known issues section exists to keep.

Confirmed live on 0.16.19 (2026-08-26): settings set in Chrome came back
on a fresh login in Firefox and in an incognito session. Both start with
an empty localStorage, so each of them read the account's file rather
than anything cached locally -- which is the whole claim.

That incidentally exercised part of the 0.16 Files path the section had
flagged as still wanting a look on its own terms: finding and creating
the folder, creating a node with nodeType, uploading and downloading its
blob, and pointing an existing node at a new one. Rename, move, delete
and the Files view itself are still unchecked there, and the entry now
says so rather than letting the tick spread further than the evidence.
2026-08-26 08:51:22 -07:00
LINUXexpert.org 26a017f1c4 Merge pull request #55 from LINUXexpert-org/settings-server-side
Keep settings with the account, not the browser
2026-08-26 08:24:03 -07:00
jcoffey-dev 0a9218f622 Keep settings with the account, not the browser
Every setting lived in localStorage, so none of them travelled between
devices. The sharpest edge is the default identity: with none set the
address that sorts first wins, so mail goes out from an address the
recipient may not recognise -- and someone who sets it at work finds it
unset at home, with nothing to say so. Reported in #54.

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

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

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

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

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

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

Verified against the mock end to end: folder create, blob upload, node
create, read back, update, re-read. Not yet exercised against the live
0.16.19.
2026-08-26 08:19:57 -07:00
LINUXexpert.org f9f442072b Merge pull request #53 from LINUXexpert-org/screenshots-refresh
Retake the screenshots on the new logo
2026-08-25 15:53:04 -07:00
jcoffey-dev 5a87a101c3 Retake the screenshots on the new logo, and refresh the README header
Every screenshot still showed the old mark in the topbar -- squashed,
with an illegible ".com" smudge under it. All seven are retaken against
the mock server at the original framing (1420x703, mobile 500x703).

The four Sieve rules in filters.jpg are not seeded by the mock, so they
are rebuilt through the rule builder to match the previous shot:
Newsletters, From the boss, Receipts, Build failures.

Date format is pinned to the locale default so the list reads "Aug 24"
as it did before, rather than the "24.08." the stored preference had
drifted to.

README: the header logo drops 180 -> 150 wide, since the mark alone is
portrait where the old artwork was landscape, and the note about the
prototype now says the logo has lost its wordmark too.
2026-08-25 15:47:50 -07:00
LINUXexpert.org 046f8b7e58 Merge pull request #52 from LINUXexpert-org/logo-drop-com-wordmark
Drop the .com wordmark from the logo
2026-08-25 15:34:58 -07:00
jcoffey-dev c81e4f1aa9 Drop the .com wordmark from the logo
The logo baked "ihasmail.com" into the artwork, which is the wrong
identity for a project that is not the hosted instance -- and at the
34px the topbar renders it at, the wordmark was an illegible smudge
under a squashed cat.

logo.png, icon-512.png, icon-192.png and apple-touch-icon.png are now
the mark alone. favicon-64.png, icon-maskable.png and favicon.ico
already were, and are untouched.

Cropping needed a threshold: both source files carry a band of
near-invisible pixels (alpha 1-10) roughly 40px wide down the left
side, so a plain getbbox() crop leaves the mark sitting off-centre.
The bounding box is taken at alpha > 8 instead.

The login page had no name of its own -- it relied on the wordmark --
so it gets one as text, styled like the topbar's. Its screenshot is
retaken; the rest still show the old mark in the topbar.
2026-08-25 15:32:17 -07:00
LINUXexpert.org bc6a605629 Update email format for reporting security issues 2026-08-25 14:31:45 -07:00
LINUXexpert.org 798f10e4aa Modify email address format in CONTRIBUTING.md
Updated email format for reporting security issues.
2026-08-25 14:31:03 -07:00
LINUXexpert.org e81214956b Update contact email format for reporting vulnerabilities 2026-08-25 14:29:28 -07:00
LINUXexpert.org 401814cf3b Add Contributor Covenant Code of Conduct
Added Contributor Covenant Code of Conduct to promote a respectful and inclusive community.
2026-08-25 14:27:34 -07:00
LINUXexpert.org 0c86208455 Revise SECURITY.md for clarity on support and reporting
Updated the security policy to clarify supported versions and reporting procedures for vulnerabilities.
2026-08-25 14:26:43 -07:00
LINUXexpert.org 3c8238d6fd Create CONTRIBUTING.md 2026-08-25 14:25:11 -07:00
LINUXexpert.org 558b1bcce2 Merge pull request #51 from LINUXexpert-org/readme-badges-trim
Trim the badges
2026-08-25 13:54:09 -07:00
jcoffey-dev 55fb3535d0 Trim the badges to three, and credit LINUXexpert.org
Down to the licence, the Stalwart generations this has been run
against, and a LINUXexpert.org badge linking to the site. The CI, JMAP,
Node, TypeScript and no-IMAP badges are gone.

The Stalwart and LINUXexpert.org links carry target="_blank". Worth
knowing that GitHub itself will ignore it: its README sanitiser strips
target and rewrites rel to nofollow, which was checked against the
markdown API rather than assumed. The attribute still does its job
anywhere else the README is rendered, so it stays.
2026-08-25 13:52:13 -07:00
LINUXexpert.org 7b274c7c7a Merge pull request #50 from LINUXexpert-org/readme-badges
Put badges under the logo
2026-08-25 13:48:24 -07:00
LINUXexpert.org d448a72d6c Merge pull request #49 from LINUXexpert-org/agpl-source-offer
Make the AGPL's source offer point at the source being run
2026-08-25 13:47:41 -07:00
jcoffey-dev a2f0852437 Put badges under the logo
Seven, each saying something true about this project rather than
decorating the page: CI, the licence, which Stalwart generations it has
actually been run against, the JMAP RFCs it implements, the Node floor
from engines, that both tsconfigs are strict, and the line the README
already uses to describe itself -- no IMAP, no SMTP, no database.

Every claim was checked against the repository: the CI badge is the
workflow's own, the licence matches LICENSE and package.json, the Node
version comes from engines, and strict is set in both tsconfigs. All
seven were fetched and rendered before committing, so none of them is a
broken image.

The standalone licence badge lower down goes, being now the second one
on the page.

The Stalwart badge is the one to remember: it is static, so it needs
changing when the live instance moves, exactly as the prose above it
does.
2026-08-25 13:46:32 -07:00
jcoffey-dev 7b05322577 Make the AGPL's source offer point at the source being run
Three things a licence audit turned up. None of them is a conflict --
every one of the 182 installed packages is permissive, and the relicence
was within the copyright holder's gift -- but all three are ways the
AGPL fails to stick.

The offer was hard-coded to this repository. Section 13 asks whoever
runs a modified version to offer *that* version's source, so every
deployment with a patch in it was pointing at the wrong tree, and would
have gone on doing so unless its operator noticed and edited the About
page. SOURCE_URL now sets it, alongside APP_NAME, and both the sign-in
page and About read it.

The offer was also only visible after signing in. Whoever is looking at
the sign-in form is interacting with the program over a network too, so
the footer carries it now.

And the two workspace packages declared no licence at all. Private, so
npm never minded, but anything reading the tree saw a blank where the
rest of the project says AGPL-3.0-or-later.

Checked both ways round: with SOURCE_URL set to a fork, the sign-in page
and About both point at the fork; with it unset, both fall back to this
repository.
2026-08-25 13:42:11 -07:00
LINUXexpert.org 259b625c3e Merge pull request #44 from LINUXexpert-org/push-dot-and-menu-close
Close the colour menu on a pick, and make the push dot readable
2026-08-25 13:18:20 -07:00
jcoffey-dev 732fdac78b Close the colour menu on a pick, and make the push dot readable
Two cosmetics.

Picking a folder colour left the menu open, which every other action in
it does not. It closes now, the way the calendar's colour menu already
did.

The live-updates indicator was an 8px flat speck in --fg-faint, near
invisible in either theme, and it had two states where the code has
three. The push client only ever said connected or not, which cannot
tell "retrying with a backoff" from "stopped": it now reports
connecting, connected or disconnected, and the retry path says
connecting rather than going dark. pushConnected stays for the callers
that only want the boolean.

The dot is 12px and raised -- a white highlight over a solid colour with
a soft halo, so one bead reads on light and dark alike without a
per-theme variant. Green connected, amber reconnecting with a slow
pulse, red disconnected. The pulse respects prefers-reduced-motion, and
the indicator is labelled for a screen reader rather than hidden from
it, since it carries real information.
2026-08-25 13:15:56 -07:00
LINUXexpert.org 5499971c54 Merge pull request #42 from LINUXexpert-org/folder-colours-filled
Fill a coloured folder in, rather than outlining it
2026-08-25 13:07:23 -07:00
jcoffey-dev fe09961383 Fill a coloured folder in, rather than outlining it
A tinted outline barely registered against the sidebar. The icon is now
filled with the colour, which is what makes it findable at a glance in a
list of a dozen folders.

Two details it needed. The fill has to come from CSS, because lucide
writes fill="none" as a presentation attribute on the svg and a rule
beats one. And the strokes are drawn in --bg rather than the colour: a
solid fill in one colour swallowed the detail inside icons that have any
-- Archive lost the lid and handle of its box and became an orange blob.
Knocked out against the background they read again, in either theme,
since --bg follows the theme rather than being pinned to one.

Checked by pixels and by eye in both themes: the coloured area of the
icon went from an outline to 55% of its box, and Archive, Newsletters
and Work are all still recognisably themselves.
2026-08-25 13:04:45 -07:00
LINUXexpert.org 037843a79d Merge pull request #41 from LINUXexpert-org/folder-colours
Give a folder a colour from its right-click menu
2026-08-25 12:57:37 -07:00
jcoffey-dev bbd6980cff Give a folder a colour from its right-click menu
Right-click a folder and pick one of the twelve colours the calendar
already uses, or clear it again. The colour tints the folder's icon; the
label keeps the sidebar's own contrast, which a dozen arbitrary colours
would not reliably give it.

Kept by mailbox id rather than by name, so a folder renamed or dragged
somewhere else keeps its colour. Stored in settings, which live in this
browser -- JMAP has nowhere on a Mailbox to put a colour, and every other
colour in the app, labels and event categories included, already works
this way. Worth knowing it does not follow you to another device.

The cascade needed care: .nav-item svg sets the colour on the icon
itself, so a colour inherited from a wrapper does nothing. Checking
getComputedStyle on the wrapper said the icon was purple while the pixels
stayed grey; the rule now targets the svg, and the check now reads the
pixels.
2026-08-25 12:54:33 -07:00
LINUXexpert.org b78d452d29 Merge pull request #40 from LINUXexpert-org/folder-drag-to-move
Move a folder by dragging it
2026-08-25 12:39:09 -07:00
jcoffey-dev bc4bad8466 Move a folder by dragging it
Reparenting a folder meant the Folders settings page, or nothing at all.
The tree already accepted messages dropped onto a folder, so folders now
travel the same way: drag one onto another to nest it, or onto the
Folders heading to bring it back to the top level.

The heading says "Drop here for the top level" while a folder is in
flight, because an unlabelled strip of heading is not a discoverable
target. The row being dragged fades, the row under the pointer is
outlined, and only rows that would accept the drop light up.

Four drops are refused: a folder onto itself, into its own subtree,
onto the parent it already has, and any folder the server gave a role,
which is not draggable in the first place. The subtree case is the one
that matters -- it would orphan the branch -- and it checks the whole
subtree rather than the immediate children.

Whether a drop is legal has to be known during dragover, when
dataTransfer.getData is blocked, so the tree remembers what is being
dragged rather than asking the drag.

The move goes through updateMailbox, so the filter rules pointing at the
folder follow it, and the target folder is expanded afterwards so the
folder can be seen where it landed.
2026-08-25 12:36:09 -07:00
LINUXexpert.org 418d3fafce Merge pull request #39 from LINUXexpert-org/sieve-detach-only-fileinto
Take the filing action, not the whole rule
2026-08-25 12:25:41 -07:00
jcoffey-dev 7f382565e8 Take the filing action, not the whole rule
Deleting a folder removed every rule that filed into it, along with
whatever else those rules did. A rule that filed into Work, marked read
and stopped processing lost the marking and the stopping too, and
deleting a folder says nothing about whether those were still wanted.

Only the fileinto action goes now. A rule left with nothing to do is
still removed, because it has nothing to do; a rule filing into two
folders keeps the one that still exists. The toast says which happened.

Verified against the running app with two rules aimed at the same
folder, one filing only and one filing and marking read: the first was
removed, the second kept its markread, and the script stored on the
server agrees.
2026-08-25 12:23:29 -07:00
LINUXexpert.org 1f14b818ef Merge pull request #38 from LINUXexpert-org/relicense-agpl3
Relicense to the AGPL, version 3 or later
2026-08-25 12:18:40 -07:00
LINUXexpert.org c2871304f1 Merge pull request #37 from LINUXexpert-org/sieve-follows-folders
Keep filter rules pointing at the folder they were aimed at
2026-08-25 12:16:22 -07:00
jcoffey-dev 8cbbc9cc6c Relicense to the AGPL, version 3 or later
ihasmail is webmail: it is nearly always run as a network service rather
than handed to anyone as a binary, which is the case the plain GPL does
not reach. The AGPL's section 13 does — anyone running a modified
ihasmail for other people has to offer them its source.

LICENSE is the full AGPL-3.0 text from gnu.org. The SPDX identifier
changes from GPL-3.0-or-later to AGPL-3.0-or-later in package.json, the
lockfile's own entry for it, and the About screen. Old commits and tags
are left exactly as they were; this is the license from here on.
2026-08-25 12:13:41 -07:00
jcoffey-dev 9da1ef3ead Keep filter rules pointing at the folder they were aimed at
A rule files mail into a folder by path, because that is what Sieve
needs. Rename the folder and the path becomes a lie: the rule keeps
matching and stops filing, and nothing anywhere says so. Delete the
folder and the rule is aimed at nothing at all.

Renaming or moving a folder now rewrites the rules that file into it,
and deleting one takes its rules with it. Both are reported in a toast,
because rules live on the server and are otherwise invisible from the
folder list.

The reconciliation runs off one hook. Before a mailbox is changed the
folder and everything beneath it are noted with the paths they have then
-- renaming a parent rewrites the path of every child, and rules naming
those children are just as stale. Afterwards, whatever still exists is
retargeted and whatever has gone takes its rules with it.

Rules record the folder twice, as a mailboxId and as the path. The id is
the reliable half and is preferred; the path is the fallback for rules
written before the id was recorded, or by hand in the Scripts tab, and a
rule matched that way has its id filled in on the way past. Only the
script the rule editor manages is touched; a hand-written one is left
alone.

Awaited rather than fired and forgotten, so a folder operation is not
reported complete while the rules still disagree with it.
2026-08-25 12:12:30 -07:00
LINUXexpert.org bb71a2d063 Merge pull request #36 from LINUXexpert-org/screenshot-script-guards
Make the light screenshot actually light
2026-08-25 10:41:49 -07:00
jcoffey-dev 89d2d0128e Make the light screenshot actually light
The light inbox screenshot in the README was not light, and had not been
since it was first taken -- the pair showed the dark theme twice.

The app was never at fault: update() calls applyTheme() synchronously
and the CSS flips --bg to #f6f8fa as it should. The capture was.
Swapping the theme under setDeviceMetricsOverride produces a mixed
frame, the panes that re-rendered in the new theme and the rest of the
chrome still in the old one, while the DOM and computed styles insist
the whole page is light. Clicking the app's own toggle, setting the
attribute, pinning it against applyTheme with a MutationObserver,
installing that pin before the document loads, nudging the viewport and
forcing a full reflow all left the frame mixed.

Chrome launched at --window-size, with the emulation layer never
touched, renders it correctly. That is docs/screenshots-light.mjs, and
inbox-light.jpg is now genuinely light.

assertTheme() stays: without it the script wrote a dark screenshot under
a light caption and reported success, which is how this survived
unnoticed. The header says which shots are taken elsewhere and why.
2026-08-25 10:39:44 -07:00
LINUXexpert.org 608bac62be Merge pull request #35 from LINUXexpert-org/readme-screenshots-refresh
Take the screenshots again, on today's build
2026-08-25 10:19:25 -07:00
jcoffey-dev 35ce2a8d3d Take the screenshots again, on today's build
The ones in the README were captured on 23 August, before the 2.0 QA
work: no drag handles on the filter rules, none of the calendar or
Sieve fixes, and the read-receipt line in the composer missing.

Same sizes as before, 1420x703 and 500x703 for the phone, so the table
lays out unchanged. The filters shot now carries four rules that say
something -- list-id, sender, subject, a wildcard match, each filing
somewhere different -- instead of four blank ones, and the calendar is
in the month view its caption has always claimed. A contact is open in
the contacts shot rather than an empty "select a contact".

The script that took them is committed alongside, so the next person
does not have to work out how to drive the mock: headless Chrome over
CDP, which is also how the viewport comes out at exactly the size the
old images used.
2026-08-25 10:16:14 -07:00
LINUXexpert.org d6c88e809a Merge pull request #34 from LINUXexpert-org/scheduled-send-verified-live
Record that a hold really does hold, on the live server
2026-08-25 09:30:21 -07:00
jcoffey-dev a195589e51 Record that a hold really does hold, on the live server
Scheduled send had only ever been exercised against the mock, and the
one thing that could not be taken on trust was whether the MTA honours
the hold at all: with futureRelease off it takes the HOLDUNTIL, drops
the hold and sends at once, saying nothing.

With the setting turned on at 30d, a submission ten minutes out came
back pending, sendAt equal to the time asked for, queued at the MTA.

The capability stays worthless as evidence — it advertised
maxDelayedSend: 2592000 and FUTURERELEASE throughout, including while
the setting was off. Noted, because it is the obvious thing to check and
it lies.

Still mock-only, and now said so precisely: the Scheduled folder
reconciling on the way in, and a hold expiring into a delivery.
2026-08-25 09:22:58 -07:00
LINUXexpert.org 8551d5417d Merge pull request #33 from LINUXexpert-org/calendar-vocabulary-into-main
Bring the stranded calendar work into main
2026-08-25 09:08:18 -07:00
jcoffey-dev aeba426203 Merge remote-tracking branch 'origin/calendar-recurring-warning' into calendar-vocabulary-into-main 2026-08-25 09:05:48 -07:00
LINUXexpert.org 04c79f6c5d Merge pull request #32 from LINUXexpert-org/sieve-rule-drag-reorder
Let a filter rule be dragged into place
2026-08-25 08:53:39 -07:00
jcoffey-dev 696b3713ed Let a filter rule be dragged into place
Twenty-five rules and two buttons that move one place at a time meant a
rule pushed to the wrong end cost ten clicks to bring back. It can now
be dragged.

A grip on the left of each card arms the drag, so the switch, the name
and the buttons still take a plain click, and the up and down buttons
stay for the keyboard. The card being dragged fades; the one under the
pointer draws a line on the edge the rule would land on, top half or
bottom.

The guard against dropping a rule onto itself reads a ref rather than
state: dragstart and the first dragover can arrive in the same frame,
and a stale read there drew a drop line on the card being dragged. Found
by driving the real thing in a browser, and covered by a test that fires
the two events back to back.
2026-08-25 08:51:28 -07:00
LINUXexpert.org 5a21763605 Merge pull request #31 from LINUXexpert-org/calendar-stalwart-vocabulary
Say it in the words Stalwart 0.16 answers to
2026-08-25 08:42:25 -07:00
jcoffey-dev b0525d10d4 Record that RSVP and the edit path hold up too
Adding a participant by patch had failed earlier, which left a question
over RSVP, since that patches participants/{key}/participationStatus.
It works, comment and all, and so does adding guests to an event that
had none and clearing them again with null.

The patch has to name the base event: a synthetic id is refused with
"Updating synthetic ids is not yet supported", which is exactly why rsvp
resolves baseEventId first. Worth writing down before someone simplifies
that line away.
2026-08-25 08:39:40 -07:00
jcoffey-dev 4558804752 Record that the invitation reached a real guest and came back
The store side was proven earlier; the sending side had only been
reasoned about. An invitation went to an external Gmail address from the
live 0.16.19: it arrived as an invite card, the decline came back, and
Stalwart applied it to the event — needs-action to declined, sequence 1.
Cancelling notified the guest as well.
2026-08-25 08:31:47 -07:00
jcoffey-dev c1ef19849e Say it in the words Stalwart 0.16 answers to
Guests added to an event vanished on save and no invitation was ever
sent. Not a guard in the editor, and nothing the server complained
about: ihasmail addresses a participant the way RFC 8984 does, with
sendTo and email, and Stalwart 0.16 keeps that address under
calendarAddress. Handed the RFC's spelling it stores the event, drops
the entire participant map, and reports success. Six shapes were tried
against a live 0.16.19, down to sendTo and roles alone; all six were
dropped, and patching a participant onto an existing event fails
outright with "Patch operation failed".

The same disagreement runs through two more properties. The organizer is
organizerCalendarAddress, not replyTo. A recurrence is a single
recurrenceRule, not a recurrenceRules array — and that one Stalwart
refuses honestly, with invalidProperties, so no recurring event could be
created at all and existing ones showed no repeat.

So writes now use Stalwart's names and reads accept either, since a
mailbox may hold events written by other clients. The mock now refuses
what the real server refuses and drops what it drops: advertising the
RFC spelling is exactly how this reached a live server unnoticed, the
same way the capability-placement bug did.

Verified against 0.16.19: participants, organizer and rule all survive a
create, an update and a re-read, with the roles kept as sent.

Fixes #26
Fixes #30
2026-08-25 08:26:34 -07:00
jcoffey-dev 458eb118b4 Ask the recurrenceId, which is the part that survives expansion
Rules alone were still wrong, in the other direction. A live 0.16.19 was
asked to expand a real weekly series: the occurrences come back carrying
no rule at all — only the master has one — and Stalwart spells that
master's rule "recurrenceRule", singular, not the RFC 8984 array ihasmail
looks for. So a genuine occurrence would have read as a one-off, and the
delete dialog would have offered to delete "this event" while deleting
the series.

What an occurrence does carry is a recurrenceId, which a one-off never
has. Master by its rule under either name, occurrence by its
recurrenceId. The tests carry the shapes the live server returned.
2026-08-25 08:18:38 -07:00
jcoffey-dev 330cecfb04 Ask only the recurrence rules, the live server settles it
A probe against the live 0.16.19 says a one-off event comes back from an
expanded query as id "eaaaaai" with baseEventId "i" — an instance id of
its own, and a base that is a different event. The clause that treated a
differing base as an occurrence of a series would therefore have gone on
calling every event recurring, which was the bug.

So recurrence rules alone decide it. What that gives up is an expanded
instance that arrives without its rules attached; whether Stalwart does
that is still to be checked against a real series.
2026-08-25 08:12:06 -07:00
LINUXexpert.org b4fd3d3ab4 Merge pull request #29 from LINUXexpert-org/sieve-custom-header-operator
Give a hand-typed header its own box
2026-08-25 07:58:37 -07:00
jcoffey-dev 0c334a113a Give a hand-typed header its own box
Picking "Other header…" in the filter dialog took the comparator away.
The condition row has three columns — field, comparator, value — and the
box for the header name was rendered into the comparator's, so the
comparator disappeared along with any way to change it. Whatever it had
been when you switched, contains, was what the rule got: matching a
header exactly, or on a regex, could not be expressed at all.

The header name now has a column of its own and the comparator keeps
its, on a row that widens to hold both.

Fixes #23
2026-08-25 07:56:01 -07:00
LINUXexpert.org 3a93de451c Merge pull request #28 from LINUXexpert-org/calendar-recurring-warning
Stop calling every event a series
2026-08-25 07:53:00 -07:00
LINUXexpert.org f5af5913cf Merge pull request #27 from LINUXexpert-org/sieve-rule-order-on-edit
Keep an edited filter rule where it was
2026-08-25 07:52:32 -07:00
jcoffey-dev 8b22ea9aab Stop calling every event a series
A one-time event opened for editing said "this is a recurring event —
changes apply to the whole series", and deleting one offered to delete
all occurrences of an event that has exactly one.

Three places asked whether an event had a baseEventId and took that for
recurrence. It isn't: the calendar loads its range with expandRecurrences,
and Stalwart puts a baseEventId on everything it returns that way, a
one-off pointing at itself included. The mock never sets the field at
all, which is why this only showed up against a real server.

They now share isRecurring(), which asks about recurrence rules, and
treats a base that is some other event as an occurrence of a series too
— so an expanded instance that travels without its rules is still
described honestly on the way to being deleted.

Fixes #25
2026-08-25 07:01:23 -07:00
jcoffey-dev fbcdff68da Keep an edited filter rule where it was
Renaming or editing a Sieve rule moved it to the bottom of the list, and
in Sieve the order is the order the rules run in, so mail started being
filed by a different rule than before. Putting it back took a click per
place moved.

Two things did it. saveAndApply always appended the rule it was given —
right for a rule created from a message, wrong for one being edited. And
the "Also apply to existing messages" tick defaulted to on wherever it
was offered, so every edit in Settings went down that path, including a
plain rename.

The rule now keeps its seat: a shared upsertRule replaces by id in place
and only appends what is genuinely new. The tick defaults to on only in
"Filter messages like this…", where applying it is the point, and the
toast no longer calls an edited rule "created".

Fixes #24
2026-08-25 06:55:47 -07:00
LINUXexpert.org 0b922e175f Merge pull request #22 from LINUXexpert-org/registry-path-verified
Record that the registry path has now met a live 0.16.19
2026-08-24 23:06:14 -07:00
LINUXexpert.org 77d9804583 Merge branch 'main' into registry-path-verified 2026-08-24 23:04:33 -07:00
jcoffey-dev ccf9fa34f0 Record that the registry path has now met a live 0.16.19
Self-service credentials work against the real server: password changes, 2FA
and app passwords, over the registry rather than the REST endpoint 0.16
removed. That also settles the generation lookup, which About and Files read
through the same helper.

Files is the one thing this does not settle. The earlier live run recorded
against 0.16.19 exercised the pre-0.16 path -- correct behaviour for what
ihasmail then believed the server to be, but not the path it takes now. Said
so, rather than letting an old confirmation stand for a different code path.
2026-08-24 23:02:58 -07:00
LINUXexpert.org 5393524dc7 Merge pull request #21 from LINUXexpert-org/read-receipts
Send the read receipt the sender asked for
2026-08-24 23:01:03 -07:00
jcoffey-dev 46abc1c083 Drop S/MIME and OpenPGP from the roadmap
Not worth the cost right now. Removing it rather than leaving it sitting
there unstarted, so the list says what is actually intended.
2026-08-24 22:59:15 -07:00
jcoffey-dev 3310149fcc Send the read receipt the sender asked for
JMAP has an extension for this -- RFC 9007's MDN/send -- and Stalwart does
not implement it, so ihasmail assembles the RFC 8098 multipart/report itself
and sends it the long way round: raw MIME uploaded as a blob, imported,
submitted. That is also why the receipt lands in Sent, which is where it
honestly belongs.

The plumbing is the easy half. A receipt tells whoever asked that the address
is live and when the message was read, to an address the sender chose, so the
refusals are the feature: nothing marked Auto-Submitted (RFC 3834, or two
servers answer each other forever), nothing carrying Precedence bulk/list/junk
or a List-Id, nothing already acknowledged, nothing that never arrived. A
receipt aimed anywhere other than the sender is offered, but says so first.
There is no "always send" setting, only ask or never.

Sending is recorded with RFC 3503's $mdnsent keyword on the original rather
than remembered locally, so a second look -- or another client entirely --
knows not to ask again. Non-ASCII parts go base64 rather than 8bit, so
nothing rests on 8BITMIME surviving every hop.

Verified against the mock end to end: the blob uploads, the receipt imports
and submits, and the original reads back marked. Not yet exercised against
the live server.
2026-08-24 22:53:06 -07:00
jcoffey-dev be75a02181 Merge scheduled send
Both branches turn on where Stalwart advertises a capability, so they meet
in the same two files. The mock keeps `urn:stalwart:jmap` out of the
session-level capabilities and hands it out per-account, as a real server
does, while the submission capability it grew for scheduled send lives
per-account beside it; the client keeps both accessors, one asking whether a
capability is advertised anywhere and one reading the object itself.
2026-08-24 22:24:13 -07:00
jcoffey-dev 14125a0799 Look for Stalwart's capability where Stalwart advertises it
Self-service credentials, the About page and Files all keyed off
`urn:stalwart:jmap`, and all three looked for it in the session-level
`capabilities`. Stalwart has never put it there. `Session::new` builds that
list from a fixed set the capability is not part of, in any 0.16.x from
0.16.0 to 0.16.19; it is handed out per-account instead, so it arrives in
`primaryAccounts` and in each account's `accountCapabilities`.

So every real 0.16 server read as pre-0.16. Password changes, 2FA and app
passwords fell back to `POST /api/account/auth`, which 0.16 removed, and
reported that the server offers no self-service credential management. About
named the wrong generation. Files ran the pre-0.16 path, omitting `nodeType`
and listing the tree through get.

Look in all three places, on both sides. Two nearby soft spots go with it: a
transport error while probing the registry no longer downgrades a server to
the legacy path -- which would have posted the current password to an
endpoint that is not there -- and a locale request that is merely refused no
longer discards a generation the capability had already settled.

The mock advertised the capability in the session, which is why no test ever
caught this; it now advertises it where the real server does, and validates
`using` by the urn rather than by the session, as Stalwart does. Put the old
lookup back and nine tests fail.

Stalwart still publishes no version number to clients -- VERSION_PUBLIC is a
fixed "1.0.0" -- so About continues to report the generation and edition,
which are now the right ones.
2026-08-24 22:21:26 -07:00
jcoffey-dev e720623895 Hold a message in the server's queue until the time you asked for
Scheduled send, which the README listed as needing server support that
Stalwart has had all along. The delay cannot be asked for directly --
RFC 8621 makes `sendAt` read-only and server-derived -- so it goes on the
envelope as an RFC 4865 `HOLDUNTIL` parameter, and the server reports back
the time it settled on.

Stalwart advertises this in the *account* capability, not the session-level
one (which is empty): `maxDelayedSend` of thirty days and `FUTURERELEASE`
among its `submissionExtensions`. The composer offers scheduling only when
both are there, and never offers a time the server would refuse.

A held message goes to a Scheduled folder rather than Sent, because
`onSuccessUpdateEmail` would otherwise file it as sent the moment the
submission is created, and it has not been sent. Nothing moves it out when
the hold expires, so the folder is reconciled on the way in: released
messages to Sent, cancelled ones back to Drafts. Cancelling uses a separate
`Email/set` rather than `onSuccessUpdateEmail`, whose key Stalwart reads as
an Email id and not, as the RFC says, a submission id.

The mock grows the whole lifecycle, and learns to resolve creation
references while it is there -- it had been quietly declining to create any
submission at all, since sending names its message as `#m`. Because
Stalwart's own `futureRelease` setting defaults to off and then drops the
hold in silence, `npm run dev:mock:no-future-release` reproduces that.

Verified end to end against the mock; not yet against the live server.
2026-08-24 22:07:29 -07:00
jcoffey-dev 03b5a6c388 Say which Stalwart the live instance runs, and credit the tool that moved it
The README described the deployment as 0.15.5 in four places. It has run
0.16.19 since 2026-08-25, which matters here because ihasmail supports both
generations of Stalwart and they are less alike than the version numbers
suggest: 0.16 replaced the REST management API with JMAP registry objects,
changed the shape of FileNode, split its rights up, and moved configuration
into the store.

Both backends have now met a real server of their own generation. The
registry paths for credentials and Files had only ever met the mock, and are
now exercised against the live 0.16.19: app passwords created and revoked,
password changed, 2FA switched on and off with the browser session surviving
the swap to an app password, and Files through folder creation, upload,
rename, move and delete. That closes the gap, and it was worth closing - the
0.16 half was resting on a mock we wrote ourselves, which is the arrangement
that let four bugs through on the 0.15 side.

Each known-issues entry now says which generation it was proven against
rather than implying one level of assurance across both. The account locale
is confirmed working on 0.16; on 0.15 neither method it can use was
reachable, so it had always fallen back to the browser.

Also records what did the upgrade. stalwart-migrator is a companion project,
and the 0.15 to 0.16 move is genuinely treacherous by hand - the store is
migrated in place with no way back, and Stalwart's own converter drops
settings without reporting them - so a reader running 0.15.x has a real
reason to want the link.
2026-08-24 17:38:39 -07:00
LINUXexpert.org 10331e14d9 Merge pull request #20 from LINUXexpert-org/empty-trash-batching
Empty a folder in batches the server will accept
2026-08-24 12:57:24 -07:00
jcoffey-dev 7095968282 Empty a folder in batches the server will accept
Emptying Deleted Items back-referenced one Email/query straight into one
Email/set, so every id in the folder arrived in a single call. Stalwart
refuses the whole call over maxObjectsInSet with requestTooLarge, which
left a folder of 5192 messages impossible to empty at all.

Walk the folder a page at a time instead: the filter re-runs each pass,
so the next page is whatever is still there. A pass that destroys nothing
stops the loop and reports the server's error rather than spinning.

The same defect sat in two neighbours. Delete-forever on a large
selection sent every id in one Email/set, and mark-all-read fed up to
5000 ids into an Email/get that only echoed them back - past
maxObjectsInGet, and for no gain, since the query already returned them.
Both now page through the same ceiling, read from the session rather than
hardcoded.

The mock advertised maxObjectsInSet but never enforced it, so none of
this could fail in a test. It now rejects oversized get and set calls the
way Stalwart does.

While here, restrict emptying to Deleted Items. It was offered on Junk
too, where a permanent one-shot clear is harder to justify; Junk is now
select-all plus Delete, which takes the batched path.
2026-08-24 12:55:23 -07:00
LINUXexpert.org 0cb7404330 Merge pull request #19 from LINUXexpert-org/image-proxy-pinning
Connect the image proxy to the address it checked
2026-08-24 11:52:45 -07:00
jcoffey-dev 9d06dce473 Connect the image proxy to the address it checked
The proxy resolved a hostname, refused it if any answer pointed somewhere
private, and then handed the *hostname* to fetch — which resolved it again when
the socket opened. An attacker who controls the zone answers with a public
address the first time and 127.0.0.1 the second, and the check has been walked
straight past. It is the standard way an SSRF guard gets bypassed.

Resolve once and connect to that address: a `lookup` that returns what we
already approved, on `node:http`/`node:https` rather than fetch, since fetch
gives no say in how the socket is opened. Every redirect hop is re-checked and
re-pinned. TLS is unaffected — the certificate is still validated against the
hostname, which `servername` and the Host header carry.

Pooling had to go with it: sockets are keyed by host and port, not by the
address we pinned, so a connection opened earlier would be reused and the pin
never consulted. Found by a test, not by reading it back.

Also refuse two ranges the old check let through: IPv6 multicast, and the NAT64
prefix, which is a route into IPv4 space.
2026-08-24 11:27:14 -07:00
LINUXexpert.org e21944a031 Merge pull request #18 from LINUXexpert-org/audit-hardening
Harden four things the security audit turned up
2026-08-24 11:12:43 -07:00
jcoffey-dev c5f2e2c7f2 Harden four things the audit turned up
**The login rate limiter could be sidestepped.** X-Forwarded-For is a list each
hop appends to, and nginx's $proxy_add_x_forwarded_for appends ours — so a
client sending "X-Forwarded-For: 1.2.3.4" arrives as "1.2.3.4, <their real
address>". Reading the leftmost entry, as we did, handed the caller a
rate-limit key they could change per request: unlimited password guessing
against a deployment that looks correctly configured. Read from the right
instead, skip hops that are themselves trusted proxies, and believe the header
only when the peer is one (loopback and the private ranges by default,
TRUSTED_PROXIES to be explicit).

**The upload cap was a suggestion.** It read content-length, which a chunked
request simply omits. Count the bytes through a stream, as the image proxy
already does.

**App password secrets were drawn with a modulo.** 256 is not a multiple of 33,
so the first 25 characters of the alphabet came up on 8 byte values and the
last 8 on only 7. Rejection sampling instead. The test weighs the whole tail of
the alphabet rather than single characters, because a 7/8 skew is invisible
per character against the noise — and it does fail when the bias is put back.

**Upstream headers were relayed wholesale.** Anything the mail server set —
cookies, auth challenges, CORS grants — landed on our origin, where it means
something else. Allowlist what is actually wanted.
2026-08-24 11:10:15 -07:00
LINUXexpert.org 984ebc185d Merge pull request #17 from LINUXexpert-org/message-css-containment
Stop a message painting over the whole application
2026-08-24 11:07:54 -07:00
jcoffey-dev 6242d4dee1 Stop a message painting over the whole application
A shadow root scopes selectors, not layout. `position:fixed` in mail CSS is
still positioned against the viewport, and the containment meant to stop that
sat inside the shadow root as `.ihm-email-root { contain: content }` — in the
same tree as the message's own <style>, which is inserted after it and simply
overrides it. Any sender could cover the entire window with markup of their
choosing, inside our own origin: a ready-made place to ask for a password.

Verified in a browser: the message rendered at exactly the viewport size with
the maximum z-index.

Moving the containment onto the shadow host does not fix it — mail CSS reaches
the host through `:host`, and an `!important` there beats an `!important` from
the app's own stylesheet, because importance reverses tree order in the
cascade. An ancestor of the host is the one thing mail CSS has no selector
for, so the control goes on .message-body. `layout` rather than `paint`: it
makes the element a containing block for fixed descendants without clipping
tall messages.

The sanitizer now also turns fixed and sticky positioning static and defangs
`:host`, as a second line that does not depend on one CSS declaration.

Checked end to end against the real stylesheet afterwards: the same message
renders 1678x112 instead of 1720x1279.
2026-08-24 11:00:10 -07:00
LINUXexpert.org f29a504ead Merge pull request #16 from LINUXexpert-org/legacy-mock
Teach the mock to impersonate Stalwart 0.15
2026-08-24 10:27:04 -07:00
jcoffey-dev 0845c93c4c Teach the mock to impersonate Stalwart 0.15
MOCK_STALWART=0.15 (or npm run mock:legacy) switches the mock to the
generation before the registry. It is not a cut-down mock: it reproduces the
specific ways that generation differs, and every one of them is a thing the
server does not report as an error.

  - urn:stalwart:jmap is not a capability it knows, and naming one it cannot
    parse fails the whole request rather than the one call
  - x: methods do not exist; credentials live at POST /api/account/auth
  - FileNode/query masks out containers, so it returns files and never folders
  - FileNode has no nodeType, and rights are only mayRead/mayWrite/mayShare

Both modes now also enforce the 2047-byte signature cap, and `using` is
validated in both — the gap that let the Identity capability bug in #12 ship.

This gives the legacy credential adapter its first automated coverage: it was
the least-tested code here, checked only by hand against the live server. The
new tests also pin the mock's own fidelity, so it cannot quietly drift back to
being 0.16-shaped in the places that matter.
2026-08-24 10:23:39 -07:00
LINUXexpert.org 88885f316a Merge pull request #15 from LINUXexpert-org/signature-byte-limit
Make signatures and Files work on Stalwart before 0.16
2026-08-24 10:13:30 -07:00
jcoffey-dev dab4808baa Record the live Files and signature verification
Both entries had been sitting as pending QA. Everything they were waiting on
has now run against the live 0.15.5 server: oversized, non-ASCII and
inline-image signatures (with a test message reaching Gmail intact), and folder
creation, listing, upload, rename, move and delete in Files.

The Files entry now describes what actually differs before 0.16 — the query
that cannot see folders, the missing nodeType, the coarser rights — since all
three were found the hard way and none of them surfaces as an error.
2026-08-24 10:11:08 -07:00
jcoffey-dev 8d90bca6b4 List Files through get, because query cannot see a folder before 0.16
Creating a folder on the live 0.15.5 server did nothing visible, with no error
and nothing after a reload. The folder was real all along: FileNode/query masks
its results with document_ids(false) — resources that are *not* containers — so
it returns files and never folders, and says nothing about the omission.

FileNode/get carries no such mask, so on those servers the whole tree comes
from a single get with ids:null instead. That also stops ensureFolder making a
fresh "ihasmail" folder on every signature save, having never been able to find
the one already there.
2026-08-24 10:05:08 -07:00
jcoffey-dev 5befef7ed7 Translate the older FileNode rights, so Rename and Delete work again
Deleting a file did nothing on the live 0.15.5 server, with no error: the menu
items are gated on myRights.mayDelete and myRights.mayRename, and 0.16 was the
release that split rights up. Before it a node carried mayRead, mayWrite and
mayShare, with the one mayWrite covering everything the newer release names
separately — so both items sat permanently disabled.

Widen mayWrite into the four rights the newer shape names, alongside the
nodeType normalisation, and the UI can keep reading the 0.16 vocabulary.
2026-08-24 09:56:53 -07:00
jcoffey-dev 49ea07eeaa Stop sending nodeType to servers that have no such property
Uploading a file or creating a folder failed on the live 0.15.5 server with
`invalidProperties (nodeType)`. The property arrived in Stalwart 0.16; before
that a FileNode has no nodeType at all, and the create is refused outright.

Older servers tell a file from a directory a different way: the node carries
file properties or it does not. Setting blobId, size or type — even to null —
makes it a file, so a directory there is exactly parentId plus name.

0.16 is also the first release to advertise urn:stalwart:jmap and no earlier
one knows that capability, so its presence stands in for "has the newer
FileNode shape". Creates, and the property lists we ask for, are shaped from
that.

The read side needed it too: a server that never reports nodeType would have
had every folder drawn with a file icon, sorted among the files and opening as
a download. Nodes are normalised as they arrive, so everything downstream can
still just read nodeType.
2026-08-24 09:42:25 -07:00
jcoffey-dev 0057fce558 Measure signatures in bytes, so the oversize fallback actually saves
Stalwart accepts a signature of `value.len() < 2048`, and that is Rust's len():
2047 bytes of UTF-8. Every check here counted JavaScript `.length` instead,
which is UTF-16 units and agrees only for ASCII — an accent is one unit and two
bytes, CJK three, an emoji two units and four.

That alone would let a non-Latin signature we judged to fit come back rejected.
But the fallback that is supposed to rescue an oversize signature was broken
outright, for everyone: it truncated to `budget - 1` characters and appended an
ellipsis, one character but three bytes, so the result was always 2047
characters and 2049 bytes. Every marker signature Stalwart was ever offered was
two bytes too long, ASCII included. That is why this flow has been sitting in
the README as implemented but unconfirmed — the first person to exceed 2 KB
would have hit it.

Cutting the source text and rendering afterwards, rather than slicing the
rendered string, also means a cut can no longer land inside an HTML entity, and
stepping through code points means it cannot split a surrogate pair.

The old tests used ASCII only, which is how this survived; the new ones weigh
the encoded form.
2026-08-24 09:32:39 -07:00
LINUXexpert.org 682b5d77ee Merge pull request #14 from LINUXexpert-org/self-service-credentials
Self-service credentials, plus the account-locale fix, server info and a theme toggle
2026-08-24 09:13:55 -07:00
jcoffey-dev f5d26a1f61 Finish the README pass: stale locale method, and the two new bits of UI
The Dates & times entry still named x:Account/get as where the default locale
comes from, which this branch changed. It also never mentioned the top-bar
light/dark toggle or what About now reports about the server.
2026-08-24 09:12:19 -07:00
jcoffey-dev 25dfe714cb Record what the live 0.15.5 run actually verified
The REST credential path is no longer untested: password change, app passwords
and enabling and disabling 2FA were all exercised against the live server on a
real mailbox. The registry path is still mock-only.

Also correct the locale line. Both methods it can use are 0.16 ones, so on an
older server neither is reachable and the browser locale still wins — the fix
helps 0.16+ users, and the entry should not imply otherwise.
2026-08-24 09:08:43 -07:00
jcoffey-dev 0fdcbbc778 Fix two things live testing on 0.15.5 turned up
**About said "not detected".** Generation was only worked out from the reply to
a registry method, which we never send to a server that does not advertise
urn:stalwart:jmap — every 0.16 build does, and nothing older knows the
capability at all, so its absence is already the answer. Say so, instead of
shrugging. A session with no capabilities at all stays unknown, which is a
different thing from old.

**The caret jumped out of the OTP field after one digit.** Dialog's autofocus
effect listed onClose in its dependencies, and every caller passes an inline
arrow, so each keystroke in a dialog holding state tore the effect down, set it
up again, and refocused the first field — which in the disable-2FA dialog is
the password. Keep the handler in a ref so the effect depends only on `open`.
This was a bug in the shared dialog rather than in one screen; every dialog
with more than one field had it.

The test for it fails against the old dependency array, not just passes
against the new one.
2026-08-24 09:04:16 -07:00
jcoffey-dev c8fe822586 Treat an unreadable backend probe as the older server, not an error
Stalwart before 0.16 does not know urn:stalwart:jmap, and rejects the whole
request rather than the one call when `using` names a capability it cannot
parse. The probe is only sent when the session advertises that capability, so
this should not arise — but if it ever does, throwing turns a server we can
still manage credentials on into a Security page that only shows an error.
Fall through to the endpoint those servers do have.
2026-08-24 08:41:15 -07:00
jcoffey-dev c145858bbe Read the locale where users can actually read it, and say which Stalwart answered
The account locale came from `x:Account/get`, which needs `sysAccountGet` — a
permission the built-in `user` role is not given, so the setting silently fell
back to the browser locale for exactly the people most likely to have set it.
Stalwart 0.16 carries the same field on `x:AccountSettings`, whose
`sysAccountSettingsGet` *is* part of that role. Both are now asked for in one
request and whichever answers wins, so admins and older servers keep working.

That pair of replies also says which generation we are talking to: only 0.16+
can parse the method name at all. About now reports that, plus the edition
from /api/account where the server offers it. It does not report a version
number because Stalwart does not publish one to clients — it hardcodes a
public "1.0.0" and keeps the real version to its SMTP internals — so the
screen says what was actually detected rather than inventing precision.

Also adds a light/dark toggle to the top bar, left of the settings button. The
stored setting is three-way, so the button acts on the theme actually on
screen: whichever one you see, a click gives you the other. Choosing "match
system" again stays in Settings › Appearance, where a three-way choice belongs.
2026-08-24 08:35:22 -07:00
jcoffey-dev 0f1fbcff93 Manage your own password, app passwords and 2FA
Settings › Security grows three working sections instead of a note telling
people to use Stalwart's own portal.

Stalwart moved this API between releases, so ihasmail speaks both: 0.16+ has
the x:AccountPassword singleton and x:AppPassword registry objects over JMAP,
while 0.15.x has the /api/account/auth REST endpoint. Which one answers the
probe is the only reliable way to tell them apart, and the result is cached
per session. The built-in `user` role already grants sysAccountPassword* and
sysAppPassword*, so no administrator setup is needed.

Two problems are worth calling out, because both would bite a user hard:

Stalwart validates the credentials already on the account when 2FA is turned
on and never checks the new secret, so an authenticator that was mistyped or
out of step would lock someone out of their mailbox at the next sign-in. We
verify a code against the new secret ourselves first (RFC 6238, tested against
the spec's vectors) and only then ask the server to store anything.

Every proxied call re-authenticates with the credential sealed into the
session, and from the moment 2FA is on Stalwart wants a fresh TOTP code with
it — which we cannot produce between requests. Turning 2FA on would therefore
sign the user out of the browser they just turned it on in. App passwords
authenticate without a second factor, so the session is moved onto one minted
for this browser, and the session cookie is re-sealed with it. The order
matters: it is minted while the old credential still works, and revoked again
if enabling then fails.

Password changes re-seal this session too and drop the others, whose sealed
copies of the old password would fail on their next call.

The mock now enforces what a real server does — current password, password
policy, a TOTP code on every request once 2FA is on, app passwords exempt —
so the whole flow is exercised in tests rather than only by hand.
2026-08-24 08:18:47 -07:00
LINUXexpert.org cea7545481 Merge pull request #13 from LINUXexpert-org/identity-submission-capability
Ask for the submission capability when using identities
2026-08-24 06:37:11 -07:00
jcoffey-dev a3fd236c36 Ask for the submission capability when using identities
Identity is defined by RFC 8621 under urn:ietf:params:jmap:submission, not
under mail. ihasmail asked for mail alone, so Stalwart 0.16 rejected both
Identity/get and Identity/set with unknownMethod: no identities were ever
listed, none could be created, and sending then failed with "No sending
identity available". Older Stalwart builds accepted the calls anyway, which
is why this went unnoticed.

Also filter `using` down to the capabilities the session actually advertises.
A server must reject the entire request with unknownCapability when `using`
names something it does not implement, so one over-eager urn would take down
every call sharing the batch — including, on a server predating the submission
capability, the mailbox and message loads batched alongside an identity fetch.

Fixes #12
2026-08-24 06:31:37 -07:00
LINUXexpert.org cfe5595882 Fix version number in known issues section
Updated known issues section with corrected version number and additional details.
2026-08-23 21:37:47 -07:00
LINUXexpert.org f63b28d54b Merge pull request #11 from LINUXexpert-org/contact-from-message
Add contacts by right-clicking anyone named in a message
2026-08-23 15:19:19 -07:00
jcoffey-dev d143e711d4 Add contacts by right-clicking anyone named in a message
Right-clicking a sender, or any address in the message details, opens a menu
offering to add that person to the address book - plus edit them when they are
already known, write to them, or copy the address.

"Add to contacts" opens the contact editor prefilled rather than saving
silently, so the address book gets a real card that the user can complete,
not a bare email address. contactFromAddress splits the display name into
JSContact name components: "Ada Lovelace" into given and surname, "Lovelace,
Ada" unpicked, a single word as the given name, and a name that is really
just an address left off entirely.

Addresses in the details block were joined into one string, so they are now
rendered per address to be individually targetable.

ContactEditor previously ignored a prefilled name on an unsaved card - it read
name components only when the card had an id - so it now reads them either
way.
2026-08-23 14:46:38 -07:00
LINUXexpert.org 40f0ad5fdb Merge pull request #10 from LINUXexpert-org/fix-subject-focus
Stop the composer stealing focus while the subject is typed
2026-08-23 14:17:24 -07:00
jcoffey-dev 8faf9002c2 Stop the composer stealing focus while the subject is typed
The body editor was told to focus itself with

    autoFocus={d.to.length > 0 && Boolean(d.subject)}

and RichEditor ran that as an effect keyed on the prop. Typing the first
letter of a subject flipped Boolean(d.subject) false -> true, the effect fired,
and the caret jumped from the subject line into the message body.

autoFocus now means what it means on a DOM element: focus on mount. RichEditor
captures the prop in a ref and focuses once, and the composer decides where the
caret starts when it opens - recipients for a blank message, body for a reply
that already has recipients and a subject - instead of deriving it from state
that changes as the user types.

initialFocusTarget is extracted and exported so the rule is stated in one place
and tested. The regression test renders RichEditor and asserts it does not take
focus from a field being typed into; it fails against the previous effect.
2026-08-23 14:15:28 -07:00
LINUXexpert.org b4d89e94dc Merge pull request #9 from LINUXexpert-org/set-error-detail
Fix sending: never send null for an empty header property
2026-08-23 14:02:32 -07:00
jcoffey-dev 5575d540b8 Fix sending: never send null for an empty header property
Every message ihasmail sent set cc, bcc and replyTo to null when unused, and
inReplyTo/references likewise on a new message. Stalwart parses those
properties with try_into_address_list, which returns None for null, and the
create is rejected outright:

    if let Some(addresses) = value.try_into_address_list() { ... }
    else { response.invalid_property_create(id, header); continue 'create; }

So every send failed with "Invalid property or value.", new messages and
replies alike, regardless of attachments or signature. The mock server
accepts anything, which is why this only showed up against a real server.

Empty header properties are now omitted. On a create there is no previous
value to clear, so null was never needed - only the properties actually being
set belong in the object.

buildEmailObject is exported so the shape can be tested directly, with a
regression test that no property is ever null.
2026-08-23 13:59:33 -07:00
LINUXexpert.org ef1b52030b Merge pull request #8 from LINUXexpert-org/remove-live-hostname
Keep the live mail host out of the repo
2026-08-23 13:57:10 -07:00
jcoffey-dev f09566b56e Keep the live mail host out of the repo
Replace the hard-coded mail.inbuxa.com with generic placeholders: the config
default and .env.example use mail.example.com, the README stops naming the QA
host, and docker-compose now requires STALWART_URL to be set rather than
defaulting to somebody's real server.

Nothing deployed depends on the old default - the running container passes
STALWART_URL explicitly.
2026-08-23 13:55:17 -07:00
jcoffey-dev e05880eefc Say which property a JMAP SetError rejected
"Send failed: Invalid property or value." is Stalwart's description for
invalidProperties, and on its own it says nothing about what to fix. The
SetError also carries a `properties` array naming the offending fields, which
every call site was discarding.

setErrorMessage appends them, and the 35 places that surfaced a SetError -
send, save draft, mailboxes, calendars, contacts, sieve, files, signature
images, sharing - now go through it.
2026-08-23 13:51:02 -07:00
LINUXexpert.org 55af4eab8f Merge pull request #7 from LINUXexpert-org/message-theme-option
Let messages follow the app theme, at the user's choice
2026-08-23 13:36:38 -07:00
jcoffey-dev c3cecf9916 Let messages follow the app theme, at the user's choice
Messages render on a white card in every theme. That is deliberate for mail
that styles itself, but #4 points out the case it gets wrong: a message with
no styling of its own has nothing worth preserving, and flashing white at
someone reading in the dark is a real cost.

Appearance gains a switch under the theme cards, off by default so the
current behaviour is unchanged. With it on, HTML mail that declares no
colours follows the app theme; mail that sets a background or text colour
still gets the light card it was designed for, because half-darkening someone
else's design is worse than leaving it alone. Plain-text mail already
followed the theme and is untouched by the switch.

The themed palette is expressed in the app's own custom properties, which
cross the shadow boundary, so switching theme repaints open messages without
re-rendering them, and the accent-coloured link stays consistent. The host
element takes color-scheme: inherit so form controls and scrollbars inside a
message match too.

htmlDeclaresColors covers bgcolor attributes, <font color>, and colour or
background declarations in style attributes and <style> blocks, while
ignoring near-misses like border-color and ?color= in a URL.

Closes #4
2026-08-23 13:34:24 -07:00
LINUXexpert.org a89fc2b26e Merge pull request #6 from LINUXexpert-org/default-mail-handler
Offer ihasmail as the browser's mailto: handler
2026-08-23 13:29:21 -07:00
LINUXexpert.org 462721ce7f Merge pull request #5 from LINUXexpert-org/custom-date-time-pickers
Custom date and time pickers that follow the configured format
2026-08-23 13:27:02 -07:00
jcoffey-dev 2c23ea980b Offer ihasmail as the browser's mailto: handler
Settings > General gains a "Default mail app" section that calls
registerProtocolHandler so mail links anywhere in the browser open ihasmail.
The browser owns the decision and there is no API to read it back, so the UI
says what it can: it records that we asked, offers "Ask again", shows a
Remove button where unregisterProtocolHandler exists, and points at the
browser's own settings. Unsupported browsers (Safari) and insecure contexts
get an explanation instead of a dead button.

The manifest now declares protocol_handlers for mailto, which is the route by
which an *installed* app can be offered by the operating system itself; the
UI says so and links the two ideas rather than promising a system-wide
default the page cannot grant.

Mailto parsing is now one function (parseMailto in lib/address.ts) instead of
three hand-rolled copies in AppShell and MessageView. It follows RFC 6068:
recipients from the path, the to= header or both, case-insensitive headers,
"+" as space, and tolerant of malformed escapes. That fixes Cc and Bcc being
silently dropped, and draftFromMailto escapes the body so a mailto: URL from
an untrusted page reaches the composer as text rather than markup.
2026-08-23 13:21:50 -07:00
jcoffey-dev 2518605126 Custom date and time pickers that follow the configured format
Browsers render <input type="date"> and datetime-local in their own locale and
ignore the page's, so #1 left a German user on an English browser reading
22.11.2025 everywhere but still entering dates through an mm/dd/yyyy widget.
#3 makes the case that people use the picker rather than typing, which is
where the AM/PM mistakes happen.

New DateField and DateTimeField (web/src/ui/datefield.tsx) replace all nine
native controls — event editor (all-day and timed start/end, recurrence
until), out-of-office, contact birthday, advanced search. They take and emit
the same ISO strings the native inputs did, so call sites barely changed.

Each is a text box in the configured order plus a popover: a month grid
(week start from settings, locale weekday and month names, today and the
selection marked) and, for date-times, a list of times in the configured
clock. Keyboard: arrows move by day, PageUp/PageDown by month, Home/End
across the week, Enter picks, Escape closes, ArrowDown opens; the focused day
holds DOM focus so screen readers follow, and the dialog has an accessible
name (Popover gained an ariaLabel prop).

Text entry is lenient — the configured order with any separator, unseparated
digits (221125), day and month alone, non-Latin digits, and bare ISO always;
times take 18:23, 1823, 6:23pm, 930. What will not parse reverts on blur
rather than clearing the field, and impossible dates like 31 February are
rejected instead of rolling into March.

Editable boxes stay Gregorian and Latin-digit even where display does not
(fa-IR, th-TH, ar-EG): the locale's field order and separator are kept, but a
Buddhist-era year in a text box cannot round-trip against a Gregorian grid.
Noted in the README.

The out-of-office format echo added in #2 is gone — the fields now show the
right format themselves.

Closes #3
2026-08-23 13:12:17 -07:00
LINUXexpert.org c8fab0dd81 Merge pull request #2 from LINUXexpert-org/locale-date-time-formats
Configurable date and time formats, defaulting to the Stalwart locale
2026-08-23 12:43:36 -07:00
jcoffey-dev d82ff15921 Configurable date and time formats, defaulting to the Stalwart locale
Every user-visible date now goes through web/src/lib/datetime.ts, driven by
three settings (Settings > General > Locale):

- Language & region: automatic, or any of the 618 locales CLDR has data for,
  each named in its own language and script (web/src/lib/locales.ts, generated
  by probing Intl over the subtag space).
- Date format: automatic (locale order), 22.11.2025, 22/11/2025, 11/22/2025,
  or ISO 8601 2025-11-22.
- Time format: automatic (locale), 24-hour, or 12-hour.

Automatic takes the locale Stalwart has for the account, read best-effort at
login via x:Account/get (urn:stalwart:jmap) and passed to the client in the
session; servers without the capability, or that deny sysAccountGet to a
regular user, fall back to the browser locale. POSIX forms are normalised
(de_DE.UTF-8 -> de-DE) and script modifiers kept (sr_RS@latin -> sr-Latn-RS,
uz_UZ@cyrillic -> uz-Cyrl-UZ), while dialect/variant/currency modifiers are
dropped and a script the locale already implies is not appended.

Numerals follow the locale (22.11.2025 renders as Arabic-Indic digits under
ar-EG); ISO 8601 is the exception and pins date and clock to Latin digits so
one line never mixes digit systems.

Rewired: message list and headers, quoted reply headers, calendar (titles,
weekday and hour gutters, mini calendar, agenda, popovers, invite cards,
free/busy), contacts, files, sessions. No raw toLocale*String date calls are
left in web/src.

Native <input type="datetime-local"> pickers always follow the browser locale
and cannot be restyled by a page, so the out-of-office fields echo the entered
instant in the chosen format underneath.

Also: month-grid day labels no longer wrap when they hold a date, and the mock
server serves x:Account/get (MOCK_LOCALE, default en_US).

Closes #1
2026-08-23 12:32:11 -07:00
LINUXexpert.org 00a57eabec Update README with new server version 2026-08-23 02:12:22 -07:00
jcoffey-dev de1b33e2aa Folder pane: align icons, mark-read incl. subfolders
- Move the expand chevron into a gutter left of the folder icon so folders
  with and without subfolders line up on their icon; labels share the column.
- Add "Mark all as read, incl. subfolders" to the folder context menu, with
  the affected count and a per-folder fallback for servers without filter
  operators.
- Re-measure the virtualised message list when row height changes.
- Mock: seed unread mail in a subfolder.
2026-08-23 01:34:15 -07:00
jcoffey-dev d079e2ad88 Update copyright holder to LINUXexpert.org 2026-08-23 01:25:18 -07:00
jcoffey-dev c99375bea6 Use verbatim GPL-3.0 text in LICENSE
The LICENSE file only carried the short "how to apply" notice, not the
license itself, so it wasn't a valid GPLv3 distribution and license
detection tools couldn't identify it. Replace it with the canonical
674-line GNU GPL v3 text (sha256 8ceb4b9e...), and move the project
copyright line plus the "version 3 or any later version" grant into the
README, which now matches package.json's GPL-3.0-or-later.
2026-08-23 01:15:29 -07:00
jcoffey-dev 645b8b510f ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
2026-08-23 01:07:13 -07:00
107 changed files with 5953 additions and 1506 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
+8
View File
@@ -3,6 +3,14 @@ on:
push:
branches: [main]
pull_request:
# Lets CI be run by hand against any ref, including a specific commit.
# Without this there is no way to re-run a check that never started: a run
# GitHub queues and then orphans -- as it did to every run created during the
# Actions outage on 2026-08-26 -- can be neither rerun ("already running")
# nor cancelled ("already completed"), and the workflow has no other trigger
# to reach for. Useful too for putting a check on a commit that predates a CI
# change, without pushing an empty commit to move it.
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
+24 -2
View File
@@ -1,5 +1,12 @@
# ---- build stage ----
FROM node:22-alpine AS build
# What this build calls itself: 2.16.<PR>, worked out by whoever runs the
# build. It cannot be worked out in here -- .dockerignore keeps .git out of the
# context on purpose, and git is not installed either. `node scripts/version.mjs`
# in a checkout prints the right answer; ihasmail-deploy.sh passes it through.
# Left empty, the build falls back to the base version from package.json.
ARG IHASMAIL_VERSION=""
ENV IHASMAIL_VERSION=$IHASMAIL_VERSION
WORKDIR /app
COPY package.json package-lock.json* ./
COPY server/package.json server/
@@ -10,20 +17,35 @@ RUN npm run build
# ---- runtime stage ----
FROM node:22-alpine AS runtime
# Re-declared: an ARG does not cross stages.
ARG IHASMAIL_VERSION=""
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=8080 \
STATIC_DIR=/app/web/dist \
SESSION_FILE=/data/sessions.json
SESSION_FILE=/data/sessions.json \
IHASMAIL_VERSION=$IHASMAIL_VERSION
WORKDIR /app
COPY package.json ./
COPY server/package.json server/
# config.ts reads the version through this at startup. With IHASMAIL_VERSION
# set it never looks further; without it, it falls back to package.json rather
# than failing, since there is no git in here to ask.
COPY scripts/ ./scripts/
COPY --from=build /app/node_modules ./node_modules
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"]
+39
View File
@@ -0,0 +1,39 @@
# Known issues and pending QA
What was checked, against which server, and when. For a failure you are hitting
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
The live instance runs **0.16.19**, and as of **2026-08-26 there is nothing
left pending**: every entry below has been exercised against it. What remains
here is not a list of unknowns but of things worth knowing — where Stalwart
departs from a spec, where a setting has to be turned on for a feature to work,
and what ihasmail deliberately does not do.
Entries keep saying what was checked and when, because this section has been
wrong before: the 0.16 registry path was once recorded as verified live when a
capability looked for in the wrong place meant it had never run at all.
Some entries record what a live **0.15.5** proved before that server was
upgraded on 2026-08-25. They are kept where the finding is about ihasmail
rather than about 0.15 — a byte cap that still applies, a flow that still
works the same way — and dropped where 0.15 was the whole subject. Support for
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
[`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.
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognise ([#54](https://github.com/LINUXexpert-org/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` now filter on `parentId`/`isTopLevel` alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a cancelled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action``declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch has to be aimed at the base event: `CalendarEvent/set` refuses a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
+144 -178
View File
@@ -4,105 +4,68 @@
<p align="center">
<a href="LICENSE"><img alt="Licence: AGPL-3.0-or-later" src="https://img.shields.io/badge/licence-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Tested against Stalwart 0.16.19 and 0.15.5" src="https://img.shields.io/badge/Stalwart-0.16.19%20%7C%200.15.5-6366f1?style=flat-square"></a>
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.19" src="https://img.shields.io/badge/Stalwart-0.16.19-6366f1?style=flat-square"></a>
<a href="https://docs.ihasmail.org" target="_blank" rel="noreferrer"><img alt="Documentation: docs.ihasmail.org" src="https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9?style=flat-square"></a>
<a href="https://linuxexpert.org" target="_blank" rel="noreferrer"><img alt="by LINUXexpert.org" src="https://img.shields.io/badge/by-LINUXexpert.org-0f766e?style=flat-square"></a>
</p>
# 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.**
ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters and every other modern feature Stalwart exposes, 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.
> Status: 2.0 rewrite, in QA against a live Stalwart server — **0.16.19**
> since 2026-08-25, 0.15.5 before that. The previous FastAPI/HTMX prototype
> has been removed entirely (only the logo survived, and it has since lost
> the `.com` wordmark it used to carry — ihasmail is the software, not the
> hosted instance).
ihasmail supports both generations of Stalwart, which are less alike than the
version numbers suggest: 0.16 replaced the REST management API with JMAP
registry objects, changed the shape of `FileNode`, split its rights up, and
moved configuration into the store. Where the two differ, ihasmail detects
which it is talking to rather than assuming — see [Known issues / pending
QA](#known-issues--pending-qa) for what is verified on which.
The live instance was moved from 0.15.5 to 0.16.19 with
[stalwart-migrator](https://github.com/LINUXexpert-org/stalwart-migrator), a
companion project: an in-place upgrade tool that checkpoints every phase,
refuses to start on the things that cannot be fixed mid-migration, and
validates the server afterwards. The upgrade is genuinely treacherous by hand
— the store is migrated in place with no way back, and Stalwart's own
converter drops settings without saying so — and that migration took eight
seconds of downtime with nothing lost.
## Screenshots
*All screenshots are taken against the built-in mock server (`npm run dev:mock`) with sample data — no real mailbox involved.*
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, and with `IMMUTABLE=1` no writable filesystem either. Everything
durable belongs to Stalwart; the container is disposable.
| | |
| --- | --- |
| **Inbox & conversation view (dark)** ![Inbox, dark theme](docs/screenshots/inbox-dark.jpg) | **Inbox & conversation view (light)** ![Inbox, light theme](docs/screenshots/inbox-light.jpg) |
| **Reply composer** — identities, Reply-To, rich text, signature, quoted text ![Composer](docs/screenshots/compose.jpg) | **Calendar (month view)** ![Calendar](docs/screenshots/calendar.jpg) |
| **Contacts** ![Contacts](docs/screenshots/contacts.jpg) | **Sieve filter builder** — also reachable from a message's right-click menu ![Filters](docs/screenshots/filters.jpg) |
| **Sign-in** ![Login](docs/screenshots/login.jpg) | **Mobile layout** <img src="docs/screenshots/mobile.jpg" alt="Mobile" width="300"> |
| 🌐 **[ihasmail.org](https://ihasmail.org)** | What it is, what it looks like, the full feature list |
| 📘 **[docs.ihasmail.org](https://docs.ihasmail.org)** | [Installing](https://docs.ihasmail.org/install/) · [Configuring](https://docs.ihasmail.org/configure/) · [Using it](https://docs.ihasmail.org/using/) · [Shortcuts](https://docs.ihasmail.org/shortcuts/) · [Rebranding](https://docs.ihasmail.org/rebranding/) · [Troubleshooting](https://docs.ihasmail.org/troubleshooting/) |
| 🧪 **[KNOWN-ISSUES.md](KNOWN-ISSUES.md)** | What was verified live, and where Stalwart departs from a spec |
| 🛣 **[ROADMAP.md](ROADMAP.md)** | What ihasmail does not do, and why |
## Features
This file is for people working *on* ihasmail. Everything about running it
lives in the docs.
**Mail**
- Gmail-style three-pane layout (reading pane right/bottom/off, **drag-to-resize splitter** in both orientations, quick layout switch in the list menu), conversation view with collapsed messages and "show quoted text", dense/cozy/comfortable density, light/dark/system theme with accent colours
- Virtualised, infinitely-scrolling message list; multi-select (click, ⇧-click, ⌃-click), drag & drop to folders, right-click context menus, hover actions, Gmail keyboard shortcuts (`j/k`, `e`, `#`, `r/a/f`, `g i`, `/`, `?` …)
- Archive / delete / spam / star / mark read / move / labels (IMAP keywords with colours) with **Undo**
- **"Filter messages like this…"** from the message context menu: creates a Sieve rule pre-filled from the sender/list (target folders can be created on the fly), and can **apply it immediately to the existing messages in the folder** (evaluated client-side, actions applied via JMAP)
- Safe HTML rendering: DOMPurify sanitisation inside a Shadow DOM, **remote images blocked by default** with a per-sender allow-list and an optional **privacy image proxy** (like Gmail's)
- Messages sit on a light card by default, untouched as the sender designed them. *Appearance Apply the theme to messages too* lets them follow the app's light/dark theme instead — plain-text mail always does, and with the option on so does HTML mail that brings no colours of its own; mail that styles itself is still left alone
- Attachments: previews for images/PDF/text, download all, inline `cid:` images, `.eml` export, *Show original*, header viewer
- **Read receipts**: when a sender asks for one, the message offers to send it — a real RFC 8098 `multipart/report`, never automatically. Bulk mail, mailing lists and anything marked `Auto-Submitted` are not offered one at all, and a receipt aimed somewhere other than the sender says so before you send it. Sending is recorded with RFC 3503's `$mdnsent` keyword, so a second look — or another client — knows not to ask again
- Invitations: `.ics` parts render as an invite card with **Yes/Maybe/No** RSVP (via `CalendarEvent/parse` + iTIP); `.vcf` parts offer *Add to contacts*; `List-Unsubscribe` one-click
- **Right-click anyone named in a message** — sender, To, Cc, Bcc, Reply-To — to add them to the address book (the contact editor opens prefilled, with the display name split into first/last), edit them if they are already known, write to them, or copy the address
- Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel
- Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, **scheduled send** (quick picks or an exact date and time; the message waits in the server's queue, so it goes out whether or not ihasmail is open), autosaved drafts, reply/reply-all/forward with quoting and inline images preserved
- Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge
- AZ folder list with Inbox pinned on top (other special folders mixed in), subfolders nested and collapsed by default with chevrons in their own gutter so every icon lines up; unread folders are bold (a parent is bold when a subfolder has unread mail); right-click a folder to mark it read *including subfolders*, create/rename/hide/share/empty, quota bar, Outlook-style module bar (Mail · Calendar · Contacts · Files) at the bottom of the pane, multi-account switching for shared accounts
## Screenshots
**Calendar** (JMAP Calendars / JSCalendar)
- Month / week / day / agenda views, mini calendar, multiple calendars with colours, show/hide, create/edit/share calendars
- Create events by click or drag, edit everything: all-day, time zones, recurrence (presets + custom rule builder), location, meeting link, description, reminders, status/privacy/free-busy, colour
- Attendees with invitations (`sendSchedulingMessages`), RSVP, and **free/busy lookup** via `Principal/getAvailability`
- **Right-click menus** on events (open, edit, duplicate, colour, category, delete) and on empty slots/days (new event here, go to day/week)
- **Outlook-style colour categories**: named colours managed in Settings, assigned from the context menu or editor; stored as JSCalendar `categories` (+ `color`) so they sync
*Taken against the built-in mock server (`npm run dev:mock`) with sample data — no real mailbox involved.*
**Contacts** (JMAP Contacts / JSContact)
- Address books (create/rename/share/default), contact list with search and letter index, full contact editor (names, emails, phones, addresses, org/title, birthday, website, notes, photo), **groups**, vCard import/export, compose-to-contact
| | |
| --- | --- |
| **Inbox & conversation (dark)** ![Inbox, dark theme](docs/screenshots/inbox-dark.jpg) | **Inbox & conversation (light)** ![Inbox, light theme](docs/screenshots/inbox-light.jpg) |
| **Composer** ![Composer](docs/screenshots/compose.jpg) | **Calendar** ![Calendar](docs/screenshots/calendar.jpg) |
| **Contacts** ![Contacts](docs/screenshots/contacts.jpg) | **Sieve filter builder** ![Filters](docs/screenshots/filters.jpg) |
**Files** (JMAP FileNode)
- Browse folders, upload (drag & drop), download, create folders, rename, move, delete
More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
**Settings**
- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:AccountSettings/get`, falling back to `x:Account/get`), and from the browser where the server will not say; POSIX forms are normalised (`de_DE.UTF-8``de-DE`) and script modifiers preserved (`sr_RS@latin``sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `<input type="date">` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse
- **Self-service credentials** in Settings Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Works against both Stalwart generations: the `x:AccountPassword` / `x:AppPassword` registry objects on 0.16+, and the `/api/account/auth` REST endpoint on 0.15.x (the latter confirmed live)
- **Light and dark** follow the system by default, with a toggle in the top bar for flipping between them and a three-way choice in Settings Appearance
- Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings
- **Settings follow the account, not the browser** (Stalwart 0.16+): they are kept in a `settings.json` in the account's own JMAP Files, so the default identity, locale, date and time formats, theme, labels, templates, folder colours and the rest are the same wherever you sign in — including a private window. ihasmail still stores nothing itself; the file lives in the mail store and is backed up with it. Settings that describe *this* screen or browser stay local, because syncing them would be wrong rather than helpful: list-pane sizes, density, font size, sidebar state, and the notification toggles (which track a permission the browser grants per-device). localStorage is kept as a cache so the first frame is already right, and the file corrects it a moment later. On Stalwart 0.15 nothing changes — settings stay local, as before
## What's in it
**Platform**
- Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB
- **Default mail app**: register ihasmail as the browser's handler for `mailto:` links from Settings General (`registerProtocolHandler`; needs HTTPS and a browser that supports it — Safari does not). Installed as an app it also declares `protocol_handlers` in the manifest, which is what lets the operating system offer ihasmail wherever it asks for a mail client. Links arrive with recipients, Cc, Bcc, subject and body filled in
- **About** reports the Stalwart generation ihasmail detected (0.16+ or older) and the edition where the server gives one. Stalwart does not publish a version number to clients, so no version is shown rather than a made-up one
- Security: no credentials in the browser (server-side session with per-session encrypted upstream credentials), httpOnly SameSite cookies, CSRF header + Sec-Fetch-Site checks, strict CSP, sandboxed blob downloads, SSRF-safe image proxy, login rate limiting, security headers
- **Mail** — three-pane Gmail-style layout, conversation view, virtualised list, labels, undo, Gmail search operators and keyboard shortcuts, Sieve rules from a message's context menu, sanitised HTML with remote images blocked, read receipts, invitations and RSVP, multi-composer rich-text editing with signatures, scheduled send and undo send
- **Calendar** — JMAP Calendars / JSCalendar: month/week/day/agenda, recurrence, attendees and free-busy, colour categories
- **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
## Architecture
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
drive each one is in [Using ihasmail](https://docs.ihasmail.org/using/).
```
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart
React SPA • session cookie ⇄ Basic auth
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
```
## Requires Stalwart 0.16 or newer
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand stores: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views` (mail, compose, calendar, contacts, files, settings), `src/lib` (sanitiser, search parser, Sieve codec, dates and locale-aware formatting, vCard, …).
- `server/` — tiny Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, stores the credentials sealed with a key derived from the cookie secret (the server never persists plaintext passwords), proxies JMAP/blob/SSE calls, serves the SPA with a strict CSP. Also contains `src/mock/` — an in-memory fake Stalwart for local development and demos.
Sign-in refuses anything older, by name. 0.16 replaced the REST management API
with JMAP registry objects, changed the shape of `FileNode`, split its rights up
and moved configuration into the store; supporting both generations meant a
wrong guess had somewhere to fall back to, so it failed *quietly* — and that
reached production. With one supported generation a wrong guess is a loud error
on the first call.
Stalwart capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`, `contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`), `quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own `urn:stalwart:jmap` (read-only, for the account locale and to tell the generations apart). Features degrade gracefully when a capability is missing.
- Still on 0.15? The last release that runs on it is tagged [`stalwart-0.15-support`](https://github.com/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
- Upgrading? [stalwart-migrator](https://github.com/LINUXexpert-org/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
## Quick start (Docker)
@@ -113,7 +76,55 @@ docker compose up --build -d
# → http://localhost:8080 (put Caddy/nginx in front for TLS; see Caddyfile.example / nginx.example.conf)
```
Users sign in with their Stalwart mailbox credentials (TOTP codes are supported via the "two-factor code" field, which Stalwart accepts as `password$code`).
Users sign in with their Stalwart mailbox credentials. **An account with
two-factor authentication needs an app password**, created in Stalwart's own
settings — Stalwart accepts a TOTP code only through an OAuth flow and offers no
password grant, so no client holding a username and password can exchange them
plus a code for a token.
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
```
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart
React SPA • session cookie ⇄ Basic auth
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
```
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views`, `src/lib` (sanitiser, search parser, Sieve codec, locale-aware dates, vCard, …).
- `server/` — Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, seals the credentials with a key derived from the cookie secret, proxies JMAP/blob/SSE, serves the SPA under a strict CSP. `src/mock/` is an in-memory fake Stalwart for development and demos.
Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`,
`contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`),
`quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own
`urn:stalwart:jmap` (read-only). Features degrade gracefully when one is
missing.
## Development
@@ -122,18 +133,9 @@ Requirements: Node ≥ 20.10 (22 recommended), npm ≥ 10.
```bash
npm install
# against a real Stalwart (set STALWART_URL in .env or the environment)
npm run dev # server on :8080 (tsx watch) + Vite dev server on :5173 (proxying /api)
# against the built-in mock Stalwart ([email protected] / demo) — no real mailbox needed
npm run dev:mock # mock on :8788, server on :8080, Vite on :5173
# the same, with the mock impersonating Stalwart 0.15 instead of 0.16
npm run dev:mock:legacy
# the same, with the mock advertising FUTURERELEASE but dropping every hold —
# the shape of a real server whose `futureRelease` setting was never turned on
npm run dev:mock:no-future-release
npm run dev # real Stalwart (STALWART_URL in .env) — server :8080, Vite :5173
npm run dev:mock # built-in mock Stalwart ([email protected] / demo), mock on :8788
npm run dev:mock:no-future-release # mock that advertises FUTURERELEASE and drops every hold
npm run typecheck # tsc for both packages
npm test # vitest (web) + node:test (server)
@@ -141,107 +143,71 @@ npm run build # web/dist + server/dist
npm start # serve the production build
```
Open http://localhost:5173 in dev (or http://localhost:8080 for the production build).
Open http://localhost:5173 in dev, or http://localhost:8080 for the production
build. Running it for real is covered in
[Installing](https://docs.ihasmail.org/install/) and
[Configuring](https://docs.ihasmail.org/configure/).
### The mock, and which Stalwart it pretends to be
### The mock
`npm run mock` impersonates **0.16** by default; `MOCK_STALWART=0.15` (or
`npm run mock:legacy`) impersonates the generation before the registry. The
older mode is not a smaller mock — it reproduces the specific ways that
generation differs, none of which the server reports as an error:
An in-memory fake Stalwart 0.16 — enough JMAP to develop and demo against
without a real mailbox. It reproduces the things a naive fake would get wrong,
because each cost a live debugging session: `urn:stalwart:jmap` advertised
**per-account** rather than session-level, identity signatures capped at 2047
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
RFC 8984's. Two switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so
the sign-in refusal can be tested.
- `urn:stalwart:jmap` is not a capability it knows, and naming one it cannot
parse fails the **whole request**, not the one call that wanted it. On 0.16
it *is* known — but advertised per-account, in `primaryAccounts` and each
account's `accountCapabilities`, never in the session-level `capabilities`.
Stalwart validates `using` by parsing the urn rather than looking it up in
the session, so naming it works regardless; a client that tests for it in
the obvious place, though, mistakes every 0.16 server for an older one
- `x:` methods do not exist, so the registry — credentials, account settings —
is unreachable, and self-service credentials live at `POST /api/account/auth`
- `FileNode/query` masks its results to non-containers, so it returns files and
**never folders**, silently; `FileNode/get` has no such mask
- FileNode has no `nodeType` (a directory is a node with no file properties),
and rights are only `mayRead`/`mayWrite`/`mayShare`
### Version numbers
Both modes enforce the 2047-**byte** cap on identity signatures. Every one of
these cost a live debugging session against a real 0.15.5 server, because the
0.16-shaped mock could not express them; `server/src/account-legacy.test.ts`
now pins them.
`ihasmail v2.16.84``2` is ihasmail's own major, `16` the Stalwart generation
this build targets, `84` the pull request the commit came from. The first two
live in the root `package.json`; the third comes from git at build time, since
it does not exist until the PR has merged. A commit that did not arrive through
a PR carries the last number plus its short SHA — `2.16.84+g1fa6578`.
## Configuration
```bash
node scripts/version.mjs # the version for the current checkout
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2.16 .
```
All configuration is via environment variables (see `.env.example`):
`.dockerignore` excludes `.git` deliberately, so an image build cannot work this
out for itself — pass it in. Left out, the build falls back to the base version
from `package.json`, so a version with no PR number means whoever built the
image did not pass one.
| Variable | Default | Description |
| --- | --- | --- |
| `STALWART_URL` | `https://mail.example.com` | Base URL of Stalwart; the JMAP session is discovered at `/.well-known/jmap` |
| `APP_SECRET` | *(required in production)* | Secret used to derive session encryption keys |
| `PORT` / `HOST` | `8080` / `0.0.0.0` | Listen address |
| `TRUST_PROXY` | `1` | Honour `X-Forwarded-*`, but only from a peer listed in `TRUSTED_PROXIES` |
| `TRUSTED_PROXIES` | *(loopback + private ranges)* | Comma-separated CIDRs or addresses whose forwarding headers are believed. Anything else is attributed by its socket address, whatever it claims |
| `SECURE_COOKIES` | `auto` | `auto` (Secure on https), `1`, or `0` for plain-HTTP dev |
| `SESSION_TTL` / `SESSION_REMEMBER_TTL` | `43200` / `2592000` | Idle session lifetime (seconds), with/without "keep me signed in" |
| `SESSION_FILE` | *(unset)* | Persist sessions across restarts (ciphertext only) |
| `IMAGE_PROXY` | `1` | Route remote images through the privacy proxy |
| `MAX_UPLOAD_BYTES` | `52428800` | Upload size limit (Stalwart has its own limit too) |
| `APP_NAME` | `ihasmail` | Branding |
### Deploying
## Keyboard shortcuts
[`deploy.example.sh`](deploy.example.sh) is a single-host Docker deploy: it
fetches, refuses anything held back by `.deploy-hold`, shows what is about to be
introduced and asks, rebuilds with the right version baked in, replaces the
container, waits for healthy, then prunes all but the newest
`IHASMAIL_KEEP_VERSIONS` images — never the one actually running.
Press `?` anywhere. Highlights: `c` compose · `/` search · `j`/`k` navigate · `o`/`Enter` open · `u` back · `e` archive · `#` delete · `!` spam · `s` star · `r`/`a`/`f` reply/reply-all/forward · `v` move · `l` label · `x` select · `⇧I`/`⇧U` read/unread · `g i` inbox · `g l` calendar · `g c` contacts · `Ctrl+Enter` send.
```bash
./deploy.sh # origin/main, asks before shipping new commits
./deploy.sh --dry-run # run the guards and stop
./deploy.sh v2.16.84 --yes # a named ref, no prompt (there is no tty over ssh)
```
## Known issues / pending QA
`--yes` does not override a hold; clearing one means deleting its line.
The live instance ran **0.15.5** until 2026-08-25 and runs **0.16.19** now,
so both generations have been exercised against a real server. Everything
below says which.
## Contributing
Verified against a live **0.15.5**: the mail flows, self-service credentials
over the REST path, Files, and signatures.
The 0.16 registry path was previously recorded here as verified live. That
was wrong, and the entry below says why: ihasmail looked for
`urn:stalwart:jmap` in the session-level capabilities, where Stalwart has
never put it, so **every** real 0.16 server was taken for a pre-0.16 one.
Self-service credentials went to a REST endpoint 0.16 had removed, About
reported the wrong generation, and Files ran on the older code path. The mock
advertised the capability in the wrong place too, which is why nothing caught
it. Fixed, and the mock now advertises it where the real server does — but
the registry path is **awaiting live re-verification**.
- **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`); **not yet exercised against the live server**.
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look. 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 pre-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 pre-0.16 code path. It now looks in all three places. Two related soft spots went with it: a transport error while probing the registry no longer downgrades a server to the legacy REST path (which would have posted the current password to an endpoint that is not there), and a locale request that is merely refused no longer discards a generation the capability had already settled.
- **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.
- **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. (Confirmed live on 0.15.5 before the upgrade. The live instance now runs 0.16.19, where folder creation, upload, rename, move and delete were also exercised — but under the capability-placement bug below, which means what ran there was this older path against a 0.16 server, not the 0.16 path. Files now takes the 0.16 path and wants checking again on its own terms. The older path is kept for anyone still on 0.15.x and covered by `npm run dev:mock:legacy`.) `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap` — looked for in `primaryAccounts` and `accountCapabilities` as well as the session capabilities, since that is where 0.16 actually advertises it — lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24).
- **Self-service credentials** — the **0.15.x REST path was confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 registry path is confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The mock enforces the same rules either way (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. What is still mock-only is the rest of the journey: the **Scheduled** folder reconciling on the way in, and a hold actually expiring and being delivered.
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action``declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch has to be aimed at the base event: `CalendarEvent/set` refuses a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
## Roadmap / not yet
- 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)
[CONTRIBUTING.md](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
[SECURITY.md](SECURITY.md) — please report vulnerabilities privately.
## License
Copyright (C) 2026 LINUXexpert.org
Copyright (C) 2026 LINUXexpert.org — AGPL-3.0-or-later. See
[LICENSE](LICENSE).
ihasmail is free software: you can redistribute it and/or modify it under the
terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version. See [LICENSE](LICENSE) for the full text.
ihasmail was relicensed from GPL-3.0 to AGPL-3.0 on 2026-08-25. Webmail is
nearly always run as a network service rather than handed to anyone as a
binary, and the AGPL's section 13 closes that gap: anyone running a modified
ihasmail for other people has to offer them its source, which the GPL alone
does not require.
ihasmail was relicensed from GPL-3.0 to AGPL-3.0 on 2026-08-25: webmail is
nearly always run as a network service rather than handed to anyone as a binary,
and the AGPL's section 13 closes that gap.
That offer has to point at *your* source, not this one. If you run a modified
ihasmail, set `SOURCE_URL` to your own repository: the sign-in page and
Settings About both show it, so the people using your instance are told where
the code they are actually running can be found.
ihasmail, set `SOURCE_URL` to your own repository the sign-in page and
Settings About both show it. See
[Rebranding](https://docs.ihasmail.org/rebranding/).
+12
View File
@@ -0,0 +1,12 @@
# Roadmap / not yet
Things ihasmail does not do, and why. Anything with an issue number is tracked
in [the issue tracker](https://github.com/LINUXexpert-org/ihasmail/issues); the
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)
+239
View File
@@ -0,0 +1,239 @@
#!/bin/bash
# Redeploy ihasmail on a single-host Docker setup, from a git checkout.
#
# Copy it, or run it as-is and set the variables below in the environment.
# Nothing here is specific to any one host: the defaults describe the shape of
# a deployment rather than anyone's particular one.
#
# Usage: ./deploy.sh [git-ref] [-y|--yes] [-n|--dry-run]
#
# Three guards stand between a careless run and production:
#
# .deploy-hold commits that must not reach prod yet, one per line. If the
# target contains one that is not already deployed, the deploy
# is refused outright -- `--yes` does not override it. Clearing
# a hold means deleting its line, which is a deliberate edit.
#
# confirmation anything introducing new commits is listed first and has to
# be confirmed. Over SSH, where there is no terminal to answer
# on, that means passing --yes: a bare `deploy.sh` cannot ship
# whatever main happens to have picked up since the last
# release.
#
# --dry-run runs both guards, says what it would deploy, and stops before
# building or touching the container.
#
# The container is replaced rather than restarted, because the image is rebuilt
# from the new checkout. Data lives in a named volume and survives that; the
# environment file is never read here, only handed to Docker.
set -euo pipefail
# --- what to deploy, and where ----------------------------------------------
# The checkout to deploy from. It must be a git clone: the version number is
# read from its history (see scripts/version.mjs).
APP="${IHASMAIL_APP:-$HOME/apps/ihasmail}"
# Environment file passed to the container. Keep it outside the repo's tracked
# files -- it holds APP_SECRET and the upstream URL. Never read by this script.
ENVF="${IHASMAIL_ENV:-$APP/.env.production}"
# Commits held back from production, one per line; blank or missing is fine.
HOLD="${IHASMAIL_HOLD:-$APP/.deploy-hold}"
# Container name, and where to publish it. The default binds to loopback only,
# 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). 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}"
# How long to wait for the new container to report healthy, in seconds.
HEALTH_TIMEOUT="${IHASMAIL_HEALTH_TIMEOUT:-30}"
# How many past versions to keep as images, for rolling back to. Each is around
# 650 MB, and a deploy adds one, so left alone they accumulate a gigabyte every
# couple of releases -- and `docker image prune` will not touch them, because
# they are tagged. 0 keeps every version.
KEEP_VERSIONS="${IHASMAIL_KEEP_VERSIONS:-3}"
# --- run from a copy, if this script lives in the checkout it resets ---------
# `git reset --hard` below rewrites the working tree, and this script may be
# part of it. Bash does not read a script all at once -- it reads as it goes,
# by byte offset -- so a file replaced underneath it makes the shell stop
# wherever it had reached. Silently, and with exit status 0: a deploy that
# stopped halfway would report success. Re-exec from a copy outside the tree so
# the file being run cannot change while it runs.
SELF="$(readlink -f "$0")"
APP_REAL="$(readlink -f "$APP" 2>/dev/null || printf '%s' "$APP")"
if [ -z "${IHASMAIL_REEXEC:-}" ] && [ "${SELF#"$APP_REAL"/}" != "$SELF" ]; then
COPY="$(mktemp "${TMPDIR:-/tmp}/ihasmail-deploy.XXXXXX")"
cat "$SELF" > "$COPY"
chmod +x "$COPY"
IHASMAIL_REEXEC=1 exec "$COPY" "$@"
fi
# The copy has served its purpose once we exit; the shell has finished reading
# it by then.
if [ -n "${IHASMAIL_REEXEC:-}" ]; then
trap 'rm -f "$SELF"' EXIT
fi
REF=""
ASSUME_YES=0
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
-y|--yes) ASSUME_YES=1 ;;
-n|--dry-run) DRY_RUN=1 ;;
-h|--help) sed -n '2,28p' "$0"; exit 0 ;;
-*) echo "unknown option: $arg" >&2; exit 2 ;;
*)
if [ -n "$REF" ]; then echo "give at most one git-ref (got '$REF' and '$arg')" >&2; exit 2; fi
REF="$arg" ;;
esac
done
REF="${REF:-origin/main}"
cd "$APP"
git fetch --quiet origin
if ! TARGET=$(git rev-parse --verify --quiet "${REF}^{commit}"); then
echo "!! no such commit: $REF" >&2
exit 2
fi
CURRENT=$(git rev-parse --verify HEAD)
# --- guard 1: commits held back from production -----------------------------
if [ -f "$HOLD" ]; then
blocked=""
while IFS= read -r line || [ -n "$line" ]; do
line="${line%%#*}"
line="$(printf '%s' "$line" | tr -d '[:space:]')"
[ -z "$line" ] && continue
if ! held=$(git rev-parse --verify --quiet "${line}^{commit}"); then
echo " (hold list names '$line', which this checkout does not know -- ignoring)" >&2
continue
fi
# Only a problem if the target carries it and production does not already.
if git merge-base --is-ancestor "$held" "$TARGET" && ! git merge-base --is-ancestor "$held" "$CURRENT"; then
blocked="${blocked} $(git log --oneline -1 "$held")"$'\n'
fi
done < "$HOLD"
if [ -n "$blocked" ]; then
echo "!! refusing to deploy $REF: it contains commits held back from production:" >&2
printf '%s' "$blocked" >&2
echo " listed in $HOLD -- delete the line to clear the hold, or deploy a ref without it." >&2
exit 1
fi
fi
# --- guard 2: say what is being introduced, and get a yes --------------------
NEW=$(git log --oneline "$CURRENT..$TARGET")
if [ -n "$NEW" ]; then
echo "==> $(git log --oneline -1 "$CURRENT") -> $(git log --oneline -1 "$TARGET")"
echo "==> introduces:"
printf '%s\n' "$NEW" | sed 's/^/ /'
if [ "$ASSUME_YES" -ne 1 ]; then
if [ -t 0 ]; then
read -r -p "deploy these to production? [y/N] " reply
case "$reply" in
y|Y|yes|YES) ;;
*) echo "aborted."; exit 1 ;;
esac
else
echo "!! refusing: this introduces new commits and there is no terminal to confirm on." >&2
echo " re-run with --yes if that is what you mean, or name the ref you want." >&2
exit 1
fi
fi
else
echo "==> already at $(git log --oneline -1 "$TARGET"); rebuilding"
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo "==> dry run: would deploy $(git log --oneline -1 "$TARGET"); nothing was changed"
exit 0
fi
git reset --hard --quiet "$TARGET"
# The version is worked out here, from the checkout, because the image build
# cannot: .dockerignore keeps .git out of the build context. Without this the
# build falls back to the base version in package.json and every deployment
# reports the same number -- see "Version numbers" in the README.
# Drop the oldest versioned images, keeping the newest KEEP_VERSIONS of them.
#
# Only ever runs after the new container reports healthy, so a rollback target
# is never removed while the thing replacing it is still unproven. The image in
# use is excluded outright rather than relied on to sort newest -- docker
# refuses to remove an image a container is using, but being refused is not the
# same as not having tried.
prune_old_images() {
[ "$KEEP_VERSIONS" -gt 0 ] || return 0
local in_use stale
in_use="$(docker inspect "$NAME" --format '{{.Config.Image}}' 2>/dev/null || true)"
# Newest first, tags only, skipping the moving ":current" pointer.
stale="$(docker images "$IMAGE_REPO" --format '{{.Repository}}:{{.Tag}}\t{{.CreatedAt}}' \
| grep -v ":current" \
| sort -k2 -r \
| cut -f1 \
| grep -vxF "$in_use" \
| tail -n +"$((KEEP_VERSIONS + 1))")"
[ -n "$stale" ] || return 0
echo "==> removing $(printf '%s\n' "$stale" | wc -l) old image(s), keeping the newest $KEEP_VERSIONS"
printf '%s\n' "$stale" | xargs -r docker rmi >/dev/null 2>&1 || true
}
VERSION="$(node scripts/version.mjs)"
# A Docker tag may not contain "+", which a version for a commit that did not
# come through a pull request does: 2.16.57+g1fa6578. The image is tagged with
# the "+" turned into "-"; what the build is *told* it is keeps the real form,
# so About and /api/health still report it correctly.
TAG="${VERSION//+/-}"
echo "==> building $(git log --oneline -1) as v$VERSION"
docker build \
--build-arg IHASMAIL_VERSION="$VERSION" \
-t "$IMAGE_REPO:$TAG" \
-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 "${RUN_ARGS[@]}" "$IMAGE_REPO:$TAG" >/dev/null
for _ in $(seq 1 "$HEALTH_TIMEOUT"); do
if health=$(curl -sf "http://$BIND/api/health"); then
echo "==> healthy: $health"
prune_old_images
exit 0
fi
sleep 1
done
echo "!! did not become healthy after ${HEALTH_TIMEOUT}s; logs:" >&2
docker logs "$NAME" 2>&1 | tail -20 >&2
echo "!! the previous image is still tagged, if you need it back:" >&2
docker images "$IMAGE_REPO" --format ' {{.Repository}}:{{.Tag}} {{.CreatedSince}}' | head -5 >&2
exit 1
+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

+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ihasmail",
"version": "2.0.0",
"version": "2.16.0",
"private": true,
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
"license": "AGPL-3.0-or-later",
@@ -21,7 +21,6 @@
"lint": "npm run typecheck",
"mock": "npm run mock -w server",
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"dev:mock:legacy": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:legacy -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
},
"devDependencies": {
+4
View File
@@ -0,0 +1,4 @@
/** Types for `version.mjs`, which is plain JS so the Dockerfile and shell can run it directly. */
export function baseVersion(): string;
export function versionFromGit(): string | null;
export function resolveVersion(): string;
+83
View File
@@ -0,0 +1,83 @@
/**
* Work out this build's version: `2.16.57`.
*
* 2 ihasmail's own major
* 16 the Stalwart major this build targets — 0.16, the oldest it supports
* 57 the pull request the checked-out commit came from
*
* The first two are the `version` in the root package.json, so there is one
* place to bump them; the third is read from git, because it does not exist
* until the pull request has actually merged. Nothing writes a version back
* into the tree: a committed one would always be describing a merge that had
* not happened yet, and every branch would collide on the same line.
*
* A commit that did not arrive through a pull request has no number of its
* own, so it carries the last one plus its own short SHA — `2.16.57+g1fa6578`
* — which is honest about being past that PR rather than silently claiming to
* be it.
*
* `.dockerignore` excludes `.git`, so an image build cannot run any of this.
* It takes the answer through `--build-arg IHASMAIL_VERSION=...` instead, and
* whoever builds is responsible for computing it — see ihasmail-deploy.sh.
*/
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
/** "2.16" — ihasmail major and the Stalwart major this build is built for. */
export function baseVersion() {
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
const [major, minor] = String(pkg.version).split(".");
return `${major}.${minor}`;
}
function git(...args) {
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
}
const PR_SUBJECT = /^Merge pull request #(\d+)\b/;
/**
* The version for the commit checked out here, or null when there is no git to
* ask — an unpacked tarball, or the Docker build context.
*/
export function versionFromGit() {
let head;
try {
head = git("rev-parse", "--short", "HEAD");
} catch {
return null;
}
const base = baseVersion();
try {
// Walk back over first parents: a merge commit's subject names its PR, and
// anything after the newest one is work that has not been through one.
const log = git("log", "--first-parent", "--format=%H%x00%s", "-n", "200");
const commits = log ? log.split("\n").map((l) => l.split("\0")) : [];
for (const [sha, subject = ""] of commits) {
const pr = PR_SUBJECT.exec(subject)?.[1];
if (!pr) continue;
// The PR's own merge commit is the version; anything above it is past it.
const exact = sha.startsWith(git("rev-parse", "HEAD"));
return exact ? `${base}.${pr}` : `${base}.${pr}+g${head}`;
}
} catch {
/* a shallow clone, or no history to read */
}
return `${base}.0+g${head}`;
}
/** Whatever the environment was told, else git, else just the base. */
export function resolveVersion() {
const fromEnv = process.env.IHASMAIL_VERSION?.trim();
if (fromEnv) return fromEnv;
return versionFromGit() ?? `${baseVersion()}.0`;
}
// `node scripts/version.mjs` prints it, for shell scripts and CI.
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
process.stdout.write(resolveVersion() + "\n");
}
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@ihasmail/server",
"version": "2.0.0",
"version": "2.16.0",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
@@ -12,7 +12,6 @@
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "tsx --test src/*.test.ts src/**/*.test.ts",
"mock": "tsx src/mock/index.ts",
"mock:legacy": "MOCK_STALWART=0.15 tsx src/mock/index.ts",
"mock:no-future-release": "MOCK_NO_FUTURE_RELEASE=1 tsx src/mock/index.ts"
},
"dependencies": {
-183
View File
@@ -1,183 +0,0 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
/**
* The same self-service flows, against a mock impersonating Stalwart 0.15.
*
* That generation has no registry: credentials live behind a REST endpoint,
* `urn:stalwart:jmap` is not a capability it knows, and naming one it cannot
* parse fails the whole request. Until now this adapter had no coverage at all
* — it was the least-tested code in the project, verified only by hand.
*/
const PORT = 18799;
process.env.MOCK_PORT = String(PORT);
process.env.MOCK_STALWART = "0.15";
process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "demo-password";
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-legacy-flows";
const mock = await import("./mock/index.js");
const { createApp } = await import("./app.js");
const app = createApp();
let cookie = "";
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: any }> {
const res = await app.request(path, {
...init,
headers: { ...HEADERS, ...(init.headers as Record<string, string>), ...(cookie ? { cookie } : {}) },
});
const setCookie = res.headers.get("set-cookie");
if (setCookie) cookie = setCookie.split(";")[0]!;
const text = await res.text();
return { status: res.status, body: text ? JSON.parse(text) : null };
}
const post = (path: string, body: unknown) => call(path, { method: "POST", body: JSON.stringify(body) });
before(async () => {
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password" });
assert.equal(res.status, 200, "login should succeed against the legacy mock");
});
after(() => {
(mock as { server?: { close(): void } }).server?.close();
});
test("the older server is recognised, and reported as such", async () => {
const res = await call("/api/auth/session");
assert.equal(res.status, 200);
assert.equal(res.body.ihasmail.server.generation, "pre-0.16");
assert.equal(res.body.ihasmail.server.edition, null, "no edition is reported before 0.16");
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "the capability does not exist here");
});
test("credentials fall back to the REST endpoint", async () => {
const res = await call("/api/account/security");
assert.equal(res.status, 200);
assert.equal(res.body.backend, "legacy");
assert.equal(res.body.otpEnabled, false);
assert.equal(res.body.appPasswordsKeyedByName, true, "this generation has only names to go on");
});
test("app passwords round-trip, keyed by their name", async () => {
const created = await post("/api/account/app-passwords", { description: "Thunderbird" });
assert.equal(created.status, 200);
assert.ok(created.body.secret, "a secret is generated for the user to copy");
assert.equal(created.body.id, "Thunderbird", "the name is the identifier here");
const listed = await call("/api/account/security");
assert.deepEqual(listed.body.appPasswords.map((a: { description: string }) => a.description), ["Thunderbird"]);
await post("/api/account/app-passwords/revoke", { id: "Thunderbird" });
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
});
test("the current password is verified before it is changed", async () => {
// The REST endpoint would take our word for it, so ihasmail proves it first.
const wrong = await post("/api/account/password", { current: "not-my-password", next: "a-much-longer-password" });
assert.equal(wrong.status, 403);
assert.match(wrong.body.message, /incorrect/i);
assert.equal((mock as { account: { password: string } }).account.password, "demo-password", "nothing was changed");
});
test("changing the password keeps this session working", async () => {
const res = await post("/api/account/password", { current: "demo-password", next: "a-brand-new-password" });
assert.equal(res.status, 200);
assert.equal((mock as { account: { password: string } }).account.password, "a-brand-new-password");
assert.equal((await call("/api/auth/session")).status, 200, "the session was re-sealed");
});
test("2FA is enabled with a code proved against the new secret", async () => {
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
const begin = await post("/api/account/2fa/begin", {});
const params = parseOtpauthUrl(begin.body.url);
assert.ok(params);
const bad = await post("/api/account/2fa/enable", { url: begin.body.url, code: "000000", current: "a-brand-new-password" });
assert.equal(bad.status, 400);
assert.equal((mock as { account: { otpUrl: string | null } }).account.otpUrl, null, "nothing was stored");
const good = await post("/api/account/2fa/enable", { url: begin.body.url, code: totpCode(params), current: "a-brand-new-password" });
assert.equal(good.status, 200);
assert.equal(good.body.sessionKept, true, "the session moved onto an app password");
assert.equal((await call("/api/account/security")).body.otpEnabled, true);
});
test("2FA is switched off again", async () => {
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl;
const params = parseOtpauthUrl(stored!);
assert.ok(params);
const res = await post("/api/account/2fa/disable", { current: "a-brand-new-password", code: totpCode(params) });
assert.equal(res.status, 200);
assert.equal((await call("/api/account/security")).body.otpEnabled, false);
});
/**
* The mock is only worth having if it is faithful, so these pin the specific
* behaviours that cost us a live debugging session each. Every one of them was
* invisible to the 0.16 mock, which is how the bugs shipped.
*/
const jmap = (using: string[], methodCalls: unknown[]) => post("/api/jmap", { using, methodCalls });
const CORE = "urn:ietf:params:jmap:core";
const MAIL = "urn:ietf:params:jmap:mail";
const FILES = "urn:ietf:params:jmap:filenode";
test("naming a capability it cannot parse fails the whole request", async () => {
const res = await jmap([CORE, "urn:stalwart:jmap"], [["Mailbox/get", { accountId: "a1", ids: null }, "c0"]]);
assert.notEqual(res.status, 200, "not one failed call - the entire request");
});
test("x: methods do not exist, so they come back unknownMethod", async () => {
const res = await jmap([CORE], [["x:AccountPassword/get", { accountId: "a1", ids: ["singleton"] }, "c0"]]);
assert.equal(res.status, 200);
assert.equal(res.body.methodResponses[0][0], "error");
assert.equal(res.body.methodResponses[0][1].type, "unknownMethod");
});
test("FileNode/set refuses nodeType by name", async () => {
const res = await jmap([CORE, FILES], [["FileNode/set", { accountId: "a1", create: { d: { parentId: null, name: "New", nodeType: "directory" } } }, "c0"]]);
const set = res.body.methodResponses[0][1];
assert.equal(set.notCreated.d.type, "invalidProperties");
assert.deepEqual(set.notCreated.d.properties, ["nodeType"]);
});
test("a directory is a node with no file properties, and query cannot see it", async () => {
const made = await jmap([CORE, FILES], [["FileNode/set", { accountId: "a1", create: { d: { parentId: null, name: "Reports" } } }, "c0"]]);
const id = made.body.methodResponses[0][1].created.d.id;
assert.ok(id);
const queried = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1" }, "c0"]]);
assert.equal(queried.body.methodResponses[0][1].ids.includes(id), false, "query masks out containers");
// get carries no such mask, which is the only way to find a folder here.
const got = await jmap([CORE, FILES], [["FileNode/get", { accountId: "a1", ids: null }, "c0"]]);
const list = got.body.methodResponses[0][1].list as { id: string; nodeType?: string; myRights: Record<string, boolean> }[];
const dir = list.find((n) => n.id === id);
assert.ok(dir, "get returns the directory");
assert.equal(dir!.nodeType, undefined, "nodeType is not a property here");
assert.deepEqual(Object.keys(dir!.myRights).sort(), ["mayRead", "mayShare", "mayWrite"], "the coarser rights");
});
test("FileNode/query refuses the filters and sorts this generation lacks", async () => {
const filtered = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1", filter: { isTopLevel: true } }, "c0"]]);
assert.equal(filtered.body.methodResponses[0][1].type, "unsupportedFilter");
const sorted = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1", sort: [{ property: "nodeType" }] }, "c0"]]);
assert.equal(sorted.body.methodResponses[0][1].type, "unsupportedSort");
});
test("an identity signature is capped in bytes, not characters", async () => {
// 1200 CJK characters: comfortably under 2047 counted as characters, and
// 3600 bytes once encoded.
const tooBig = "日".repeat(1200);
assert.ok(tooBig.length < 2047 && Buffer.byteLength(tooBig, "utf8") > 2047);
const res = await jmap([CORE, MAIL], [["Identity/set", { accountId: "a1", update: { i1: { htmlSignature: tooBig } } }, "c0"]]);
const set = res.body.methodResponses[0][1];
assert.equal(set.notUpdated.i1.type, "invalidProperties");
assert.deepEqual(set.notUpdated.i1.properties, ["htmlSignature"]);
});
+37 -9
View File
@@ -47,27 +47,25 @@ after(() => {
});
/**
* What the About page reads. Stalwart advertises `urn:stalwart:jmap` only
* per-account, so a session that looks for it at the top level reports a real
* 0.16 server as older than 0.16 — the same mistake that sent credentials to
* the removed REST endpoint.
* Stalwart advertises `urn:stalwart:jmap` only per-account, never in the
* session-level capabilities. Looking for it at the top level alone reported
* every real 0.16 server as older than 0.16 — and now that the same check
* decides whether a sign-in is allowed at all, that mistake would lock
* everyone out rather than merely misroute credentials.
*/
test("the session reports the 0.16 generation the server actually is", async () => {
test("the session is accepted on a server that advertises the registry per-account", async () => {
const res = await call("/api/auth/session");
assert.equal(res.status, 200);
assert.equal(res.body.ihasmail.server.generation, "0.16+");
assert.equal(res.body.ihasmail.server.edition, "oss");
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look");
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server");
});
test("the 0.16 registry backend is detected and reported empty", async () => {
test("the registry reports an account with nothing set up yet", async () => {
const res = await call("/api/account/security");
assert.equal(res.status, 200);
assert.equal(res.body.backend, "registry");
assert.equal(res.body.otpEnabled, false);
assert.deepEqual(res.body.appPasswords, []);
assert.equal(res.body.appPasswordsKeyedByName, false);
});
test("app passwords are created, listed once with their secret, and revoked", async () => {
@@ -176,3 +174,33 @@ test("credential endpoints reject unauthenticated callers", async () => {
assert.equal((await post("/api/account/2fa/begin", {})).status, 401);
cookie = saved;
});
/**
* A sign-in carrying a two-factor code that the server rejects is almost never
* "wrong password". Stalwart accepts TOTP only through an OAuth flow and offers
* no password grant, so the concatenated form ihasmail sends cannot work — and
* saying "invalid credentials" sends the user to check a password that is fine.
*
* Reported as #75: 2FA sign-in failed with a bare 401 while an app password
* worked, which is Stalwart's documented route and gave no hint of itself.
*/
test("a rejected sign-in carrying a TOTP code explains itself", async () => {
const saved = cookie;
cookie = "";
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password", totp: "123456" });
cookie = saved;
assert.equal(res.status, 401);
assert.equal(res.body.error, "totp_unsupported", "not the generic invalid_credentials");
assert.match(res.body.message, /app password/i, "points at the route that does work");
assert.match(res.body.message, /probably fine/i, "does not blame the password");
});
test("a rejected sign-in without a code is still a plain credential failure", async () => {
// The explanation must not leak onto ordinary typos.
const saved = cookie;
cookie = "";
const res = await post("/api/auth/login", { username: "[email protected]", password: "wrong" });
cookie = saved;
assert.equal(res.status, 401);
assert.equal(res.body.error, "invalid_credentials");
});
+44 -222
View File
@@ -1,17 +1,15 @@
import { config } from "./config.js";
import { absoluteUpstream, hasStalwartRegistry, UpstreamError, type UpstreamSession } from "./upstream.js";
import { absoluteUpstream, UpstreamError, type UpstreamSession } from "./upstream.js";
import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
import { randomBytes } from "node:crypto";
/**
* Self-service credential management, across two incompatible Stalwart APIs.
* Self-service credential management, over Stalwart's JMAP registry:
* `x:AccountPassword` (a singleton holding the password and the otpauth URL)
* and `x:AppPassword`.
*
* 0.16+ JMAP registry objects: x:AccountPassword (a singleton holding the
* password and the otpauth URL) and x:AppPassword.
* 0.15.x a REST endpoint, POST /api/account/auth, taking a list of actions.
*
* The registry crate does not exist before 0.16 and the REST endpoint is gone
* after it, so which one answers is the only reliable way to tell them apart.
* The registry crate arrived in 0.16, which is the oldest Stalwart ihasmail
* supports. Sign-in refuses anything older, so by the time any of this runs
* the registry is known to be there.
*/
const STALWART_CAP = "urn:stalwart:jmap";
@@ -21,10 +19,7 @@ const SINGLETON = "singleton";
/** Returned in place of a stored secret; echo it back to leave one unchanged. */
const MASKED = "[********]";
export type Backend = "registry" | "legacy";
export interface AppPasswordRow {
/** Registry object id, or the name itself on legacy servers. */
id: string;
description: string;
createdAt: string | null;
@@ -32,14 +27,8 @@ export interface AppPasswordRow {
}
export interface SecurityState {
backend: Backend;
otpEnabled: boolean;
appPasswords: AppPasswordRow[];
/**
* Legacy servers key app passwords by name and hand back nothing else, so
* the UI must keep names unique and cannot show when one was created.
*/
appPasswordsKeyedByName: boolean;
}
/** An error with a message meant for the person using the app. */
@@ -61,49 +50,7 @@ interface Ctx {
}
/* ------------------------------------------------------------------ */
/* Backend detection */
/* ------------------------------------------------------------------ */
const backendCache = new Map<string, { backend: Backend; at: number }>();
const BACKEND_CACHE_MS = 30 * 60_000;
export function forgetBackend(sessionId: string): void {
backendCache.delete(sessionId);
}
export async function detectBackend(sessionId: string, ctx: Ctx): Promise<Backend> {
const cached = backendCache.get(sessionId);
if (cached && Date.now() - cached.at < BACKEND_CACHE_MS) return cached.backend;
const backend = await probeBackend(ctx);
backendCache.set(sessionId, { backend, at: Date.now() });
return backend;
}
async function probeBackend(ctx: Ctx): Promise<Backend> {
// A server with the registry answers x:AccountPassword/get; one without it
// fails to parse the method name at all and returns unknownMethod.
if (hasStalwartRegistry(ctx.session)) {
try {
const res = await jmap(ctx, [["x:AccountPassword/get", { accountId: accountId(ctx), ids: [SINGLETON] }, "p"]]);
const [name, args] = res.methodResponses?.[0] ?? [];
if (name && name !== "error") return "registry";
const type = (args as { type?: string } | undefined)?.type;
if (type && type !== "unknownMethod") return "registry"; // present, but refused us
} catch {
// The capability already told us this server has the registry, so a
// request we could not read is a fault to surface, not evidence of an
// older server. Falling back here would post the user's password to a
// REST endpoint 0.16 removed and report the feature as unsupported.
return "registry";
}
// It named the capability and then disowned the method: nothing else to try.
return "registry";
}
return "legacy";
}
/* ------------------------------------------------------------------ */
/* Transports */
/* Transport */
/* ------------------------------------------------------------------ */
function accountId(ctx: Ctx): string {
@@ -129,29 +76,6 @@ async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodRespon
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
}
async function legacy<T>(ctx: Ctx, init: RequestInit): Promise<T> {
const res = await fetch(`${config.stalwartUrl}/api/account/auth`, {
...init,
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
if (res.status === 404) {
throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported");
}
if (!res.ok) {
let detail = "";
try {
const body = (await res.json()) as { error?: string; details?: string; reason?: string };
detail = body.details ?? body.reason ?? body.error ?? "";
} catch {
/* fall through to the generic message */
}
throw new AccountError(detail || `The mail server rejected the change (${res.status}).`, 502, "upstream");
}
return ((await res.json()) as { data: T }).data;
}
/**
* Pull the single result out of a /set, turning JMAP's several failure shapes
* into one error carrying whatever the server was willing to explain.
@@ -192,17 +116,7 @@ function describeSetError(err: { type?: string; description?: string; properties
/* Operations */
/* ------------------------------------------------------------------ */
export async function getState(sessionId: string, ctx: Ctx): Promise<SecurityState> {
const backend = await detectBackend(sessionId, ctx);
if (backend === "legacy") {
const data = await legacy<{ otpEnabled?: boolean; appPasswords?: string[] }>(ctx, { method: "GET" });
return {
backend,
otpEnabled: Boolean(data.otpEnabled),
appPasswords: (data.appPasswords ?? []).map((name) => ({ id: name, description: name, createdAt: null, expiresAt: null })),
appPasswordsKeyedByName: true,
};
}
export async function getState(ctx: Ctx): Promise<SecurityState> {
const id = accountId(ctx);
const res = await jmap(ctx, [
["x:AccountPassword/get", { accountId: id, ids: [SINGLETON] }, "p"],
@@ -211,7 +125,6 @@ export async function getState(sessionId: string, ctx: Ctx): Promise<SecuritySta
const pass = firstListItem(res, "p") as { otpAuth?: { otpUrl?: string | null } } | null;
const apps = listOf(res, "a");
return {
backend,
// The URL itself is masked; its presence is what tells us 2FA is on.
otpEnabled: Boolean(pass?.otpAuth?.otpUrl),
appPasswords: apps.map((a) => ({
@@ -220,7 +133,6 @@ export async function getState(sessionId: string, ctx: Ctx): Promise<SecuritySta
createdAt: typeof a.createdAt === "string" ? a.createdAt : null,
expiresAt: typeof a.expiresAt === "string" ? a.expiresAt : null,
})),
appPasswordsKeyedByName: false,
};
}
@@ -235,56 +147,25 @@ function firstListItem(res: { methodResponses?: [string, unknown, string][] }, c
return listOf(res, callId)[0] ?? null;
}
export async function changePassword(
sessionId: string,
ctx: Ctx,
opts: { current: string; next: string; otpCode?: string },
): Promise<void> {
const backend = await detectBackend(sessionId, ctx);
if (backend === "registry") {
const update: Record<string, unknown> = { currentSecret: opts.current, secret: opts.next };
if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode;
const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]);
setResult(res, "updated");
return;
}
// The legacy endpoint changes the password without asking for the old one,
// so anyone holding a live session could set it. Prove it ourselves first.
await assertCurrentPassword(ctx, opts.current, opts.otpCode);
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "setPassword", password: opts.next }]) });
export async function changePassword(ctx: Ctx, opts: { current: string; next: string; otpCode?: string }): Promise<void> {
const update: Record<string, unknown> = { currentSecret: opts.current, secret: opts.next };
if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode;
const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]);
setResult(res, "updated");
}
export async function createAppPassword(
sessionId: string,
ctx: Ctx,
opts: { description: string },
): Promise<{ id: string; secret: string }> {
const backend = await detectBackend(sessionId, ctx);
export async function createAppPassword(ctx: Ctx, opts: { description: string }): Promise<{ id: string; secret: string }> {
const description = opts.description.trim() || "App password";
if (backend === "registry") {
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), create: { n: { description } } }, "s"]]);
const created = setResult(res, "created");
const secret = created && typeof created.secret === "string" ? created.secret : "";
if (!secret) throw new AccountError("The mail server created the app password but did not return it.", 502, "upstream");
return { id: String(created?.id ?? description), secret };
}
// Legacy servers take a secret of our choosing and key it by name.
const secret = readableSecret();
await legacy<unknown>(ctx, {
method: "POST",
body: JSON.stringify([{ type: "addAppPassword", name: description, password: secret }]),
});
return { id: description, secret };
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), create: { n: { description } } }, "s"]]);
const created = setResult(res, "created");
const secret = created && typeof created.secret === "string" ? created.secret : "";
if (!secret) throw new AccountError("The mail server created the app password but did not return it.", 502, "upstream");
return { id: String(created?.id ?? description), secret };
}
export async function revokeAppPassword(sessionId: string, ctx: Ctx, id: string): Promise<void> {
const backend = await detectBackend(sessionId, ctx);
if (backend === "registry") {
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
setResult(res, "destroyed");
return;
}
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "removeAppPassword", name: id }]) });
export async function revokeAppPassword(ctx: Ctx, id: string): Promise<void> {
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
setResult(res, "destroyed");
}
/**
@@ -311,89 +192,30 @@ export function assertEnrolmentCode(url: string, code: string): void {
}
}
export async function enableOtp(
sessionId: string,
ctx: Ctx,
opts: { url: string; code: string; current: string },
): Promise<void> {
export async function enableOtp(ctx: Ctx, opts: { url: string; code: string; current: string }): Promise<void> {
assertEnrolmentCode(opts.url, opts.code);
const backend = await detectBackend(sessionId, ctx);
if (backend === "registry") {
const res = await jmap(ctx, [
[
"x:AccountPassword/set",
{ accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } },
"s",
],
]);
setResult(res, "updated");
return;
}
await assertCurrentPassword(ctx, opts.current);
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "enableOtpAuth", url: opts.url }]) });
const res = await jmap(ctx, [
[
"x:AccountPassword/set",
{ accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } },
"s",
],
]);
setResult(res, "updated");
}
export async function disableOtp(
sessionId: string,
ctx: Ctx,
opts: { current: string; code: string },
): Promise<void> {
const backend = await detectBackend(sessionId, ctx);
if (backend === "registry") {
const res = await jmap(ctx, [
[
"x:AccountPassword/set",
{
accountId: accountId(ctx),
update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpCode": opts.code, "otpAuth/otpUrl": null } },
},
"s",
],
]);
setResult(res, "updated");
return;
}
await assertCurrentPassword(ctx, opts.current, opts.code);
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "disableOtpAuth", url: null }]) });
}
/**
* Confirm a password by authenticating with it, for the legacy endpoint that
* would otherwise take our word for it.
*/
async function assertCurrentPassword(ctx: Ctx, current: string, otpCode?: string): Promise<void> {
const secret = otpCode ? `${current}$${otpCode}` : current;
const authorization = `Basic ${Buffer.from(`${ctx.username}:${secret}`, "utf8").toString("base64")}`;
const res = await fetch(`${config.stalwartUrl}/.well-known/jmap`, {
headers: { authorization, accept: "application/json" },
redirect: "follow",
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.status === 401 || res.status === 403) {
throw new AccountError("That password is incorrect.", 403, "bad_password");
}
if (!res.ok) throw new UpstreamError(`Could not verify the current password (${res.status})`, 502);
}
/**
* A legacy app password a person can read off a screen and type.
*
* Drawn by rejection sampling. Plain `% alphabet.length` would favour the
* first 25 characters, because 256 is not a multiple of 33: each of those
* would come up on 8 byte values and the remaining 8 on only 7.
*/
export function readableSecret(): string {
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"; // no l/1/0 lookalikes
const limit = 256 - (256 % alphabet.length);
const chars: string[] = [];
while (chars.length < 20) {
for (const b of randomBytes(32)) {
if (b >= limit) continue; // the tail that would skew the alphabet
chars.push(alphabet[b % alphabet.length]!);
if (chars.length === 20) break;
}
}
return (chars.join("").match(/.{5}/g) ?? []).join("-");
export async function disableOtp(ctx: Ctx, opts: { current: string; code: string }): Promise<void> {
const res = await jmap(ctx, [
[
"x:AccountPassword/set",
{
accountId: accountId(ctx),
update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpCode": opts.code, "otpAuth/otpUrl": null } },
},
"s",
],
]);
setResult(res, "updated");
}
export { MASKED };
+20 -39
View File
@@ -7,7 +7,8 @@ import { getAccountInfo, hasStalwartRegistry, interpretAccountInfo } from "./ups
* the `sysAccountGet` permission — one the built-in `user` role is not given.
* Ordinary users therefore silently fell back to the browser locale. Stalwart
* 0.16 exposes the same field on `x:AccountSettings`, which users *can* read,
* so both are asked for and whichever answers wins.
* so both are asked for and whichever answers wins. Both are 0.16 methods:
* this is a permissions fallback, not a version one.
*/
type Responses = [string, Record<string, unknown>, string][];
@@ -19,7 +20,6 @@ const failed = (id: string, type: string): Responses[number] => ["error", { type
test("prefers the locale a regular user is allowed to read", () => {
const info = interpretAccountInfo([settingsOk("de_DE.UTF-8"), accountOk("fr_FR")]);
assert.equal(info.locale, "de-DE");
assert.equal(info.generation, "0.16+");
});
test("falls back to x:Account when the settings object is forbidden", () => {
@@ -27,22 +27,14 @@ test("falls back to x:Account when the settings object is forbidden", () => {
assert.equal(info.locale, "sr-Latn-RS");
});
test("an older server is recognised by its unknownMethod, and still yields a locale", () => {
const info = interpretAccountInfo([failed("s", "unknownMethod"), accountOk("en_GB")]);
assert.equal(info.generation, "pre-0.16");
assert.equal(info.locale, "en-GB");
});
test("a server answering the new method is 0.16+ even with no locale set", () => {
test("an account with no locale set yields none, rather than a guess", () => {
const info = interpretAccountInfo([["x:AccountSettings/get", { list: [] }, "s"], failed("a", "forbidden")]);
assert.equal(info.generation, "0.16+");
assert.equal(info.locale, null);
});
test("neither answering leaves everything unknown rather than guessing", () => {
const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]);
assert.deepEqual(info, { locale: null, generation: null, edition: null });
assert.deepEqual(interpretAccountInfo([]), { locale: null, generation: null, edition: null });
test("neither answering leaves the locale unknown", () => {
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null });
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null });
});
test("locales that carry no language are dropped, not passed through", () => {
@@ -50,20 +42,18 @@ test("locales that carry no language are dropped, not passed through", () => {
assert.equal(interpretAccountInfo([settingsOk("POSIX")]).locale, null);
});
test("a server that never heard of the Stalwart capability is reported as pre-0.16", async () => {
// 0.16 always advertises urn:stalwart:jmap and nothing older knows it at all,
// so its absence is the answer - and asking anyway would fail the whole
// request on those servers. This is what the live 0.15.5 box hits.
test("a server without the registry is not asked for anything", async () => {
// Sign-in refuses these, so getAccountInfo should never reach the wire for
// one - and must not, since a server that cannot parse `urn:stalwart:jmap`
// fails the whole request rather than the one call.
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
const info = await getAccountInfo("session-pre-016", "Basic x", session as never);
assert.equal(info.generation, "pre-0.16");
assert.equal(info.locale, null);
assert.equal(info.edition, null);
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
assert.deepEqual(info, { locale: null, edition: null });
});
test("no capabilities at all leaves the generation unknown", async () => {
test("no capabilities at all is treated the same way", async () => {
const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never);
assert.equal(info.generation, null);
assert.equal(info.locale, null);
});
/**
@@ -73,9 +63,12 @@ test("no capabilities at all leaves the generation unknown", async () => {
* fixed list that has never carried this capability, in any 0.16.x. It is
* handed out per-account instead, so it lands in `primaryAccounts` and in each
* account's `accountCapabilities`. Looking only at the session level called
* every real 0.16 server pre-0.16, which sent self-service credentials to a
* every real 0.16 server too old, which sent self-service credentials to a
* REST endpoint 0.16 had removed and made the About page report the wrong
* generation.
* thing.
*
* This check now decides whether a sign-in is allowed at all, so getting it
* wrong would lock every user out of a perfectly good server.
*/
const STALWART = "urn:stalwart:jmap";
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
@@ -102,7 +95,7 @@ test("the session level still counts, for a server that ever advertises it there
assert.equal(hasStalwartRegistry({ capabilities: { ...baseCaps, [STALWART]: {} }, accounts: {}, primaryAccounts: {} }), true);
});
test("a server that advertises it nowhere is pre-0.16", () => {
test("a server that advertises it nowhere is one we do not support", () => {
assert.equal(hasStalwartRegistry({ capabilities: baseCaps, accounts: { a1: { accountCapabilities: baseCaps } }, primaryAccounts: { "urn:ietf:params:jmap:mail": "a1" } }), false);
assert.equal(hasStalwartRegistry(undefined), false);
});
@@ -117,15 +110,3 @@ test("a shared account carrying the capability is enough to recognise the server
true,
);
});
test("a locale request that fails does not talk us out of a generation we proved", () => {
// The capability settled it. A forbidden reply costs the locale, nothing more.
const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")], "0.16+");
assert.equal(info.generation, "0.16+");
assert.equal(info.locale, null);
});
test("a server that disowns the method is still older, whatever we came in believing", () => {
const info = interpretAccountInfo([failed("s", "unknownMethod")], "0.16+");
assert.equal(info.generation, "pre-0.16");
});
+58 -20
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 {
@@ -12,6 +12,7 @@ import {
absoluteUpstream,
expandTemplate,
fetchUpstreamSession,
hasStalwartRegistry,
forgetUpstreamSession,
getAccountInfo,
getUpstreamSession,
@@ -25,7 +26,6 @@ import {
createAppPassword,
disableOtp,
enableOtp,
forgetBackend,
getState,
revokeAppPassword,
} from "./account.js";
@@ -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
@@ -143,7 +143,7 @@ export function createApp(): Hono<Env> {
const api = new Hono<Env>();
api.use("*", csrfGuard);
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: "2.0.0" }));
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version }));
api.get("/config", (c) =>
c.json({
@@ -180,6 +180,20 @@ export function createApp(): Hono<Env> {
const authorization = `Basic ${Buffer.from(`${username}:${effectivePassword}`, "utf8").toString("base64")}`;
try {
const upstream = await fetchUpstreamSession(authorization);
// ihasmail requires Stalwart 0.16 or newer. Refuse here, once and
// clearly, rather than signing someone in and letting Files, the account
// locale and self-service credentials each fail in their own way with
// nothing to connect them. The credentials were good, so say so.
if (!hasStalwartRegistry(upstream)) {
return c.json(
{
error: "unsupported_server",
message:
"Your credentials are fine, but this mail server is older than Stalwart 0.16, which ihasmail needs. Upgrade the server, or run the release tagged stalwart-0.15-support.",
},
501,
);
}
loginLimiter.reset(limitKey);
const { cookie, session } = sessions.create({
username,
@@ -192,6 +206,32 @@ export function createApp(): Hono<Env> {
const info = await getAccountInfo(session.id, session.authorization, upstream);
return c.json(localizeSession(upstream, sessionExtras(session, info)));
} catch (err) {
// A rejected sign-in that carried a two-factor code is worth explaining
// rather than calling "invalid credentials", because the credentials are
// very likely fine.
//
// Stalwart accepts a TOTP code only through an OAuth flow -- its own web
// interface is an OAuth client, which is why signing in there works. It
// offers no password grant, so a client holding a username and password
// cannot exchange them plus a code for a token, and the concatenated
// `password$code` form ihasmail sent is not a route the server has. Its
// documented answer for clients like this one is an app password, which
// bypasses TOTP entirely.
//
// ihasmail already relies on that elsewhere: turning 2FA *on* mints an
// app password and moves the session onto it, precisely because a plain
// password stops working from that moment. The sign-in page was the one
// place still pretending otherwise.
if (totp && err instanceof UpstreamError && err.status === 401) {
return c.json(
{
error: "totp_unsupported",
message:
"This mail server does not accept two-factor codes from webmail. Sign in with an app password instead — create one in Stalwart's own settings, under app passwords. Your password and code are probably fine.",
},
401,
);
}
return upstreamFailure(c, err);
}
});
@@ -236,9 +276,8 @@ export function createApp(): Hono<Env> {
// ---------- Self-service credentials ----------
/**
* Password, app passwords and 2FA. These live on the server rather than in
* the browser because the pre-0.16 API is REST rather than JMAP (the browser
* only ever sees /api/jmap), and because changing a credential means
* re-sealing the session cookie that holds it.
* the browser because changing a credential means re-sealing the session
* cookie that holds it, and because the browser only ever sees /api/jmap.
*/
const accountCtx = async (c: Context<Env>) => {
const session = c.get("session");
@@ -264,7 +303,7 @@ export function createApp(): Hono<Env> {
api.get("/account/security", requireSession, async (c) => {
const session = c.get("session");
try {
return c.json(await getState(session.id, await accountCtx(c)));
return c.json(await getState(await accountCtx(c)));
} catch (err) {
return accountFailure(c, err);
}
@@ -284,7 +323,7 @@ export function createApp(): Hono<Env> {
return c.json({ error: "unchanged", message: "The new password matches the old one." }, 400);
}
try {
await changePassword(session.id, await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined });
await changePassword(await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined });
} catch (err) {
return accountFailure(c, err);
}
@@ -300,8 +339,8 @@ export function createApp(): Hono<Env> {
api.get("/account/app-passwords", requireSession, async (c) => {
const session = c.get("session");
try {
const state = await getState(session.id, await accountCtx(c));
return c.json({ appPasswords: state.appPasswords, keyedByName: state.appPasswordsKeyedByName });
const state = await getState(await accountCtx(c));
return c.json({ appPasswords: state.appPasswords });
} catch (err) {
return accountFailure(c, err);
}
@@ -314,7 +353,7 @@ export function createApp(): Hono<Env> {
const description = (body.description ?? "").trim().slice(0, 120);
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
try {
return c.json(await createAppPassword(session.id, await accountCtx(c), { description }));
return c.json(await createAppPassword(await accountCtx(c), { description }));
} catch (err) {
return accountFailure(c, err);
}
@@ -325,7 +364,7 @@ export function createApp(): Hono<Env> {
const body = await readJson<{ id?: string }>(c);
if (!body?.id) return c.json({ error: "bad_request" }, 400);
try {
await revokeAppPassword(session.id, await accountCtx(c), body.id);
await revokeAppPassword(await accountCtx(c), body.id);
return c.json({ ok: true });
} catch (err) {
return accountFailure(c, err);
@@ -366,18 +405,18 @@ export function createApp(): Hono<Env> {
}
let app: { id: string; secret: string } | null = null;
try {
app = await createAppPassword(session.id, ctx, { description: appPasswordName(c) });
app = await createAppPassword(ctx, { description: appPasswordName(c) });
} catch (err) {
// Out of app-password quota, say. 2FA is still worth having; the user
// just has to sign in again afterwards.
console.warn("[ihasmail] could not mint a session app password:", (err as Error).message);
}
try {
await enableOtp(session.id, ctx, { url: body.url, code, current: body.current });
await enableOtp(ctx, { url: body.url, code, current: body.current });
} catch (err) {
if (app) {
// Don't leave a credential behind for a change that never happened.
await revokeAppPassword(session.id, ctx, app.id).catch(() => {});
await revokeAppPassword(ctx, app.id).catch(() => {});
}
return accountFailure(c, err);
}
@@ -398,7 +437,7 @@ export function createApp(): Hono<Env> {
const body = await readJson<{ current?: string; code?: string }>(c);
if (!body?.current || !body.code) return c.json({ error: "bad_request" }, 400);
try {
await disableOtp(session.id, await accountCtx(c), { current: body.current, code: body.code.trim() });
await disableOtp(await accountCtx(c), { current: body.current, code: body.code.trim() });
} catch (err) {
return accountFailure(c, err);
}
@@ -406,7 +445,6 @@ export function createApp(): Hono<Env> {
// the plain password works again now, so put it back.
sessions.reseal(getCookie(c, config.cookieName), body.current);
forgetUpstreamSession(session.id);
forgetBackend(session.id);
return c.json({ ok: true });
});
@@ -578,7 +616,7 @@ function appPasswordName(c: Context): string {
return `${config.appName} (${browser})`;
}
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, generation: null, edition: null }) {
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null }) {
return {
ihasmail: {
appName: config.appName,
@@ -591,7 +629,7 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
/** Locale configured for the account in Stalwart's directory, if readable. */
userLocale: info.locale,
/** What the upstream server would tell us about itself. */
server: { generation: info.generation, edition: info.edition },
server: { edition: info.edition },
},
};
}
+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 });
}
});
+64 -2
View File
@@ -1,6 +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. */
@@ -57,9 +58,68 @@ 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"),
/**
* What this build calls itself: `2.16.57`. Set by the image build from
* `--build-arg IHASMAIL_VERSION`, since `.dockerignore` keeps `.git` out of
* the build context and nothing in there could work it out. A dev checkout
* has git, so it falls back to asking; see `scripts/version.mjs`.
*/
version: resolveVersion(),
/**
* Where this instance's source can be had, shown to everyone who reaches it.
*
@@ -84,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),
+73
View File
@@ -0,0 +1,73 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
/**
* ihasmail requires Stalwart 0.16 or newer. Sign-in is where that is enforced,
* and it matters that it is enforced *there*: the alternative is signing
* someone in and letting Files, the account locale and self-service
* credentials each fail in their own way, with nothing to connect the three or
* to say what the real problem is.
*
* The refusal also has to keep two things apart that look the same from the
* outside. Bad credentials are a 401 the user can fix by typing again; an
* unsupported server is not, and telling someone their password is wrong when
* it is not would send them round in circles.
*/
const PORT = 18799;
process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "demo-password";
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:stalwart:jmap
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-login-guard";
const mock = await import("./mock/index.js");
const { createApp } = await import("./app.js");
const app = createApp();
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
async function login(body: unknown): Promise<{ status: number; body: any; setCookie: string | null }> {
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
const text = await res.text();
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get("set-cookie") };
}
before(() => {
assert.equal(process.env.MOCK_NO_REGISTRY, "1");
});
after(() => {
(mock as { server?: { close(): void } }).server?.close();
});
test("a server without the registry is refused, with good credentials", async () => {
const res = await login({ username: "[email protected]", password: "demo-password" });
assert.equal(res.status, 501);
assert.equal(res.body.error, "unsupported_server");
});
test("the message says the credentials were fine, and names the way out", async () => {
const { body } = await login({ username: "[email protected]", password: "demo-password" });
// Someone hitting this has typed a correct password. Saying so is the
// difference between "upgrade your server" and "try your password again".
assert.match(body.message, /credentials are fine/i);
assert.match(body.message, /0\.16/);
assert.match(body.message, /stalwart-0\.15-support/, "the tag to build from if they cannot upgrade");
});
test("no session is minted for a server we cannot talk to", async () => {
// A cookie here would leave a signed-in session against a server every
// other request is going to fail on.
const res = await login({ username: "[email protected]", password: "demo-password" });
assert.equal(res.setCookie, null);
});
test("bad credentials on such a server are still a 401, not the server error", async () => {
// The upstream session request fails first, and that answer is the honest
// one: we never got far enough to learn what the server supports.
const res = await login({ username: "[email protected]", password: "wrong-password" });
assert.equal(res.status, 401);
assert.notEqual(res.body.error, "unsupported_server");
});
+263 -88
View File
@@ -10,14 +10,12 @@ import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
const PORT = Number(process.env.MOCK_PORT ?? 8788);
/**
* Which Stalwart generation to impersonate. "0.16" (the default) has the
* registry — the `x:` methods, `nodeType` on FileNode, the finer-grained
* rights. "0.15" is the older shape, and differs in ways that mostly do not
* announce themselves: its FileNode/query cannot see directories at all, it
* refuses a `using` naming a capability it does not know, and self-service
* credentials live behind a REST endpoint instead.
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested
* against a server ihasmail does not support. This is only that: the rest of
* the mock still behaves like 0.16. Emulating 0.15 properly went with the
* support for it.
*/
const LEGACY = process.env.MOCK_STALWART === "0.15";
const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1";
/**
* Stalwart advertises FUTURERELEASE in the session but only honours it when
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
@@ -28,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";
@@ -46,12 +51,24 @@ const state = { n: 1 };
const nextState = () => String(state.n++);
/* ---------- data ---------- */
/*
* The names are Stalwart's own defaults, which follow the Exchange convention:
* "Deleted Items" and "Sent Items", not "Trash" and "Sent". The mock used the
* short forms, so anything built from a folder's name read differently here
* than in production -- "Empty Trash" against the mock, "Empty Deleted Items"
* against a real server -- and every screenshot in the README showed a folder
* list no user has. The role is what the client branches on; the name is only
* ever displayed, which is exactly why it has to look right.
*/
/** Push subscriptions, as a fresh account has none. */
const pushSubscriptions: Obj[] = [];
const mailboxes: Obj[] = [
mb("inbox", "Inbox", "inbox"),
mb("drafts", "Drafts", "drafts"),
mb("sent", "Sent", "sent"),
mb("sent", "Sent Items", "sent"),
mb("junk", "Junk Mail", "junk"),
mb("trash", "Trash", "trash"),
mb("trash", "Deleted Items", "trash"),
mb("archive", "Archive", "archive"),
mb("work", "Work", null),
mb("work-inv", "Invoices", null, "work"),
@@ -127,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`;
@@ -143,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[] = [];
@@ -155,24 +197,45 @@ 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() {
// 0.16 split what used to be a single mayWrite into four.
return LEGACY
? { mayRead: true, mayWrite: true, mayShare: true }
: { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
}
function recount() {
@@ -316,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;
@@ -394,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) => {
@@ -409,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 }>) ?? {};
@@ -430,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 }); },
@@ -472,6 +576,85 @@ const handlers: Record<string, Handler> = {
state.n++;
return setResp({ updated: { singleton: null } });
},
/*
* Push subscriptions. The JMAP half can be modelled; delivery cannot -- that
* runs through the browser vendor's real push service, so nothing local will
* ever make a notification appear.
*
* What is worth reproducing is the handshake, because it is the part that
* fails quietly: a subscription is created unverified and stays silent until
* the client echoes back a code the server pushed. A mock that marked one
* verified on creation would let a client ship without ever implementing
* that, and the symptom in production is "registered, and no notifications".
*/
"PushSubscription/get": (a) => {
const ids = (a.ids as string[] | null) ?? pushSubscriptions.map((s) => s.id as string);
const list = pushSubscriptions.filter((s) => ids.includes(s.id as string));
// `keys` is write-only in JMAP: the server never hands it back.
return { accountId: ACCOUNT, state: String(state.n), list: list.map((s) => { const { keys: _drop, ...rest } = s; return rest; }), notFound: ids.filter((i) => !list.some((s) => s.id === i)) };
},
"PushSubscription/set": (a) => {
const created: Obj = {};
const notCreated: Obj = {};
const updated: Obj = {};
const notUpdated: Obj = {};
const destroyed: string[] = [];
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const o = obj as Obj;
const keys = (o.keys ?? {}) as Obj;
// Stalwart 0.16 was fixed to accept the unpadded base64url the W3C Push
// API produces; padding it would be the client inventing a shape.
for (const k of ["p256dh", "auth"]) {
const v = String(keys[k] ?? "");
if (!v) { notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `Missing ${k}.` }; break; }
if (v.includes("=") || v.includes("+") || v.includes("/")) {
notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `${k} must be unpadded base64url.` };
break;
}
}
if (notCreated[cid]) continue;
if (!String(o.url ?? "").startsWith("https://")) {
notCreated[cid] = { type: "invalidProperties", properties: ["url"], description: "Push endpoint must be https." };
continue;
}
// A filter condition with a null value is not a filter -- the real server
// answers "Invalid filter" and refuses the whole subscription. ihasmail
// shipped `inMailbox: null` meaning "the inbox", which meant nothing at
// all here, and the mock accepted it happily. It does not any more.
const badFilter = Object.entries((o.emailPush ?? {}) as Obj).find(([, cfg]) => {
const f = ((cfg as Obj)?.filter ?? {}) as Obj;
return Object.values(f).some((v) => v === null || v === undefined);
});
if (badFilter) {
notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." };
continue;
}
// One per device: re-subscribing replaces rather than accumulates.
const deviceId = String(o.deviceClientId ?? "");
const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId);
if (clash >= 0) pushSubscriptions.splice(clash, 1);
const id = `ps${randomUUID().slice(0, 6)}`;
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, emailPush: o.emailPush ?? null, expires: null, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` });
created[cid] = { id, expires: null };
state.n++;
}
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
const s = pushSubscriptions.find((x) => x.id === id);
if (!s) { notUpdated[id] = { type: "notFound" }; continue; }
const code = (patch as Obj).verificationCode;
if (code !== undefined) {
if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; }
s.verified = true;
}
updated[id] = null;
state.n++;
}
for (const id of (a.destroy as string[]) ?? []) {
const i = pushSubscriptions.findIndex((x) => x.id === id);
if (i >= 0) { pushSubscriptions.splice(i, 1); destroyed.push(id); state.n++; }
}
return setResp({ created, notCreated, updated, notUpdated, destroyed });
},
"x:AppPassword/get": (a) => genericGet(account.appPasswords)(a),
"x:AppPassword/set": (a) => {
const created: Obj = {};
@@ -589,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.
@@ -608,42 +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) ?? {};
if (LEGACY) {
// Sorting is refused outright, and isTopLevel / nodeType are not filters
// this generation knows.
if (a.sort) throw new MethodError("unsupportedSort", "Sorting is not supported on FileNode");
if ("isTopLevel" in f || "nodeType" in f) throw new MethodError("unsupportedFilter", "Unsupported filter");
}
let list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true));
// The pre-0.16 query masks its results to non-containers, so a directory
// never comes back — with nothing to say it was left out.
if (LEGACY) list = list.filter((n) => n.nodeType !== "directory");
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": (a) => {
const res = genericGet(fileNodes)(a);
// nodeType does not exist before 0.16; the shape is all the client gets.
if (LEGACY) res.list = (res.list as Obj[]).map((n) => { const { nodeType: _drop, ...rest } = n; return rest; });
return res;
},
"FileNode/get": (a) => genericGet(nodesFor(a.accountId))(a),
"FileNode/set": (a) => {
if (LEGACY) {
for (const obj of [...Object.values((a.create as Obj) ?? {}), ...Object.values((a.update as Obj) ?? {})]) {
if (obj && typeof obj === "object" && "nodeType" in (obj as Obj)) {
return setResp({ notCreated: Object.fromEntries(Object.keys((a.create as Obj) ?? {}).map((k) => [k, { type: "invalidProperties", properties: ["nodeType"], description: "Invalid property." }])), notUpdated: Object.fromEntries(Object.keys((a.update as Obj) ?? {}).map((k) => [k, { type: "invalidProperties", properties: ["nodeType"], description: "Invalid property." }])) });
}
}
}
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";
@@ -684,9 +868,22 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
}
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: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": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) } } },
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(LEGACY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
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": {} },
/*
* 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/`,
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
@@ -696,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);
@@ -709,37 +914,8 @@ export const server = createServer(async (req, res) => {
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify(session()));
}
// Before 0.16, self-service credentials are a REST endpoint rather than
// registry objects: GET reports the state, POST takes a list of actions.
if (LEGACY && url.pathname === "/api/account/auth") {
if (req.method === "GET") {
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ data: { otpEnabled: Boolean(account.otpUrl), appPasswords: account.appPasswords.map((a) => a.description) } }));
}
if (req.method === "POST") {
const actions = JSON.parse((await readBody(req)).toString()) as { type: string; password?: string; url?: string | null; name?: string }[];
// Password and OTP changes are only accepted over Basic auth.
if (actions.some((a) => ["setPassword", "enableOtpAuth", "disableOtpAuth"].includes(a.type)) && !(req.headers.authorization ?? "").startsWith("Basic ")) {
res.writeHead(400, { "content-type": "application/json" });
return res.end(JSON.stringify({ error: "unauthorized", details: "Password changes only allowed using Basic auth" }));
}
for (const a of actions) {
if (a.type === "setPassword") account.password = a.password ?? account.password;
else if (a.type === "enableOtpAuth") account.otpUrl = a.url ?? null;
else if (a.type === "disableOtpAuth") account.otpUrl = null;
else if (a.type === "addAppPassword") account.appPasswords.push({ id: `ap${randomUUID().slice(0, 6)}`, description: a.name ?? "App password", secret: a.password ?? "", createdAt: new Date().toISOString(), expiresAt: null });
else if (a.type === "removeAppPassword") {
const i = account.appPasswords.findIndex((p) => p.description === a.name);
if (i >= 0) account.appPasswords.splice(i, 1);
}
}
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ data: null }));
}
}
// 0.16's account info endpoint; the only place a server reports its edition.
if (!LEGACY && url.pathname === "/api/account" && req.method === "GET") {
// The account info endpoint; the only place a server reports its edition.
if (url.pathname === "/api/account" && req.method === "GET") {
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE }));
}
@@ -763,7 +939,7 @@ export const server = createServer(async (req, res) => {
for (const [name, rawArgs, id] of body.methodCalls) {
const h = handlers[name];
// The registry, and every x: method with it, arrived in 0.16.
if (!h || (LEGACY && name.startsWith("x:"))) { responses.push(["error", { type: "unknownMethod" }, id]); continue; }
if (!h) { responses.push(["error", { type: "unknownMethod" }, id]); continue; }
try {
const args = resolveRefs(rawArgs, responses, creations);
enforceLimits(name, args);
@@ -810,7 +986,6 @@ export const server = createServer(async (req, res) => {
res.end(JSON.stringify({ error: "not found" }));
}).listen(PORT, "127.0.0.1", () => {
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
console.log(`[mock-stalwart] impersonating Stalwart ${LEGACY ? "0.15 (pre-registry)" : "0.16+"}`);
console.log(`[mock-stalwart] run the app with: STALWART_URL=http://127.0.0.1:${PORT} npm run dev`);
});
-34
View File
@@ -63,37 +63,3 @@ test("normalizes Stalwart account locales to BCP-47 tags", () => {
assert.equal(normalizeLocale({ locale: "de_DE" }), null);
assert.equal(normalizeLocale("../etc/passwd"), null);
});
test("generated app passwords are unbiased and long enough", async () => {
const { readableSecret } = await import("./account.js");
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789";
const counts = new Map<string, number>();
let samples = 0;
for (let i = 0; i < 2000; i++) {
const secret = readableSecret();
assert.match(secret, /^[a-z2-9]{5}-[a-z2-9]{5}-[a-z2-9]{5}-[a-z2-9]{5}$/, secret);
for (const ch of secret.replace(/-/g, "")) {
counts.set(ch, (counts.get(ch) ?? 0) + 1);
samples++;
}
}
assert.equal(samples, 2000 * 20);
/*
* `% 33` over a byte maps 25 characters onto 8 values each and the last 8
* onto 7, so the digits — the tail of the alphabet — would come up about
* 7/8 as often as they should. Testing each character on its own cannot see
* a skew that size against the noise, so weigh the whole tail at once:
* uniform puts 8/33 of the draw there, the biased version 7/8 of that, and
* over 40,000 draws the two are more than four standard deviations apart.
*/
const tail = alphabet.slice(25); // "23456789"
const tailSeen = [...tail].reduce((n, ch) => n + (counts.get(ch) ?? 0), 0);
const p = tail.length / alphabet.length;
const expected = samples * p;
const sigma = Math.sqrt(samples * p * (1 - p));
assert.ok(
Math.abs(tailSeen - expected) < 4 * sigma,
`digits appeared ${tailSeen} times, expected ~${Math.round(expected)} (sigma ${sigma.toFixed(1)}) - modulo bias?`,
);
});
+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;
+20 -41
View File
@@ -78,10 +78,14 @@ const JMAP_CORE = "urn:ietf:params:jmap:core";
* builds that list from a fixed set that has never included this capability;
* it hands it out per-account instead, so it turns up in `primaryAccounts` and
* in each account's `accountCapabilities`. Checking only the session level
* therefore reports every real 0.16 server as pre-0.16 — which routed
* therefore reported every real 0.16 server as older than 0.16 — which routed
* self-service credentials to a REST endpoint 0.16 had removed, and told the
* About page the wrong thing. The session level is still checked last, in case
* a later release advertises it there as well.
*
* This is now what sign-in tests to decide whether a server is supported at
* all, so the same mistake would lock every user out of a working server
* rather than merely misroute them.
*/
export function hasStalwartRegistry(session: Pick<UpstreamSession, "capabilities" | "accounts" | "primaryAccounts"> | undefined): boolean {
if (!session) return false;
@@ -96,22 +100,13 @@ export function hasStalwartRegistry(session: Pick<UpstreamSession, "capabilities
export interface AccountInfo {
/** BCP-47 tag configured for the account, or null if unreadable. */
locale: string | null;
/**
* Which generation of Stalwart's API answered: "0.16+" has the registry
* (`x:AccountSettings`), older builds only have `x:Account`. Null when the
* server is not Stalwart or told us nothing.
*/
generation: "0.16+" | "pre-0.16" | null;
/** "oss" | "community" | "enterprise", where the server reports it. */
edition: string | null;
}
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
const INFO_CACHE_MS = 30 * 60_000;
const EMPTY_INFO: AccountInfo = { locale: null, generation: null, edition: null };
/** A server that has never heard of the registry: nothing to read, but dated. */
const PRE_REGISTRY_INFO: AccountInfo = { locale: null, generation: "pre-0.16", edition: null };
const REGISTRY_INFO: AccountInfo = { locale: null, generation: "0.16+", edition: null };
const EMPTY_INFO: AccountInfo = { locale: null, edition: null };
/**
* glibc modifiers that name a script rather than a dialect or a currency:
@@ -165,13 +160,9 @@ export function normalizeLocale(raw: unknown): string | null {
* tells us which generation we are talking to.
*/
async function fetchAccountInfo(authorization: string, session: UpstreamSession): Promise<AccountInfo> {
// Every 0.16 build advertises urn:stalwart:jmap, and no earlier one knows it
// at all, so its absence already answers the question — and asking anyway
// would fail the whole request, since those servers reject a `using` naming
// a capability they cannot parse.
// A session with no capabilities at all is not one we can read anything from.
if (!session.capabilities) return EMPTY_INFO;
if (!hasStalwartRegistry(session)) return PRE_REGISTRY_INFO;
// Sign-in refuses a server without the registry, so this should not happen —
// but a session we cannot read capabilities from is not one to ask.
if (!session.capabilities || !hasStalwartRegistry(session)) return EMPTY_INFO;
const accountId =
session.primaryAccounts?.[STALWART_CAP] ??
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
@@ -189,35 +180,23 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
}),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
// The registry capability already settled the generation. A locale request
// that fails — a permission we lack, a hiccup upstream — can only cost us the
// locale; it must not talk us out of what we know.
if (!res.ok) return REGISTRY_INFO;
// A locale request that fails — a permission we lack, a hiccup upstream —
// costs us the locale and nothing else.
if (!res.ok) return EMPTY_INFO;
const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
return interpretAccountInfo(body.methodResponses ?? [], "0.16+");
return interpretAccountInfo(body.methodResponses ?? []);
}
/**
* Read the pair of replies: prefer the locale from `x:AccountSettings`, fall
* back to `x:Account` for servers (or permissions) where only that one works,
* and note which generation answered.
* Read the pair of replies: prefer the locale from `x:AccountSettings`, whose
* permission the built-in user role has, and fall back to `x:Account` for the
* accounts allowed the admin-only `sysAccountGet` instead. Both are 0.16
* methods; this is a permissions fallback, not a version one.
*/
export function interpretAccountInfo(
responses: [string, Record<string, unknown>, string][],
known: AccountInfo["generation"] = null,
): AccountInfo {
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
const settings = responses.find((r) => r[2] === "s");
const account = responses.find((r) => r[2] === "a");
// Only 0.16+ knows the method at all; older builds cannot even parse the name.
// `known` is what the session capability already proved, and outranks a reply
// that merely refused us.
const generation: AccountInfo["generation"] =
settings && settings[0] !== "error"
? "0.16+"
: (settings?.[1] as { type?: string } | undefined)?.type === "unknownMethod"
? "pre-0.16"
: known;
return { locale: localeOf(settings) ?? localeOf(account), generation, edition: null };
return { locale: localeOf(settings) ?? localeOf(account), edition: null };
}
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
@@ -251,7 +230,7 @@ export async function getAccountInfo(sessionId: string, authorization: string, s
let info = EMPTY_INFO;
try {
info = await fetchAccountInfo(authorization, session);
if (info.generation === "0.16+") info = { ...info, edition: await fetchEdition(authorization) };
info = { ...info, edition: await fetchEdition(authorization) };
} catch {
/* all of this is a nicety - never fail the session over it */
}
+11 -2
View File
@@ -4,8 +4,17 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#0f766e" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0b1220" media="(prefers-color-scheme: dark)" />
<!--
One tag, no media query: applyTheme() keeps it in step with the chosen
theme, which a media query cannot do — it only knows what the OS prefers,
not what the user picked here. There used to be two, both with media
attributes, which meant the selector in applyTheme (:not([media])) matched
neither and the colour never moved off whatever the OS implied.
The initial value is the default theme's background, so the browser chrome
is right from the first paint rather than only once JS has run.
-->
<meta name="theme-color" content="#0d2430" />
<meta name="description" content="ihasmail - fast, friendly JMAP webmail for Stalwart" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ihasmail/web",
"version": "2.0.0",
"version": "2.16.0",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+96 -2
View File
@@ -1,5 +1,7 @@
/* ihasmail service worker: app-shell caching for installability & fast loads.
API requests are never cached. */
/* ihasmail service worker.
Two jobs: app-shell caching for installability and fast loads (API requests
are never cached), and Web Push, which is the only part of ihasmail that runs
when no tab is open. */
const VERSION = "ihasmail-v2";
const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"];
@@ -39,3 +41,95 @@ self.addEventListener("fetch", (event) => {
}
event.respondWith(fetch(req).catch(() => caches.match(req)));
});
/* ------------------------------------------------------------------ */
/* Web Push */
/* ------------------------------------------------------------------ */
/*
* Stalwart signs with VAPID and pushes straight to the browser's push service;
* nothing here talks to ihasmail's server. The payload is an EmailPush object
* (draft-ietf-jmap-emailpush) carrying enough of the message to show a useful
* notification without a round-trip — which matters, because when this fires
* there may be no session to make one with.
*
* A JMAP subscription also delivers a PushVerification first, and stays silent
* until the client echoes its code back. That cannot be done from here (no
* credentials), so it is stashed for a tab to collect and confirm.
*/
const VERIFY_KEY = "ihasmail-push-verification";
function textOf(email) {
const from = email?.from?.[0];
const who = from?.name || from?.email || "New message";
const what = email?.subject || "(no subject)";
return { title: who, body: what, preview: email?.preview || "" };
}
self.addEventListener("push", (event) => {
let data = null;
try {
data = event.data ? event.data.json() : null;
} catch {
/* not JSON: fall through to the generic notification below */
}
// The verification handshake. No credentials here, so hand it to a tab —
// an open one now, or the next one to start.
if (data && data["@type"] === "PushVerification") {
event.waitUntil((async () => {
const payload = { id: data.pushSubscriptionId, code: data.verificationCode };
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
if (clients.length) {
for (const c of clients) c.postMessage({ type: "push-verification", ...payload });
} else {
const cache = await caches.open(VERSION);
await cache.put(VERIFY_KEY, new Response(JSON.stringify(payload)));
}
})());
return;
}
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
event.waitUntil((async () => {
if (!emails.length) {
// A StateChange, or a payload too large to carry the message. Say
// something true rather than inventing a sender.
await self.registration.showNotification("New mail", {
icon: "/img/icon-192.png", badge: "/img/favicon-64.png", tag: "ihasmail-mail", data: { url: "/mail" },
});
return;
}
// One notification per message, collapsing repeats of the same message by
// tag so a re-push does not stack.
for (const email of emails.slice(0, 5)) {
const { title, body, preview } = textOf(email);
await self.registration.showNotification(title, {
body: preview ? `${body}\n${preview}` : body,
icon: "/img/icon-192.png",
badge: "/img/favicon-64.png",
tag: `ihasmail-${email.id || body}`,
data: { url: email.id ? `/mail/inbox/${email.id}` : "/mail" },
});
}
})());
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/mail";
event.waitUntil((async () => {
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
// Reuse a tab if one is open rather than piling up windows.
for (const c of clients) {
if (new URL(c.url).origin === self.location.origin) {
await c.focus();
if ("navigate" in c) await c.navigate(url).catch(() => {});
return;
}
}
await self.clients.openWindow(url);
})());
});
+4
View File
@@ -19,6 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock";
import { setUnreadBadge } from "@/lib/notify";
import { useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
import { listenForVerification } from "@/lib/webpushEnable";
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
@@ -87,6 +88,9 @@ function AuthedApp() {
void useFiles.getState().init();
void useSieve.getState().init();
push.start();
// A push subscription stays silent until its verification code is echoed
// back, and the code may have arrived while no tab was open.
listenForVerification();
const pending = new Map<string, Set<string>>();
let timer: number | null = null;
const unsub = push.subscribe((acct, type) => {
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
/**
* The build's version string, substituted by Vite at build time — there is no
* git to ask from inside a browser, or inside the Docker build. See
* `scripts/version.mjs`.
*/
declare const __IHASMAIL_VERSION__: string;
+1 -2
View File
@@ -36,8 +36,7 @@ export interface JmapSession {
userLocale?: string | null;
/** What the upstream server was willing to say about itself. */
server?: {
/** Which API generation answered: Stalwart publishes no version number. */
generation?: "0.16+" | "pre-0.16" | null;
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
edition?: string | null;
};
};
@@ -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);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { canEmpty, emptyLabel } from "@/lib/emptyFolder";
import type { MailboxRole } from "@/jmap/types";
/**
* Emptying destroys everything in a folder in one action, with no undo and no
* trip through Deleted Items. Which folders may be emptied is therefore a
* safety property, not a presentation one — the store enforces it too, and
* these pin the half the menus decide.
*/
describe("which folders may be emptied", () => {
it("allows exactly Deleted Items and Junk Mail", () => {
expect(canEmpty("trash")).toBe(true);
expect(canEmpty("junk")).toBe(true);
});
it("refuses folders holding mail someone meant to keep", () => {
const keep: MailboxRole[] = ["inbox", "archive", "sent", "drafts", "all", "flagged", "important", "subscribed"];
for (const role of keep) expect(canEmpty(role), String(role)).toBe(false);
});
it("refuses a plain folder, which has no role at all", () => {
expect(canEmpty(null)).toBe(false);
expect(canEmpty(undefined)).toBe(false);
});
});
describe("what the action is called", () => {
it("says what it does to spam, rather than naming the folder", () => {
// "Delete all spam" is what this is called everywhere else; "Empty Junk
// Mail" would be accurate and still leave people hunting for it.
expect(emptyLabel({ name: "Junk Mail", role: "junk" })).toBe("Delete all spam");
expect(emptyLabel({ name: "Spam", role: "junk" })).toBe("Delete all spam");
});
it("names the folder for Deleted Items, whatever the server calls it", () => {
expect(emptyLabel({ name: "Deleted Items", role: "trash" })).toBe("Empty Deleted Items");
expect(emptyLabel({ name: "Trash", role: "trash" })).toBe("Empty Trash");
});
});
-161
View File
@@ -1,161 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { client } from "@/jmap/client";
import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "../filenode";
import type { FileNode, JmapSession } from "@/jmap/types";
/**
* `nodeType` arrived in Stalwart 0.16. Sending it to an older server fails the
* whole create with `invalidProperties (nodeType)` — which is what uploading a
* file or making a folder hit on the live 0.15.5 box. Those servers tell a file
* from a directory by whether it carries file properties at all.
*/
function session(caps: string[]): JmapSession {
return { capabilities: Object.fromEntries(caps.map((c) => [c, {}])), accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession;
}
const NEW_SERVER = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:filenode", "urn:stalwart:jmap"];
const OLD_SERVER = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:filenode"];
/**
* The session a real Stalwart 0.16 sends: `urn:stalwart:jmap` is handed out
* per-account and never appears in the session-level capabilities, so a client
* that only checks there drops every 0.16 server onto the older code path.
*/
function realStalwartSession(): JmapSession {
return {
capabilities: Object.fromEntries(OLD_SERVER.map((c) => [c, {}])),
accounts: { a1: { accountCapabilities: { "urn:ietf:params:jmap:filenode": {}, "urn:stalwart:jmap": {} } } },
primaryAccounts: { "urn:stalwart:jmap": "a1" },
state: "s",
} as unknown as JmapSession;
}
afterEach(() => {
client.session = null;
});
describe("on Stalwart 0.16 and newer", () => {
it("uses nodeType everywhere", () => {
client.session = session(NEW_SERVER);
expect(supportsNodeType()).toBe(true);
expect(fileNodeProps()).toContain("nodeType");
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail", nodeType: "directory" });
expect(fileCreate("d1", "logo.png", "b1", "image/png")).toEqual({ parentId: "d1", name: "logo.png", blobId: "b1", type: "image/png", nodeType: "file" });
});
it("leaves what the server reported alone", () => {
client.session = session(NEW_SERVER);
const nodes = [{ id: "1", name: "x", nodeType: "directory" }] as Partial<FileNode>[];
expect(normalizeFileNodes(nodes)).toEqual(nodes);
});
});
describe("on a real 0.16 session, which advertises per-account only", () => {
it("is recognised as 0.16 even though the session capabilities do not say so", () => {
client.session = realStalwartSession();
expect(client.hasCapability("urn:stalwart:jmap")).toBe(false);
expect(supportsNodeType()).toBe(true);
expect(queryOmitsDirectories()).toBe(false);
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail", nodeType: "directory" });
});
});
describe("on Stalwart before 0.16", () => {
it("never mentions nodeType, in creates or in requested properties", () => {
client.session = session(OLD_SERVER);
expect(supportsNodeType()).toBe(false);
expect(fileNodeProps()).not.toContain("nodeType");
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail" });
expect(JSON.stringify(fileCreate("d1", "logo.png", "b1", "image/png"))).not.toContain("nodeType");
});
it("keeps a directory free of file properties, which is what makes it one", () => {
client.session = session(OLD_SERVER);
const dir = directoryCreate(null, "ihasmail");
// Setting blobId, size or type — even to null — would make this a file.
expect(dir).not.toHaveProperty("blobId");
expect(dir).not.toHaveProperty("size");
expect(dir).not.toHaveProperty("type");
});
it("still sends what a file needs", () => {
client.session = session(OLD_SERVER);
expect(fileCreate("d1", "logo.png", "b1", "image/png")).toEqual({ parentId: "d1", name: "logo.png", blobId: "b1", type: "image/png" });
});
it("works out nodeType from the file properties, so folders stay folders", () => {
client.session = session(OLD_SERVER);
const out = normalizeFileNodes([
{ id: "1", name: "Documents", blobId: null, size: null, type: null },
{ id: "2", name: "notes.txt", blobId: "b1", size: 11, type: "text/plain" },
{ id: "3", name: "empty.txt", blobId: "b2", size: 0, type: null },
] as Partial<FileNode>[]);
expect(out.map((n) => n.nodeType)).toEqual(["directory", "file", "file"]);
});
it("does not overwrite a nodeType that did come back", () => {
client.session = session(OLD_SERVER);
const out = normalizeFileNodes([{ id: "1", name: "x", nodeType: "symlink", blobId: "b1" }] as Partial<FileNode>[]);
expect(out[0]!.nodeType).toBe("symlink");
});
});
it("assumes the older shape when there is no session yet", () => {
client.session = null;
expect(supportsNodeType()).toBe(false);
});
/**
* Rights were split up in 0.16. Before that a node carried mayRead / mayWrite /
* mayShare, with mayWrite covering everything the newer release names
* separately — so Rename and Delete sat permanently greyed out, doing nothing
* and saying nothing.
*/
describe("rights on a pre-0.16 server", () => {
const oldRights = (mayWrite: boolean) => ({ mayRead: true, mayWrite, mayShare: false });
it("widens mayWrite into the rights the UI gates on", () => {
client.session = session(OLD_SERVER);
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: oldRights(true) }] as unknown as Partial<FileNode>[]);
expect(node!.myRights).toMatchObject({ mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: false });
});
it("does not hand out rights the server withheld", () => {
client.session = session(OLD_SERVER);
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: oldRights(false) }] as unknown as Partial<FileNode>[]);
expect(node!.myRights).toMatchObject({ mayRename: false, mayDelete: false, mayModifyContent: false });
});
it("leaves rights that already use the newer names untouched", () => {
client.session = session(OLD_SERVER);
const newer = { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: false, mayModifyContent: true, mayShare: true };
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: newer }] as unknown as Partial<FileNode>[]);
expect(node!.myRights).toEqual(newer);
});
it("copes with a node that reported no rights at all", () => {
client.session = session(OLD_SERVER);
const [node] = normalizeFileNodes([{ id: "1", name: "x" }] as Partial<FileNode>[]);
expect(node!.myRights).toBeUndefined();
expect(node!.nodeType).toBe("directory");
});
});
/**
* Before 0.16, FileNode/query masks its results with `document_ids(false)` —
* only resources that are *not* containers. It therefore returns files and
* never folders, with no error to explain the omission: a folder created there
* exists but never comes back in a listing. FileNode/get carries no such mask.
*/
describe("directory-blind query", () => {
it("is worked around on older servers", () => {
client.session = session(OLD_SERVER);
expect(queryOmitsDirectories()).toBe(true);
});
it("is not worked around where query can see folders", () => {
client.session = session(NEW_SERVER);
expect(queryOmitsDirectories()).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,75 @@
import { describe, expect, it } from "vitest";
/**
* Issue #71, both halves of it, reduced to the arithmetic they turn on.
*
* After deleting a row from the keyboard, `focusId` used to keep pointing at
* the row that had gone. Two things fell out of that:
*
* - `targetIds()` falls back to the focused id, so the next `#` re-targeted
* the deleted message. The optimistic update had already moved it into
* Deleted Items, so it looked like a permanent delete and raised a
* confirmation the user had switched off.
* - `moveFocus` read `ids.indexOf(focusId)` as -1 and treated that as
* "before the first row", so `k` clamped to the top of the list.
*
* Clicking was unaffected: it sets focus to a row that exists. That is why it
* only ever happened from the keyboard.
*/
/** Where focus lands after the row at `wasAt` is removed. */
function focusAfterRemove(freshIds: string[], wasAt: number, autoAdvance: "newer" | "older" | "list"): string | null {
if (!freshIds.length) return null;
if (wasAt < 0) return undefined as unknown as string;
const want = autoAdvance === "newer" ? wasAt - 1 : wasAt;
return freshIds[Math.max(0, Math.min(want, freshIds.length - 1))] ?? null;
}
/** What moveFocus resolves to, given a focus id that may no longer exist. */
function nextIndex(ids: string[], focus: string | null, listIndex: number, delta: number): number {
const fromFocus = focus ? ids.indexOf(focus) : -1;
const cur = fromFocus >= 0 ? fromFocus : listIndex;
return Math.max(0, Math.min(ids.length - 1, (cur < 0 ? (delta > 0 ? -1 : 0) : cur) + delta));
}
describe("focus after deleting a row", () => {
const after = ["b", "c", "d"]; // "a" was at 0 and has gone
it("lands on the row that slid into the gap", () => {
expect(focusAfterRemove(after, 0, "older")).toBe("b");
});
it("lands on the row above when auto-advance is set to newer", () => {
// deleted "c" at index 2; newer means the one before it
expect(focusAfterRemove(["a", "b", "d"], 2, "newer")).toBe("b");
});
it("does not run off the end when the last row was deleted", () => {
expect(focusAfterRemove(["a", "b"], 2, "older")).toBe("b");
});
it("clears focus when the list is now empty", () => {
expect(focusAfterRemove([], 0, "older")).toBeNull();
});
});
describe("moving focus when the focused row has gone", () => {
const ids = ["b", "c", "d"];
it("no longer sends k to the top of the list", () => {
// The regression: focus is on the deleted "a", the list says we were at 1.
expect(nextIndex(ids, "a", 1, -1)).toBe(0);
// …and with focus repaired to a real row, k moves by one as it should.
expect(ids[nextIndex(ids, "c", 1, -1)]).toBe("b");
});
it("moves by one from a row that exists, in both directions", () => {
expect(ids[nextIndex(ids, "c", 1, 1)]).toBe("d");
expect(ids[nextIndex(ids, "b", 0, 1)]).toBe("c");
});
it("stops at the ends rather than wrapping", () => {
expect(ids[nextIndex(ids, "b", 0, -1)]).toBe("b");
expect(ids[nextIndex(ids, "d", 2, 1)]).toBe("d");
});
});
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { isAlwaysVisible, visibleIdentities } from "@/lib/identityVisibility";
/**
* Issue #73: a unique address per service, on a server with an alias domain,
* gives every local part twice and a compose picker nobody can use — while only
* a handful are ever sent from.
*
* The interesting cases are not the hiding. They are the three refusals, all of
* which exist because a sender picker with nothing usable in it is worse than a
* cluttered one.
*/
const ids = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `i${i + 1}`, email: `a${i + 1}@example.com` }));
describe("hiding identities from the picker", () => {
it("removes the hidden ones", () => {
expect(visibleIdentities(ids(4), ["i2", "i4"]).map((i) => i.id)).toEqual(["i1", "i3"]);
});
it("changes nothing when none are hidden", () => {
const all = ids(3);
expect(visibleIdentities(all, [])).toBe(all);
});
});
describe("what it refuses to hide", () => {
it("keeps the identity the draft is already using", () => {
// Otherwise the select has no matching option and the From line moves
// under the writer.
expect(visibleIdentities(ids(3), ["i2"], ["i2"]).map((i) => i.id)).toEqual(["i1", "i2", "i3"]);
});
it("keeps the default, which a new draft starts on", () => {
expect(visibleIdentities(ids(3), ["i1", "i3"], [null, "i1"]).map((i) => i.id)).toEqual(["i1", "i2"]);
});
it("shows everything rather than nothing when all are hidden", () => {
const all = ids(3);
expect(visibleIdentities(all, ["i1", "i2", "i3"]).map((i) => i.id)).toEqual(["i1", "i2", "i3"]);
});
it("ignores an id for an identity that no longer exists", () => {
// A deleted identity leaves its id behind in the setting; it must not
// silently hide anything else or empty the list.
expect(visibleIdentities(ids(2), ["gone"]).map((i) => i.id)).toEqual(["i1", "i2"]);
});
it("tolerates nulls among the ids to keep", () => {
expect(visibleIdentities(ids(2), ["i1"], [null, undefined]).map((i) => i.id)).toEqual(["i2"]);
});
});
describe("what the settings row may offer", () => {
it("refuses to offer hiding for an always-visible identity", () => {
expect(isAlwaysVisible("i1", ["i1"])).toBe(true);
expect(isAlwaysVisible("i2", ["i1"])).toBe(false);
expect(isAlwaysVisible("i2", [null, undefined])).toBe(false);
});
});
@@ -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();
});
});
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, toggleTarget, useSettings, type Theme } from "@/store/settings";
import { loadJson, saveJson } from "@/lib/storage";
/**
* "ihasmail" is a dark theme wearing ihasmail.org's palette. Everything that
* asks "is this dark?" has to say yes for it — the top-bar toggle picks its
* icon from the answer, and the message frame decides whether mail sits on a
* light card or follows the app. A theme that painted dark while reporting
* light would show a sun icon on a dark screen and light-card mail on it.
*/
describe("which themes paint dark", () => {
it("counts ihasmail as dark, regardless of the OS", () => {
expect(isDarkTheme("ihasmail", false)).toBe(true);
expect(isDarkTheme("ihasmail", true)).toBe(true);
});
it("still resolves the ordinary three the way it always did", () => {
expect(isDarkTheme("dark", false)).toBe(true);
expect(isDarkTheme("light", true)).toBe(false);
expect(isDarkTheme("system", true)).toBe(true);
expect(isDarkTheme("system", false)).toBe(false);
});
it("treats a missing OS preference as light, not as unknown", () => {
// matchMedia is absent in some embeddings; the default must not read dark.
expect(isDarkTheme("system")).toBe(false);
});
it("has an answer for every theme there is", () => {
// A theme added later without a branch here would silently paint light.
const all: Theme[] = ["system", "light", "dark", "ihasmail"];
for (const t of all) expect(typeof isDarkTheme(t, false), t).toBe("boolean");
});
});
describe("the default theme", () => {
it("is ihasmail, so a new account looks like ihasmail before anyone chooses", () => {
expect(DEFAULT_SETTINGS.theme).toBe("ihasmail");
});
/**
* The guarantee that matters when a default changes: it moves nobody who
* already has a theme stored — which is everyone using ihasmail today, since
* the setting is saved whether or not they deliberately picked it.
*
* `localStorage` is not available in this environment, and `saveJson`
* swallows that, so a plain round-trip here would pass for the wrong reason:
* both sides would be the fallback. Stub it, so what is under test is
* `loadJson`'s merge rather than the environment.
*/
const withStorage = (fn: () => void) => {
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
});
try {
fn();
} finally {
Reflect.deleteProperty(globalThis, "localStorage");
}
};
it("is only a default — a stored theme wins", () => {
withStorage(() => {
saveJson("theme-test", { ...DEFAULT_SETTINGS, theme: "light" });
expect(loadJson("theme-test", DEFAULT_SETTINGS).theme).toBe("light");
});
});
it("fills in from the default only for keys the stored settings lack", () => {
withStorage(() => {
// An older settings blob that predates a key must not lose the new one.
saveJson("theme-test-partial", { theme: "dark" });
const loaded = loadJson("theme-test-partial", DEFAULT_SETTINGS);
expect(loaded.theme).toBe("dark");
expect(loaded.accent).toBe(DEFAULT_SETTINGS.accent);
});
});
it("falls back to the default when nothing is stored", () => {
withStorage(() => {
expect(loadJson("theme-test-absent", DEFAULT_SETTINGS).theme).toBe("ihasmail");
});
});
});
describe("the top-bar toggle", () => {
it("goes to light from anything dark", () => {
expect(toggleTarget("dark", "ihasmail")).toBe("light");
expect(toggleTarget("dark", "dark")).toBe("light");
expect(toggleTarget("dark", "system")).toBe("light");
});
it("comes back to the theme you were actually on", () => {
// The whole point: two clicks from ihasmail must return to ihasmail, not
// deposit you on plain dark.
expect(toggleTarget("light", "ihasmail")).toBe("ihasmail");
expect(toggleTarget("light", "dark")).toBe("dark");
});
it("can bring back \"match system\", which the toggle used to strand", () => {
expect(toggleTarget("light", "system")).toBe("system");
});
it("round-trips every dark theme there is", () => {
for (const t of ["dark", "ihasmail", "system"] as const) {
expect(toggleTarget(toggleTarget("light", t) === "light" ? "light" : "dark", t), t).toBe("light");
expect(toggleTarget("light", t), t).toBe(t);
}
});
});
describe("remembering which dark theme you were on", () => {
const setTheme = (t: Theme) => {
useSettings.getState().update({ theme: t });
return useSettings.getState().settings;
};
it("records a dark theme chosen from Settings, not just from the toggle", () => {
// update() is the single path every way of choosing a theme goes through,
// which is why the remembering lives there rather than at the call sites.
expect(setTheme("dark").lastDarkTheme).toBe("dark");
expect(setTheme("ihasmail").lastDarkTheme).toBe("ihasmail");
expect(setTheme("system").lastDarkTheme).toBe("system");
});
it("does not let light overwrite it — that is the theme being toggled away from", () => {
setTheme("ihasmail");
expect(setTheme("light").lastDarkTheme).toBe("ihasmail");
});
it("survives a there-and-back through the toggle", () => {
setTheme("ihasmail");
const away = setTheme(toggleTarget("dark", useSettings.getState().settings.lastDarkTheme));
expect(away.theme).toBe("light");
const back = setTheme(toggleTarget("light", away.lastDarkTheme));
expect(back.theme).toBe("ihasmail");
});
});
describe("where the theme settings live", () => {
it("follows the account, not the browser", () => {
// Both of these ride in the account's settings.json, so a theme chosen on
// one machine — and the toggle's way back to it — are the same everywhere.
// Named explicitly rather than derived from DEVICE_KEYS: the test that
// does derive it would still pass if one of these were moved there, since
// its expectation would move too.
const synced = syncedPart(DEFAULT_SETTINGS);
expect(synced).toHaveProperty("theme");
expect(synced).toHaveProperty("lastDarkTheme");
expect(DEVICE_KEYS.has("theme")).toBe(false);
expect(DEVICE_KEYS.has("lastDarkTheme")).toBe(false);
});
it("is applied from a settings file another device wrote", () => {
expect(acceptRemote({ theme: "dark", lastDarkTheme: "dark" })).toEqual({ theme: "dark", lastDarkTheme: "dark" });
});
});
@@ -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();
});
});
+177
View File
@@ -0,0 +1,177 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import {
applicationServerKey,
decodeApplicationServerKey,
encodeKey,
subscriptionPayload,
supportsEmailPush,
webPushAvailable,
} from "@/lib/webpush";
import type { JmapSession } from "@/jmap/types";
/**
* The key encoding is where this breaks silently. `subscribe()` fails with an
* opaque error on a mis-decoded VAPID key, and Stalwart 0.16 had to be fixed to
* accept the *unpadded* base64url the W3C Push API produces — so re-padding on
* the way out would be sending a shape the server has not been tested against.
*
* The real key from the live 0.16.19 is used below rather than a made-up one:
* its length is what exercises the padding arithmetic.
*/
const LIVE_KEY = "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY";
function session(caps: Record<string, unknown>): JmapSession {
return { capabilities: caps, accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession;
}
afterEach(() => {
client.session = null;
vi.unstubAllGlobals();
});
describe("the VAPID key", () => {
it("is read from the capability the server publishes", () => {
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
expect(applicationServerKey()).toBe(LIVE_KEY);
});
it("is null when the server does not do Web Push, rather than an empty string", () => {
client.session = session({ "urn:ietf:params:jmap:core": {} });
expect(applicationServerKey()).toBeNull();
});
it("decodes to the 65 bytes of an uncompressed P-256 point", () => {
const buf = decodeApplicationServerKey(LIVE_KEY);
expect(buf.byteLength).toBe(65);
// 0x04 marks an uncompressed EC point; the Push API rejects anything else.
expect(new Uint8Array(buf)[0]).toBe(0x04);
});
it("handles base64url without padding, which is how it arrives", () => {
expect(LIVE_KEY).not.toContain("=");
expect(LIVE_KEY).toMatch(/[-_]/);
expect(() => decodeApplicationServerKey(LIVE_KEY)).not.toThrow();
});
it("returns an ArrayBuffer, which is what subscribe() accepts", () => {
expect(decodeApplicationServerKey(LIVE_KEY)).toBeInstanceOf(ArrayBuffer);
});
});
describe("encoding keys for the server", () => {
it("produces unpadded base64url, the form Stalwart was fixed to accept", () => {
// 5 bytes: a length that would be padded with "===" in standard base64.
const buf = new Uint8Array([1, 2, 3, 4, 5]).buffer;
const out = encodeKey(buf);
expect(out).not.toContain("=");
expect(out).not.toContain("+");
expect(out).not.toContain("/");
});
it("round-trips through the decoder", () => {
const bytes = new Uint8Array([0, 255, 128, 64, 32, 16]);
expect(new Uint8Array(decodeApplicationServerKey(encodeKey(bytes.buffer)))).toEqual(bytes);
});
it("gives an empty string rather than throwing on a missing key", () => {
expect(encodeKey(null)).toBe("");
});
});
describe("what gets registered", () => {
const fakeSub = {
endpoint: "https://push.example/abc",
toJSON: () => ({ keys: { p256dh: "cGRoLWtleQ", auth: "YXV0aA" } }),
getKey: () => null,
} as unknown as PushSubscription;
it("asks for the message itself when the server supports emailpush", () => {
client.session = session({
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
"urn:ietf:params:jmap:emailpush": {},
});
const body = subscriptionPayload(fakeSub, "a1") as Record<string, any>;
expect(body.url).toBe("https://push.example/abc");
expect(body.keys).toEqual({ p256dh: "cGRoLWtleQ", auth: "YXV0aA" });
expect(body.emailPush.a1.properties).toContain("subject");
expect(body.emailPush.a1.properties).toContain("from");
// Order is priority: the server drops from the end when the payload is
// too large, so the sender must outrank the preview.
const props: string[] = body.emailPush.a1.properties;
expect(props.indexOf("from")).toBeLessThan(props.indexOf("preview"));
});
it("omits emailPush entirely when the server does not support it", () => {
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
expect(supportsEmailPush()).toBe(false);
expect(subscriptionPayload(fakeSub, "a1")).not.toHaveProperty("emailPush");
});
it("omits emailPush when there is no account to scope it to", () => {
client.session = session({
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
"urn:ietf:params:jmap:emailpush": {},
});
expect(subscriptionPayload(fakeSub, null)).not.toHaveProperty("emailPush");
});
it("subscribes to Email changes only, since EventSource covers an open tab", () => {
client.session = session({ "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY } });
expect((subscriptionPayload(fakeSub, "a1") as Record<string, unknown>).types).toEqual(["Email"]);
});
});
describe("availability", () => {
it("is false without a push key, however capable the browser", () => {
client.session = session({ "urn:ietf:params:jmap:core": {} });
expect(webPushAvailable()).toBe(false);
});
});
describe("the emailPush filter", () => {
/**
* This is the bug that reached production: `inMailbox: null` read as "the
* inbox" and meant nothing to the server, which answered "Invalid filter"
* and refused the subscription outright. The original tests checked the
* property ordering and never looked at the filter at all.
*/
const fakeSub = {
endpoint: "https://push.example/abc",
toJSON: () => ({ keys: { p256dh: "cGRoLWtleQ", auth: "YXV0aA" } }),
getKey: () => null,
} as unknown as PushSubscription;
const withEmailPush = () => {
client.session = session({
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
"urn:ietf:params:jmap:emailpush": {},
});
};
it("never sends a condition with a null or undefined value", () => {
withEmailPush();
for (const inbox of ["mb1", null]) {
const body = subscriptionPayload(fakeSub, "a1", inbox) as Record<string, any>;
const filter = body.emailPush.a1.filter as Record<string, unknown>;
for (const [k, v] of Object.entries(filter)) {
expect(v, `${k} was ${String(v)} with inbox=${String(inbox)}`).not.toBeNull();
expect(v, k).not.toBeUndefined();
}
}
});
it("uses the real mailbox id when it knows one", () => {
withEmailPush();
const body = subscriptionPayload(fakeSub, "a1", "mbInbox") as Record<string, any>;
expect(body.emailPush.a1.filter.inMailbox).toBe("mbInbox");
});
it("leaves inMailbox out entirely when it does not, rather than sending null", () => {
withEmailPush();
const filter = (subscriptionPayload(fakeSub, "a1", null) as Record<string, any>).emailPush.a1.filter;
expect(filter).not.toHaveProperty("inMailbox");
// Still narrowed to unread: notifying more widely beats not notifying.
expect(filter.notKeyword).toBe("$seen");
});
});
+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;
}
+23 -45
View File
@@ -7,53 +7,39 @@
* is what makes this state travel between devices without ihasmail storing
* anything server-side of its own — but it is housekeeping rather than
* something anyone filed there, so the Files view hides it. See `isAppFolder`.
*
* Both lookups below filter on `parentId`/`isTopLevel` alone and match the name
* here rather than asking the server to. Those are the filters Files itself
* relies on; `name` is not one Stalwart is known to implement, and a filter it
* does not know fails the whole query rather than being ignored.
*/
import { client, setErrorMessage } from "@/jmap/client";
import type { FileNode, GetResponse, Id, SetResponse } from "@/jmap/types";
import { directoryCreate, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "@/lib/filenode";
import { directoryCreate } from "@/lib/filenode";
export const APP_FOLDER = "ihasmail";
/** Just enough to find the folder, asking for nodeType only where it exists. */
export const folderProps = (): string[] =>
supportsNodeType() ? ["id", "name", "nodeType", "parentId"] : ["id", "name", "parentId", "blobId", "size", "type"];
/** Just enough to find the folder. */
export const folderProps = (): string[] => ["id", "name", "nodeType", "parentId"];
/** The client's own folder, which the Files view does not show. */
export function isAppFolder(n: Pick<FileNode, "name" | "parentId" | "nodeType">): boolean {
return n.name === APP_FOLDER && !n.parentId && n.nodeType === "directory";
}
/** Every node in the account, for servers whose query cannot see directories. */
async function allNodes(accountId: Id, properties: string[]): Promise<FileNode[]> {
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties });
return normalizeFileNodes(res.list);
/** List one level of the tree: the top level, or the children of a folder. */
async function children(accountId: Id, parentId: Id | null, properties: string[]): Promise<FileNode[]> {
const filter = parentId ? { parentId } : { isTopLevel: true };
const res = await client.chain([
["FileNode/query", { accountId, filter, limit: 1000 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties }, "g"],
]);
return (res.get("g")?.[0] as unknown as GetResponse<FileNode>).list;
}
/** Find the app folder, or make it. Returns its node id. */
export async function ensureFolder(accountId: Id): Promise<Id> {
const props = folderProps();
let list: FileNode[] = [];
if (queryOmitsDirectories()) {
// Query cannot see a directory on these servers, so it would never find the
// folder and we would make a fresh one on every save. Ask get for the lot.
list = await allNodes(accountId, props);
} else {
try {
const res = await client.chain([
["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: APP_FOLDER }, limit: 5 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
]);
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
} catch {
// Filters unsupported: scan everything and pick it out here.
const res = await client.chain([
["FileNode/query", { accountId, limit: 1000 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
]);
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
}
}
const existing = list.find(isAppFolder);
const existing = (await children(accountId, null, folderProps())).find(isAppFolder);
if (existing) return existing.id;
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: directoryCreate(null, APP_FOLDER) } });
const err = set.notCreated?.d;
@@ -61,7 +47,10 @@ export async function ensureFolder(accountId: Id): Promise<Id> {
return set.created!.d!.id;
}
/** A node's persistent blobId, for servers that do not return one on create. */
/**
* A node's persistent blobId. `FileNode/set` does not return one on create, so
* anything that needs the blob straight after making the node has to ask.
*/
export async function nodeBlobId(accountId: Id, id?: Id): Promise<Id | undefined> {
if (!id) return undefined;
try {
@@ -74,18 +63,7 @@ export async function nodeBlobId(accountId: Id, id?: Id): Promise<Id | undefined
/** Find a file by name inside the app folder. */
export async function findInFolder(accountId: Id, folderId: Id, name: string): Promise<FileNode | undefined> {
const props = ["id", "name", "parentId", "blobId", "size", "type", ...(supportsNodeType() ? ["nodeType"] : [])];
try {
const res = await client.chain([
["FileNode/query", { accountId, filter: { parentId: folderId, name }, limit: 5 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
]);
const list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
const hit = list.find((n) => n.name === name && n.parentId === folderId);
if (hit) return hit;
} catch {
/* filters unsupported: fall through to the full scan */
}
const list = await allNodes(accountId, props);
const props = ["id", "name", "parentId", "blobId", "size", "type", "nodeType"];
const list = await children(accountId, folderId, props);
return list.find((n) => n.name === name && n.parentId === folderId);
}
Binary file not shown.
+52
View File
@@ -0,0 +1,52 @@
/**
* Emptying a folder, and asking first.
*
* There are three ways in — the folder's right-click menu, the list's own
* menu, and the banner across the top of Junk Mail — and they must not drift
* apart in what they warn about. A folder can only be emptied when it is one
* whose whole purpose is holding things you did not want: Deleted Items, or
* Junk Mail.
*
* The wording differs between them for a reason. Emptying Deleted Items is
* what anyone expects it to do. Emptying Junk Mail is the surprising one: the
* messages do not travel to Deleted Items on the way out, so there is no
* second chance to change your mind, and the dialog says so rather than
* leaving it to be discovered.
*/
import { confirmDialog } from "@/ui/dialog";
import { useMail } from "@/store/mail";
import type { Id, MailboxRole } from "@/jmap/types";
export interface EmptyTarget {
id: Id;
name: string;
role: MailboxRole;
totalEmails: number;
}
/** Whether this folder is one that may be emptied at all. */
export function canEmpty(role: MailboxRole | undefined | null): boolean {
return role === "trash" || role === "junk";
}
const plural = (n: number) => `${n.toLocaleString()} message${n === 1 ? "" : "s"}`;
/** What the button or menu item is called, in the folder's own terms. */
export function emptyLabel(target: Pick<EmptyTarget, "name" | "role">): string {
return target.role === "junk" ? "Delete all spam" : `Empty ${target.name}`;
}
/** Ask, then empty. Resolves once the emptying has been attempted, or declined. */
export async function confirmAndEmpty(target: EmptyTarget): Promise<void> {
if (!canEmpty(target.role)) return;
const junk = target.role === "junk";
const ok = await confirmDialog({
title: junk ? `Delete all spam in “${target.name}”?` : `Empty “${target.name}”?`,
message: junk
? `All ${plural(target.totalEmails)} will be deleted permanently. They do not go to Deleted Items first, so this cannot be undone.`
: `All ${plural(target.totalEmails)} will be permanently deleted.`,
confirmLabel: junk ? "Delete all spam" : "Empty folder",
danger: true,
});
if (ok) await useMail.getState().emptyMailbox(target.id);
}
+45 -70
View File
@@ -1,92 +1,67 @@
/**
* FileNode compatibility across Stalwart releases.
* FileNode shapes, as Stalwart 0.16 defines them.
*
* `nodeType` arrived in 0.16. Before that a FileNode had no such property at
* all, and the server rejects the whole create with
* `invalidProperties (nodeType)` — which is what uploading a file or making a
* folder used to hit. Older servers instead tell a file from a directory by
* whether it carries file properties at all: set `blobId`, `size` or `type`
* (even to null) and the node becomes a file, leave them off and it is a
* directory.
*
* 0.16 is also the first release to advertise `urn:stalwart:jmap`, and no
* earlier one knows that capability, so its presence is a reliable stand-in for
* "this server has the newer FileNode shape" — as long as it is looked for in
* `primaryAccounts` and `accountCapabilities`, which is where Stalwart puts it,
* and not only in the session-level `capabilities`, where it never appears.
* This used to be a compatibility layer spanning 0.15 and 0.16, which differ
* in ways the server does not report: `nodeType` did not exist and sending it
* failed the create outright, `FileNode/query` masked directories out of its
* own results, and rights were a single `mayWrite` rather than the four
* 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 { client } from "@/jmap/client";
import type { FileNode, Id } from "@/jmap/types";
import { descendantIds } from "./folderMove";
const STALWART_CAP = "urn:stalwart:jmap";
export function supportsNodeType(): boolean {
// Not `hasCapability`: Stalwart advertises this per-account, never in the
// session-level capabilities, so looking only there treats every real 0.16
// server as pre-0.16 and drops Files onto the older code path.
return client.hasCapabilityAnywhere(STALWART_CAP);
}
const BASE_PROPS = ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable"];
/**
* Whether `FileNode/query` is blind to directories.
*
* Before 0.16 the query masks its results with `document_ids(false)`, which
* keeps only resources that are *not* containers — so it returns files and
* never folders, with no error to say so. A folder created there is real, and
* simply never comes back in a listing. `FileNode/get` has no such mask, so
* asking it for every id is the only way to see the whole tree.
*/
export function queryOmitsDirectories(): boolean {
return !supportsNodeType();
}
/** Properties to request, asking for `nodeType` only where it exists. */
/** Properties to request for a node. */
export function fileNodeProps(): string[] {
return supportsNodeType() ? [...BASE_PROPS, "nodeType"] : BASE_PROPS;
return ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "shareWith", "role", "executable", "nodeType"];
}
/** Create-arguments for a directory. */
export function directoryCreate(parentId: Id | null, name: string): Record<string, unknown> {
// Any file property — blobId, size, type — would make this a file on an
// older server, so a directory there is exactly parentId plus name.
return supportsNodeType() ? { parentId, name, nodeType: "directory" } : { parentId, name };
return { parentId, name, nodeType: "directory" };
}
/** Create-arguments for a file with an already-uploaded blob. */
export function fileCreate(parentId: Id | null, name: string, blobId: Id, type: string): Record<string, unknown> {
const base = { parentId, name, blobId, type };
return supportsNodeType() ? { ...base, nodeType: "file" } : base;
return { parentId, name, blobId, type, nodeType: "file" };
}
/**
* Fill in what an older server does not report, so everything downstream —
* icons, sorting, "may I delete this" — can read the 0.16 shape.
* Whether a node is shared with anyone.
*
* Rights were split up in 0.16. Before that a node carried `mayRead`,
* `mayWrite` and `mayShare`, with the one `mayWrite` covering everything the
* newer release names separately. Without translating it, the Rename and
* Delete menu items sit permanently greyed out: no error, just nothing.
* 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 normalizeFileNodes<T extends Partial<FileNode>>(nodes: T[]): T[] {
if (supportsNodeType()) return nodes;
return nodes.map((n) => ({
...n,
nodeType: n.nodeType ?? (isFile(n) ? "file" : "directory"),
myRights: widenRights(n.myRights),
}));
export function isShared(node: Pick<FileNode, "shareWith">): boolean {
return Object.keys(node.shareWith ?? {}).length > 0;
}
type Rights = FileNode["myRights"];
function widenRights(rights: Rights | undefined): Rights | undefined {
if (!rights) return rights;
const r = rights as Rights & { mayWrite?: boolean };
if (r.mayDelete !== undefined || r.mayWrite === undefined) return rights; // already the newer shape
return { ...r, mayAddChildren: r.mayWrite, mayRename: r.mayWrite, mayDelete: r.mayWrite, mayModifyContent: r.mayWrite };
}
function isFile(n: Partial<FileNode>): boolean {
return n.blobId != null || n.size != null || n.type != null;
/**
* 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) {
+36
View File
@@ -0,0 +1,36 @@
/**
* Which identities the compose picker offers.
*
* Someone using a unique address per service, on a server with an alias domain,
* ends up with every local part twice and a picker they cannot use — while only
* ever sending from a handful (#73). Hiding is presentation only: the identity
* still exists, still receives, and is still listed in Settings, the same way an
* unsubscribed folder is still a folder.
*
* Three things it will not do, because a sender picker that cannot offer a
* sender is worse than a cluttered one:
*
* - hide the identity a draft is already using, which would leave the select
* with no matching option and reset the From line under the writer
* - hide the default identity, which is what a new draft starts on
* - hide everything; if every identity is hidden it shows them all instead
*/
import type { Identity } from "@/jmap/types";
export function visibleIdentities<T extends Pick<Identity, "id">>(
identities: T[],
hidden: readonly string[],
keep: Array<string | null | undefined> = [],
): T[] {
if (!hidden.length) return identities;
const hide = new Set(hidden);
for (const k of keep) if (k) hide.delete(k);
const shown = identities.filter((i) => !hide.has(i.id));
// Everything hidden: show the lot rather than an empty picker.
return shown.length ? shown : identities;
}
/** Whether hiding this one would be refused, so the UI can say so. */
export function isAlwaysVisible(id: string, keep: Array<string | null | undefined>): boolean {
return keep.some((k) => k === id);
}
+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];
}
+4 -8
View File
@@ -15,15 +15,11 @@
* and the file overwrites it once it lands. A browser with no cache (a private
* window) therefore shows defaults for one frame before the account's real
* settings arrive.
*
* Requires Stalwart 0.16: `FileNode/query` before that cannot see directories
* and the rights model differs. On an older server the settings simply stay
* local, exactly as they were.
*/
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { FileNode, Id, SetResponse } from "@/jmap/types";
import { ensureFolder, findInFolder, nodeBlobId } from "@/lib/appFolder";
import { fileCreate, supportsNodeType } from "@/lib/filenode";
import { fileCreate } from "@/lib/filenode";
import { useSession } from "@/store/session";
const FILE = "settings.json";
@@ -40,7 +36,7 @@ let armed = false;
let listenersBound = false;
export function settingsSyncAvailable(): boolean {
return supportsNodeType() && client.hasCapability(CAP.filenode) && Boolean(useSession.getState().accountFor(CAP.filenode));
return client.hasCapability(CAP.filenode) && Boolean(useSession.getState().ownAccountFor(CAP.filenode));
}
/**
@@ -50,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);
@@ -113,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;
}
+6
View File
@@ -0,0 +1,6 @@
/**
* What this build calls itself: `2.16.57`, or `2.16.57+g1fa6578` for a commit
* that did not come through a pull request. Baked in by Vite; see
* `scripts/version.mjs` for where the parts come from.
*/
export const APP_VERSION = __IHASMAIL_VERSION__;
+198
View File
@@ -0,0 +1,198 @@
/**
* Web Push: notifications that arrive when ihasmail is not open.
*
* The existing EventSource channel only lives as long as a tab does, so
* "desktop notifications" have really meant "while you are looking". Stalwart
* 0.16 signs Web Push with VAPID (RFC 9749) and can carry the message itself in
* the payload (draft-ietf-jmap-emailpush), so the browser's own push service
* delivers a useful notification with ihasmail closed.
*
* Nothing in this path touches ihasmail's server. Stalwart talks to the push
* service directly; the only thing proxied is the JMAP call that registers the
* subscription. That is deliberate — it is why this needs no relay, no extra
* service to run, and no third party beyond the browser vendor's push endpoint
* that Web Push requires of everyone.
*
* Verified against the live 0.16.19 before this was written: the server
* publishes a real `applicationServerKey`, and `PushSubscription/get` answers a
* normal user rather than refusing them.
*/
import { CAP, client } from "@/jmap/client";
import type { GetResponse, Id, SetResponse } from "@/jmap/types";
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
/** Which Email properties to put in the payload, best first. */
const PAYLOAD_PROPS = ["from", "subject", "preview", "receivedAt"];
export interface JmapPushSubscription {
id: Id;
deviceClientId: string;
url: string;
expires: string | null;
verificationCode?: string | null;
}
/** The VAPID key this server signs with, or null if it does not do Web Push. */
export function applicationServerKey(): string | null {
const cap = client.session?.capabilities?.[VAPID_CAP] as { applicationServerKey?: string } | undefined;
return typeof cap?.applicationServerKey === "string" ? cap.applicationServerKey : null;
}
/** Whether the payload can carry the message, rather than only "something changed". */
export function supportsEmailPush(): boolean {
return Boolean(client.session?.capabilities && EMAILPUSH_CAP in client.session.capabilities);
}
/** Whether this browser and this server can do Web Push at all. */
export function webPushAvailable(): boolean {
return (
typeof navigator !== "undefined" &&
"serviceWorker" in navigator &&
typeof window !== "undefined" &&
"PushManager" in window &&
applicationServerKey() !== null
);
}
/**
* The VAPID key as the Push API wants it.
*
* It arrives base64url and unpadded; `atob` needs standard base64 with padding.
* Getting this wrong fails at subscribe() with an opaque error, which is the
* sort of thing worth doing in one place with a name.
*/
export function decodeApplicationServerKey(key: string): ArrayBuffer {
const padded = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (key.length % 4)) % 4);
const raw = atob(padded);
// An ArrayBuffer rather than a Uint8Array: TypeScript 5.7 types the latter
// over ArrayBufferLike, which no longer satisfies BufferSource, and
// subscribe() wants a BufferSource.
const buffer = new ArrayBuffer(raw.length);
const out = new Uint8Array(buffer);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return buffer;
}
/**
* Base64url, unpadded — the form the W3C Push API produces for its keys.
*
* Stalwart 0.16 had to be fixed to accept unpadded keys, so this deliberately
* does not pad: sending what the browser gave us is the case the server now
* handles, and re-padding would be inventing a shape nobody tested.
*/
export function encodeKey(buffer: ArrayBuffer | null): string {
if (!buffer) return "";
const bytes = new Uint8Array(buffer);
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** A stable id for this browser, so a re-subscribe replaces rather than piles up. */
export function deviceClientId(): string {
const KEY = "ihasmail:pushDeviceId";
try {
const existing = localStorage.getItem(KEY);
if (existing) return existing;
const made = `ihasmail-${crypto.randomUUID()}`;
localStorage.setItem(KEY, made);
return made;
} catch {
// Private mode: a per-session id still works, it just will not be reused.
return `ihasmail-${Math.random().toString(36).slice(2)}`;
}
}
/**
* What to send Stalwart for a browser subscription.
*
* `inboxId` is the Inbox's mailbox id. It is a parameter rather than something
* looked up here because an `inMailbox` condition needs a real id: the first
* version of this passed `null`, meaning "the inbox" in the author's head and
* nothing at all to the server, which answered "Invalid filter" and refused the
* whole subscription. Without an id the filter simply leaves `inMailbox` out
* and notifies more widely, which is a worse default but a working one.
*/
export function subscriptionPayload(sub: PushSubscription, accountId: Id | null, inboxId: Id | null = null): Record<string, unknown> {
const json = sub.toJSON();
const body: Record<string, unknown> = {
deviceClientId: deviceClientId(),
url: sub.endpoint,
keys: { p256dh: json.keys?.p256dh ?? encodeKey(sub.getKey("p256dh")), auth: json.keys?.auth ?? encodeKey(sub.getKey("auth")) },
// StateChange notifications are not wanted: the app already has EventSource
// while it is open, and this channel exists for when it is not.
types: ["Email"],
};
if (accountId && supportsEmailPush()) {
body.emailPush = {
[accountId]: {
// Only mail that actually lands in the inbox. Filtering here rather
// than in the service worker means spam never leaves the server.
// Unread mail only, and only in the Inbox when we know which it is.
// Filtering here rather than in the service worker means spam and
// filed mail never leave the server at all.
filter: { ...(inboxId ? { inMailbox: inboxId } : {}), notKeyword: "$seen" },
properties: PAYLOAD_PROPS,
urgency: "normal",
},
};
}
return body;
}
export async function listSubscriptions(): Promise<JmapPushSubscription[]> {
const res = await client.call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
return res.list;
}
export async function createSubscription(body: Record<string, unknown>): Promise<Id | null> {
const res = await client.call<SetResponse<JmapPushSubscription>>(
"PushSubscription/set",
{ create: { s: body } },
[CAP.core, VAPID_CAP, EMAILPUSH_CAP],
);
if (res.notCreated?.s) throw new Error(String(res.notCreated.s.description ?? res.notCreated.s.type));
return (res.created?.s as { id?: Id } | undefined)?.id ?? null;
}
/**
* Hand back the code the server pushed.
*
* A JMAP push subscription delivers nothing until this round-trip completes —
* the server sends a code over the channel to prove it reaches this client, and
* the client echoes it. A subscription left unverified looks registered and is
* silent, which is the confusing failure worth being explicit about.
*/
export async function verifySubscription(id: Id, verificationCode: string): Promise<void> {
const res = await client.call<SetResponse<JmapPushSubscription>>(
"PushSubscription/set",
{ update: { [id]: { verificationCode } } },
[CAP.core, VAPID_CAP],
);
const err = res.notUpdated?.[id];
if (err) throw new Error(String(err.description ?? err.type));
}
export async function destroySubscription(id: Id): Promise<void> {
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: [id] }, [CAP.core, VAPID_CAP]);
}
/** Remove every subscription this browser registered. Used when signing out. */
export async function unsubscribeThisDevice(): Promise<void> {
const mine = deviceClientId();
try {
const reg = await navigator.serviceWorker?.getRegistration();
const sub = await reg?.pushManager.getSubscription();
await sub?.unsubscribe();
} catch {
/* the browser end is gone or was never there; still clear the server end */
}
try {
const subs = await listSubscriptions();
for (const s of subs) if (s.deviceClientId === mine) await destroySubscription(s.id);
} catch {
/* signing out must not fail over this */
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Turning Web Push on and off, and completing the handshake it needs.
*
* Kept apart from `webpush.ts` so that module stays pure JMAP and stays
* testable: everything here touches the browser's service worker and
* permission prompt, none of which exists under a test runner.
*/
import { CAP } from "@/jmap/client";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
import {
applicationServerKey,
createSubscription,
decodeApplicationServerKey,
listSubscriptions,
subscriptionPayload,
unsubscribeThisDevice,
verifySubscription,
webPushAvailable,
} from "@/lib/webpush";
let listening = false;
/**
* Watch for the verification code the server pushes.
*
* The service worker cannot answer it — a JMAP call needs the session cookie
* and this is a background context — so it forwards the code here, or leaves it
* in the cache when no tab was open to forward it to.
*/
export function listenForVerification(): void {
if (listening || typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
listening = true;
navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => {
const d = e.data as { type?: string; id?: string; code?: string } | undefined;
if (d?.type === "push-verification" && d.id && d.code) void verifySubscription(d.id, d.code).catch(() => {});
});
void collectStoredVerification();
}
/** Pick up a code that arrived while no tab was open. */
async function collectStoredVerification(): Promise<void> {
try {
const cache = await caches.open("ihasmail-v2");
const hit = await cache.match("ihasmail-push-verification");
if (!hit) return;
const { id, code } = (await hit.json()) as { id?: string; code?: string };
await cache.delete("ihasmail-push-verification");
if (id && code) await verifySubscription(id, code);
} catch {
/* nothing waiting, or no cache: not a failure */
}
}
/**
* Subscribe this browser. Safe to call again — the deviceClientId makes a
* repeat replace rather than accumulate.
*
* Returns why it could not, rather than throwing, because every reason is
* something to tell the user plainly: an old server, a browser without push, a
* permission they declined.
*/
export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reason: string }> {
if (!webPushAvailable()) {
return { ok: false, reason: "This browser or mail server does not support background notifications." };
}
if (Notification.permission === "denied") {
return { ok: false, reason: "Notifications are blocked for this site in your browser's settings." };
}
const key = applicationServerKey();
if (!key) return { ok: false, reason: "This mail server does not publish a push key." };
try {
const reg = await navigator.serviceWorker.ready;
const existing = await reg.pushManager.getSubscription();
const sub = existing ?? (await reg.pushManager.subscribe({
// Web Push requires it, and Chrome refuses a subscription without it.
userVisibleOnly: true,
applicationServerKey: decodeApplicationServerKey(key),
}));
const accountId = useSession.getState().ownAccountFor(CAP.mail);
const inboxId = useMail.getState().roleId("inbox");
await createSubscription(subscriptionPayload(sub, accountId, inboxId));
listenForVerification();
return { ok: true };
} catch (err) {
return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." };
}
}
/** Remove this browser's subscription, at the browser and at the server. */
export async function disableWebPush(): Promise<void> {
await unsubscribeThisDevice();
}
/** Whether this browser currently has a verified subscription registered. */
export async function webPushActive(): Promise<boolean> {
try {
const reg = await navigator.serviceWorker?.getRegistration();
if (!(await reg?.pushManager.getSubscription())) return false;
return (await listSubscriptions()).length > 0;
} catch {
return false;
}
}
+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>
+40 -8
View File
@@ -12,6 +12,7 @@ import type { JmapSession } from "@/jmap/types";
*/
const TRASH = "mbTrash";
const JUNK = "mbJunk";
const MAX = 500;
interface Call {
@@ -21,13 +22,13 @@ interface Call {
}
/** A server that holds `count` messages and enforces MAX objects per call. */
function server(count: number, opts: { refuseDestroy?: boolean } = {}) {
function server(count: number, opts: { refuseDestroy?: boolean; mailbox?: string } = {}) {
const live = new Set(Array.from({ length: count }, (_, i) => `e${i}`));
const destroyBatches: number[] = [];
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = body.methodCalls.map(([name, args, id]: [string, Record<string, unknown>, string]) => {
if (name === "Email/query" && (args.filter as { inMailbox?: string })?.inMailbox === TRASH) {
if (name === "Email/query" && (args.filter as { inMailbox?: string })?.inMailbox === (opts.mailbox ?? TRASH)) {
const limit = Math.min((args.limit as number) ?? 50, MAX);
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids: [...live].slice(0, limit), total: live.size }, id];
}
@@ -57,7 +58,16 @@ beforeEach(() => {
primaryAccounts: {},
state: "s1",
} as unknown as JmapSession;
useMail.setState({ accountId: "a1", mailboxes: { [TRASH]: { id: TRASH, role: "trash", name: "Deleted Items" } } as never, list: null, emails: {} });
useMail.setState({
accountId: "a1",
mailboxes: {
[TRASH]: { id: TRASH, role: "trash", name: "Deleted Items" },
[JUNK]: { id: JUNK, role: "junk", name: "Junk Mail" },
mbArchive: { id: "mbArchive", role: "archive", name: "Archive" },
} as never,
list: null,
emails: {},
});
useToasts.setState({ toasts: [] });
});
@@ -82,13 +92,35 @@ describe("emptyMailbox", () => {
expect(messages()).toContain("Deleted 12 messages");
});
it("refuses any folder that is not Deleted Items", async () => {
const s = server(5192);
useMail.setState({ mailboxes: { ...useMail.getState().mailboxes, mbJunk: { id: "mbJunk", role: "junk", name: "Junk" } } as never });
await useMail.getState().emptyMailbox("mbJunk");
/**
* Junk Mail is emptiable too, and the messages are destroyed rather than
* moved to Deleted Items — routing spam through the bin on its way out
* would leave the user with the same problem in a different folder.
*/
it("empties Junk Mail, destroying rather than moving to Deleted Items", async () => {
const s = server(1200, { mailbox: JUNK });
await useMail.getState().emptyMailbox(JUNK);
expect(s.live.size).toBe(0);
expect(Math.max(...s.destroyBatches)).toBeLessThanOrEqual(MAX);
expect(messages()).toContain("Deleted 1200 messages");
// Nothing was moved anywhere: every mutating call was a destroy.
const sets = s.fetchMock.mock.calls.flatMap(([, init]) => {
const body = JSON.parse((init as RequestInit).body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
return body.methodCalls.filter(([n]) => n === "Email/set").map(([, a]) => a);
});
expect(sets.length).toBeGreaterThan(0);
for (const a of sets) {
expect(Array.isArray(a.destroy)).toBe(true);
expect(a.update).toBeUndefined();
}
});
it("refuses a folder that is neither Deleted Items nor Junk Mail", async () => {
const s = server(5192, { mailbox: "mbArchive" });
await useMail.getState().emptyMailbox("mbArchive");
expect(s.destroyBatches).toEqual([]);
expect(s.live.size).toBe(5192);
expect(messages()).toContain("Only Deleted Items can be emptied.");
expect(messages()).toContain("Only Deleted Items and Junk Mail can be emptied.");
});
it("stops instead of looping when the server destroys nothing", async () => {
@@ -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);
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useSieve } from "@/store/sieve";
import { newRule, rulesToSieve } from "@/lib/sieve";
import type { SieveScript } from "@/jmap/types";
/**
* Issue #76: adding a filter from a message reported success, and the script
* on the server never held more than two rules.
*
* The chain was three links long, and each looked reasonable alone:
*
* 1. `load()` recorded a *failed* blob fetch as `contents[id] = ""`.
* 2. `sieveToRules("")` returns `[]` — "this script has no rules", which is
* indistinguishable from "we could not read this script".
* 3. Saving writes the whole script from that baseline, so every existing
* rule was deleted, and the UI reported success because the write worked.
*
* The fix is to keep "unknown" and "empty" apart at every step. These pin that:
* an unreadable script must never present as an empty one.
*/
const SCRIPT: SieveScript = { id: "s1", name: "ihasmail", isActive: true, blobId: "b1" } as SieveScript;
const threeRules = [newRule({ name: "One" }), newRule({ name: "Two" }), newRule({ name: "Three" })];
beforeEach(() => {
useSieve.setState({ accountId: "a1", scripts: [SCRIPT], contents: {}, loading: false, error: null });
});
describe("a script whose content could not be read", () => {
it("reports its rules as unknown, not as none", () => {
// contents is empty: the fetch failed, or has not happened yet.
const { rules, loaded } = useSieve.getState().rules();
expect(rules).toBeNull();
expect(loaded).toBe(false);
});
it("refuses to save rather than overwriting what it cannot see", async () => {
await expect(useSieve.getState().saveRules([newRule({ name: "New" })])).rejects.toThrow(/could not be read/i);
});
it("says so in terms that point at the fix", async () => {
// "Reload and try again" is recoverable advice; a generic failure is not.
await expect(useSieve.getState().saveRules([newRule({ name: "New" })])).rejects.toThrow(/reload/i);
});
});
describe("a script that is genuinely empty", () => {
it("is distinguishable from one that could not be read", () => {
useSieve.setState({ contents: { s1: "" } });
const { rules, loaded } = useSieve.getState().rules();
expect(loaded).toBe(true);
expect(rules).toEqual([]);
});
});
describe("a script that was read", () => {
it("hands back every rule in it", () => {
useSieve.setState({ contents: { s1: rulesToSieve(threeRules) } });
const { rules, loaded } = useSieve.getState().rules();
expect(loaded).toBe(true);
expect(rules).toHaveLength(3);
expect(rules?.map((r) => r.name)).toEqual(["One", "Two", "Three"]);
});
it("does not lose rules across a save-shaped round trip", () => {
// The regression in one line: N rules in, N + 1 out after adding one.
useSieve.setState({ contents: { s1: rulesToSieve(threeRules) } });
const before = useSieve.getState().rules().rules!;
const after = [...before, newRule({ name: "Four" })];
useSieve.setState({ contents: { s1: rulesToSieve(after) } });
expect(useSieve.getState().rules().rules).toHaveLength(4);
});
});
describe("reloading", () => {
it("does not discard content it already holds when a refetch yields nothing", () => {
// saveScript caches what it just wrote, then reloads. A reload whose fetch
// failed used to replace the whole map and wipe that.
useSieve.setState({ contents: { s1: rulesToSieve(threeRules) } });
const kept = useSieve.getState().contents.s1;
useSieve.setState((st) => ({ contents: { ...st.contents } })); // merge, not replace
expect(useSieve.getState().contents.s1).toBe(kept);
expect(useSieve.getState().rules().rules).toHaveLength(3);
});
});
+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();
},
}));
+190 -61
View File
@@ -1,35 +1,74 @@
import { create } from "zustand";
import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client";
import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories } from "@/lib/filenode";
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;
}
/** Whether the server supports parentId/isTopLevel query filters (detected at runtime). */
let filtersSupported = true;
const byName = (a: FileNode, b: FileNode) => (a.nodeType === b.nodeType ? a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) : a.nodeType === "directory" ? -1 : 1);
/**
* Drop the client's own `ihasmail` folder, and everything inside it, from a
@@ -56,56 +95,113 @@ export function withoutAppFolder(nodes: FileNode[]): FileNode[] {
return nodes.filter((n) => !hidden.has(n.id));
}
/** Fetch all nodes (paged, no filter) and rebuild the full children map. */
async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial<FilesState>) => void): Promise<void> {
const all: FileNode[] = [];
if (queryOmitsDirectories()) {
// Query would hand back files only, so every folder — including one just
// created — would be missing with nothing to say why. Ask get for the lot.
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties: fileNodeProps() });
all.push(...normalizeFileNodes(res.list));
} else {
let position = 0;
for (let guard = 0; guard < 100; guard++) {
const res = await client.chain([
["FileNode/query", { accountId, position, limit: 500, calculateTotal: true }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"],
]);
const q = res.get("q")?.[0] as unknown as QueryResponse;
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
all.push(...normalizeFileNodes(g.list));
position += q.ids.length;
if (!q.ids.length || (q.total != null && position >= q.total)) break;
}
}
// After the whole collection, not per page: the folder and its contents can
// land in different pages, and a half-filtered pass would spill the rest.
const visible = withoutAppFolder(all);
const nodes: Record<Id, FileNode> = {};
const children: Record<string, Id[]> = { root: [] };
for (const n of visible) nodes[n.id] = n;
for (const n of visible.sort(byName)) {
const key = n.parentId && nodes[n.parentId] ? n.parentId : "root";
(children[key] ??= []).push(n.id);
}
for (const n of visible) children[n.id] ??= [];
set(() => ({ nodes, children, loading: false, error: null }));
/**
* 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) {
@@ -113,10 +209,6 @@ export const useFiles = create<FilesState>((set, get) => ({
if (!accountId) return;
set({ loading: true });
try {
if (!filtersSupported || queryOmitsDirectories()) {
await loadAllNodes(accountId, set);
return;
}
const filter = parentId ? { parentId } : { isTopLevel: true };
const res = await client.chain([
["FileNode/query", { accountId, filter, sort: [{ property: "nodeType", isAscending: false }, { property: "name", isAscending: true }], limit: 1000 }, "q"],
@@ -124,7 +216,7 @@ export const useFiles = create<FilesState>((set, get) => ({
]);
const q = res.get("q")?.[0] as unknown as QueryResponse;
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
const listed = withoutAppFolder(normalizeFileNodes(g.list));
const listed = withoutAppFolder(g.list);
const keep = new Set(listed.map((n) => n.id));
set((s) => {
const nodes = { ...s.nodes };
@@ -132,18 +224,10 @@ export const useFiles = create<FilesState>((set, get) => ({
return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids.filter((id) => keep.has(id)) }, loading: false, error: null };
});
} catch (err) {
// Older Stalwart releases don't support parentId / isTopLevel filters: fall back to
// fetching every node and building the tree client-side.
if (err instanceof JmapMethodError && (err.type === "unsupportedFilter" || err.type === "unsupportedSort")) {
filtersSupported = false;
try {
await loadAllNodes(accountId, set);
return;
} catch (err2) {
set({ loading: false, error: (err2 as Error).message });
return;
}
}
// There used to be a fallback here that abandoned filters and fetched
// every node in the account, because 0.15 refused parentId/isTopLevel.
// 0.16 supports them, and quietly loading the whole tree instead would
// hide a real fault behind a performance cliff nobody would notice.
set({ loading: false, error: (err as Error).message });
}
},
@@ -154,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;
},
@@ -181,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) {
@@ -196,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) {
@@ -205,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) {
+65 -12
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 });
@@ -447,7 +473,12 @@ export const useMail = create<MailState>((set, get) => ({
try {
await setEmails(accountId, update);
if (!opts.silent) {
const name = opts.label ?? mailboxes[toMailboxId]?.name ?? "folder";
// The folder's own name, because that is what the user is looking at
// in the sidebar. A hardcoded word here told people their mail had
// moved to "Trash" or "Spam" on a server whose folders are called
// "Deleted Items" and "Junk Mail" -- naming somewhere that does not
// exist, in the one message whose job is saying where it went.
const name = mailboxes[toMailboxId]?.name ?? opts.label ?? "folder";
toast.show(`${ids.length === 1 ? "Conversation" : `${ids.length} conversations`} moved to ${name}`, {
action: {
label: "Undo",
@@ -506,7 +537,7 @@ export const useMail = create<MailState>((set, get) => ({
const inTrash = ids.filter((id) => (trashId && emails[id]?.mailboxIds[trashId]) || (roleId("junk") && emails[id]?.mailboxIds[roleId("junk")!]));
const toMove = ids.filter((id) => !inTrash.includes(id));
if (inTrash.length) await get().destroy(inTrash);
if (toMove.length && trashId) await get().move(toMove, trashId, { label: "Trash" });
if (toMove.length && trashId) await get().move(toMove, trashId, { label: "Deleted Items" });
else if (toMove.length) await get().destroy(toMove);
},
@@ -552,17 +583,22 @@ export const useMail = create<MailState>((set, get) => ({
} catch {
/* keyword may be rejected; still move */
}
await get().move(ids, target, { label: isSpam ? "Spam" : "Inbox" });
await get().move(ids, target, { label: isSpam ? "Junk Mail" : "Inbox" });
},
async emptyMailbox(mailboxId) {
const accountId = get().accountId;
if (!accountId) return;
// Emptying is permanent and covers the whole folder at once, so it is
// offered for Deleted Items alone. The menus hide it elsewhere; this is
// the guard that makes that true of the action itself.
if (mailboxId !== get().roleId("trash")) {
toast.error("Only Deleted Items can be emptied.");
// offered only for the two folders whose whole purpose is holding what you
// did not want. The menus hide it elsewhere; this is the guard that makes
// that true of the action itself, whatever calls it.
//
// Junk Mail is destroyed outright rather than moved to Deleted Items —
// there is no point routing spam through the bin on its way out, and it is
// what "delete all spam" means everywhere else. The dialogs say so.
if (mailboxId !== get().roleId("trash") && mailboxId !== get().roleId("junk")) {
toast.error("Only Deleted Items and Junk Mail can be emptied.");
return;
}
// A folder can hold far more messages than the server will destroy in one
@@ -823,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.
+25 -7
View File
@@ -2,8 +2,11 @@ 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";
@@ -21,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) => ({
@@ -62,6 +67,14 @@ export const useSession = create<SessionState>((set, get) => ({
} catch {
/* ignore */
}
// A push subscription lives on the account, not the session, so signing out
// without removing it leaves this browser notifying for a mailbox nobody is
// signed into. On a shared machine that is somebody else's mail.
try {
await unsubscribeThisDevice();
} catch {
/* never block signing out over this */
}
stopSettingsSync();
try {
await apiFetch("/api/auth/logout", { method: "POST" });
@@ -88,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);
},
}));
@@ -107,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 }));
+83 -7
View File
@@ -4,7 +4,12 @@ import { loadJson, saveJson } from "@/lib/storage";
import { queueSettingsPush } from "@/lib/settingsSync";
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
export type Theme = "system" | "light" | "dark";
/**
* "ihasmail" is a dark theme carrying the palette from ihasmail.org. It is a
* theme rather than an accent because it changes the backgrounds, borders and
* text as well as the highlight colour — an accent could not.
*/
export type Theme = "system" | "light" | "dark" | "ihasmail";
export type Density = "comfortable" | "cozy" | "compact";
export type ReadingPane = "right" | "bottom" | "off";
export type ImagePolicy = "ask" | "always" | "contacts";
@@ -28,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;
@@ -83,10 +103,38 @@ export interface Settings {
eventCategories: Array<{ name: string; color: string }>;
/** Default sending identity per account (JMAP has no such flag). */
defaultIdentityByAccount: Record<string, string>;
/**
* Identities kept out of the compose picker, by id.
*
* An account with alias domains can have every address twice over while only
* a handful are ever sent from, which makes the picker useless (#73). This
* hides them from the picker only — the identity still exists on the server,
* still receives, and is still listed and editable in Settings, exactly as an
* unsubscribed folder still exists.
*
* A flat list rather than keyed by account: identity ids are unique, and an
* id belonging to another account simply never matches.
*/
hiddenIdentities: string[];
/**
* The theme the top-bar toggle goes back to from light. Remembered rather
* than assumed, so flipping to light and back returns you to the theme you
* were on — "ihasmail", "system" or plain "dark" — instead of dropping
* everyone onto the same one. Never "light": that is the side being
* toggled away from.
*/
lastDarkTheme: Exclude<Theme, "light">;
}
export const DEFAULT_SETTINGS: Settings = {
theme: "system",
/**
* ihasmail's own palette is what a new account gets, so the app looks like
* itself before anyone has chosen anything. It is only a default: a stored
* theme always wins, so nobody who has picked one — including everyone
* already using ihasmail, whose choice is saved even if they never changed
* it — is moved off it.
*/
theme: "ihasmail",
accent: "teal",
density: "cozy",
readingPane: "right",
@@ -95,6 +143,7 @@ export const DEFAULT_SETTINGS: Settings = {
showAvatars: true,
pageSize: 50,
markReadDelay: 0,
addedShares: [],
imagePolicy: "ask",
themeMessageBody: false,
undoSendSeconds: 8,
@@ -140,6 +189,8 @@ export const DEFAULT_SETTINGS: Settings = {
{ name: "Family", color: "#9333ea" },
],
defaultIdentityByAccount: {},
hiddenIdentities: [],
lastDarkTheme: "ihasmail",
};
/**
@@ -203,14 +254,18 @@ applyDateTimePrefs(initialSettings);
export const useSettings = create<SettingsState>((set, get) => ({
settings: initialSettings,
update(patch) {
const settings = { ...get().settings, ...patch };
// Picking a theme anywhere — the toggle, Appearance, an imported file —
// is what teaches the toggle where to come back to. Doing it here rather
// than at the call sites means a fourth way to set a theme cannot forget.
const next = patch.theme && patch.theme !== "light" ? { ...patch, lastDarkTheme: patch.theme } : patch;
const settings = { ...get().settings, ...next };
saveJson("settings", settings);
set({ settings });
applyTheme(settings);
applyDateTimePrefs(settings);
// Dragging a splitter changes a device key on every frame and must not put
// a request in the air; anything else is queued and coalesced.
if (Object.keys(patch).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) {
if (Object.keys(next).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) {
queueSettingsPush(syncedPart(settings));
}
},
@@ -247,16 +302,37 @@ function applyDateTimePrefs(s: Settings): void {
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
}
/** Background of each theme, for the browser chrome (`theme-color`). */
const THEME_COLOR = { light: "#ffffff", dark: "#0b1220", ihasmail: "#0d2430" } as const;
export function applyTheme(s: Settings = useSettings.getState().settings): void {
const root = document.documentElement;
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
const dark = s.theme === "dark" || (s.theme === "system" && prefersDark);
const dark = isDarkTheme(s.theme, prefersDark);
// ihasmail keeps data-theme="dark" and adds a palette on top, so every
// dark-only rule in the stylesheet applies to it without being repeated.
root.dataset.theme = dark ? "dark" : "light";
if (s.theme === "ihasmail") root.dataset.palette = "ihasmail";
else delete root.dataset.palette;
root.dataset.density = s.density;
root.dataset.accent = s.accent;
root.dataset.fontsize = s.fontSize;
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]:not([media])');
if (meta) meta.content = dark ? "#0b1220" : "#ffffff";
if (meta) meta.content = s.theme === "ihasmail" ? THEME_COLOR.ihasmail : dark ? THEME_COLOR.dark : THEME_COLOR.light;
}
/**
* Where the top-bar toggle goes next. Away from dark is always light; back
* from light is wherever you last were, which is the whole point of
* remembering it.
*/
export function toggleTarget(effective: "light" | "dark", lastDarkTheme: Settings["lastDarkTheme"]): Theme {
return effective === "dark" ? "light" : lastDarkTheme;
}
/** Whether a theme paints dark, resolving "system" against the OS. */
export function isDarkTheme(theme: Theme, prefersDark = false): boolean {
return theme === "dark" || theme === "ihasmail" || (theme === "system" && prefersDark);
}
if (typeof window !== "undefined") {
@@ -278,7 +354,7 @@ export function useEffectiveTheme(): "light" | "dark" {
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return theme === "dark" || (theme === "system" && systemDark) ? "dark" : "light";
return isDarkTheme(theme, systemDark) ? "dark" : "light";
}
export const settings = () => useSettings.getState().settings;
+32 -8
View File
@@ -18,7 +18,8 @@ interface SieveState {
load(): Promise<void>;
getContent(id: Id): Promise<string>;
/** Rules derived from the "ihasmail" script (null = the active script is hand-written). */
rules(): { script: SieveScript | null; rules: SieveRule[] | null; content: string };
/** `loaded` distinguishes "this script is hand-written" from "we could not read it". */
rules(): { script: SieveScript | null; rules: SieveRule[] | null; content: string; loaded: boolean };
saveRules(rules: SieveRule[]): Promise<void>;
saveScript(id: Id | null, name: string, content: string, activate: boolean): Promise<Id>;
activate(id: Id | null): Promise<void>;
@@ -49,18 +50,29 @@ export const useSieve = create<SieveState>((set, get) => ({
try {
const res = await client.call<GetResponse<SieveScript>>("SieveScript/get", { accountId, ids: null });
set({ scripts: res.list, loading: false, error: null });
// Preload contents
const contents: Record<Id, string> = {};
// Preload contents.
//
// A fetch that fails must not be recorded as "". An empty script parses
// to an empty rule list, which reads as "this script has no rules" and is
// indistinguishable from "we could not read this script" -- and the next
// save then writes the whole script out from that empty baseline,
// destroying every rule in it. That is issue #76.
//
// Leaving the key absent instead means `rules()` reports the content as
// unknown, and `saveRules` refuses rather than guessing.
const fetched: Record<Id, string> = {};
await Promise.all(
res.list.map(async (s) => {
try {
contents[s.id] = await client.fetchBlobText(accountId, s.blobId, "application/sieve");
fetched[s.id] = await client.fetchBlobText(accountId, s.blobId, "application/sieve");
} catch {
contents[s.id] = "";
/* leave absent: unknown, not empty */
}
}),
);
set({ contents });
// Merged, not replaced: saveScript caches the content it just wrote, and
// a reload whose fetch failed must not throw that away.
set((st) => ({ contents: { ...st.contents, ...fetched } }));
} catch (err) {
set({ loading: false, error: (err as Error).message });
}
@@ -79,12 +91,24 @@ export const useSieve = create<SieveState>((set, get) => ({
rules() {
const { scripts, contents } = get();
const script = scripts.find((s) => s.name === IHASMAIL_SCRIPT) ?? scripts.find((s) => s.isActive) ?? null;
const content = script ? (contents[script.id] ?? "") : "";
return { script, rules: script ? sieveToRules(content) : [], content };
if (!script) return { script: null, rules: [], content: "", loaded: true };
const content = contents[script.id];
// Not loaded, or the fetch failed. `null` means "cannot say", which every
// caller already treats as "do not edit this script" -- as opposed to `[]`,
// which means "this script genuinely has no rules" and invites a save that
// would overwrite whatever is really in it.
if (content === undefined) return { script, rules: null, content: "", loaded: false };
return { script, rules: sieveToRules(content), content, loaded: true };
},
async saveRules(rules) {
const existing = get().scripts.find((s) => s.name === IHASMAIL_SCRIPT) ?? null;
// The last line of defence. Writing rules replaces the whole script, so
// doing it from a baseline we never managed to read deletes whatever was
// there. Refusing is recoverable; overwriting is not.
if (existing && get().contents[existing.id] === undefined) {
throw new Error("Your filter script could not be read, so saving would overwrite it. Reload and try again.");
}
await get().saveScript(existing?.id ?? null, IHASMAIL_SCRIPT, rulesToSieve(rules), true);
},
+96 -2
View File
@@ -90,6 +90,59 @@
color-scheme: dark;
}
/*
* The "ihasmail" theme: the palette from ihasmail.org, which is a teal-navy
* rather than the blue-slate of the plain dark theme, warmed by the orange the
* logo's cat is drawn in.
*
* It rides on data-theme="dark" rather than replacing it, so every dark-only
* rule further down this file -- tooltips, toasts, the message frame -- keeps
* applying without being duplicated. Only the palette is overridden.
*
* Specificity is doing deliberate work here. This block is [data-palette] plus
* :root, so 0,2,0; the accent variants below are :root[data-theme][data-accent],
* so 0,3,0 and they win. That is what makes the accent swatches keep working on
* top of this theme -- and because the default accent ("teal") has no rule of
* its own, ihasmail.org's own accent is what shows until someone picks another.
*/
:root[data-palette="ihasmail"] {
--bg: #0d2430;
--bg-elev: #12303e;
--bg-sunken: #0a1c26;
--bg-hover: rgba(70, 202, 195, 0.10);
--bg-active: rgba(70, 202, 195, 0.16);
--fg: #eaf6f6;
--fg-muted: #a3c3cb;
--fg-faint: #86aab4;
--border: #21505f;
--border-strong: #2e6a7a;
--accent: #46cac3;
--accent-fg: #062028;
--accent-soft: rgba(70, 202, 195, 0.16);
--accent-soft-fg: #9fe6e2;
--danger: #f87171;
--danger-soft: rgba(248, 113, 113, 0.15);
--warn: #f9a34b;
--warn-soft: rgba(249, 163, 75, 0.14);
--success: #4ade80;
--success-soft: rgba(74, 222, 128, 0.15);
--link: #6fdcd6;
--unread-bg: #163a4a;
--read-bg: #12303e;
--selected-bg: rgba(70, 202, 195, 0.18);
--focus-ring: 0 0 0 3px rgba(70, 202, 195, 0.4);
/* The cat is orange; so is the star. */
--star: #f9a34b;
--q1: #6fdcd6;
--q2: #4ade80;
--q3: #c084fc;
--scrollbar: rgba(163, 195, 203, 0.3);
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-2: 0 8px 24px rgba(0, 0, 0, 0.55);
--shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75);
color-scheme: dark;
}
/* Accent variants */
:root[data-accent="blue"] { --accent: #2563eb; --accent-soft: #dbeafe; --accent-soft-fg: #1e3a8a; --selected-bg: #dbeafe; --focus-ring: 0 0 0 3px rgba(37,99,235,.35); --link:#1d4ed8; }
:root[data-accent="purple"] { --accent: #7c3aed; --accent-soft: #ede9fe; --accent-soft-fg: #4c1d95; --selected-bg: #ede9fe; --focus-ring: 0 0 0 3px rgba(124,58,237,.35); --link:#6d28d9; }
@@ -229,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; }
@@ -451,7 +509,7 @@ img { max-width: 100%; }
.msg-row .msg-important { color: var(--warn); }
.list-footer { padding: 12px; text-align: center; color: var(--fg-muted); font-size: .9em; }
.list-hint { padding: 8px 12px; font-size: .85em; color: var(--fg-muted); background: var(--bg-sunken); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; }
.list-hint button { color: var(--link); font-weight: 600; }
.list-hint button { color: var(--link); font-weight: 600; white-space: nowrap; }
.drag-ghost { position: fixed; top: -1000px; left: -1000px; padding: 8px 12px; background: var(--accent); color: var(--accent-fg); border-radius: 999px; font-weight: 600; box-shadow: var(--shadow-2); pointer-events: none; z-index: 5000; }
/* Splitter between list and reading pane */
@@ -573,7 +631,22 @@ img { max-width: 100%; }
.composer-field .field-extra button { color: var(--fg-muted); padding: 2px 6px; border-radius: 4px; }
.composer-field .field-extra button:hover { background: var(--bg-hover); color: var(--fg); }
.composer-field input.plain { flex: 1; border: 0; background: transparent; outline: none; min-width: 80px; height: 30px; }
.composer-field .from-select { flex: 1; border: 0; background: transparent; padding: 0; height: 30px; cursor: pointer; }
.composer-field .from-select { flex: 1; border: 0; background: transparent; color: var(--fg); padding: 0; height: 30px; cursor: pointer; }
/*
* A native <select>'s dropdown is painted by the browser from the element's own
* colours, not the page's. `.from-select` is deliberately transparent so it
* sits flush in the composer's From line -- which left its popup with no
* background of its own, so the browser drew a light one while the text kept
* the app's light foreground: light on light, unreadable in any dark theme.
*
* Styling `option` fixes the popup without giving the closed control a box.
* Scoped to every select rather than this one, because nothing else in the app
* styled options either -- the next transparent select would have arrived with
* the same bug.
*/
select option,
select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.recipients { flex: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-width: 0; position: relative; }
.recipients .chip { height: 24px; }
.recipients input { flex: 1; min-width: 120px; border: 0; background: transparent; outline: none; height: 28px; }
@@ -946,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>
);
}
+41 -30
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 { useEffectiveTheme, useSettings } from "@/store/settings";
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 />}
@@ -205,22 +207,31 @@ function QuotaBar() {
}
/**
* Flip between light and dark from the top bar.
* Flip to light and back from the top bar.
*
* The stored setting has a third value, "system", so the button acts on what
* is actually on screen rather than on the setting: whichever theme you can
* see, one click gives you the other one. Choosing "match system" again lives
* in Settings Appearance, where the three-way choice belongs.
* The setting has four values and only two of them are "light", so the button
* acts on what is actually on screen rather than on the setting: if you can
* see a dark theme, one click gives you light.
*
* Coming back is the part that needs remembering. There is more than one way
* to be dark — "dark", "ihasmail", or "system" while the OS is — so the way
* back is whichever you were on, kept in `lastDarkTheme`, rather than plain
* "dark" for everyone. Without that, two clicks would quietly move an
* ihasmail user onto a theme they never chose.
*/
function ThemeToggle() {
const effective = useEffectiveTheme();
const lastDarkTheme = useSettings((s) => s.settings.lastDarkTheme);
const update = useSettings((s) => s.update);
const next = effective === "dark" ? "light" : "dark";
const next = toggleTarget(effective, lastDarkTheme);
// The label names where you are going, and going back is not always "dark"
// any more -- it is whichever theme you were on before flipping to light.
const label = next === "light" ? "light mode" : next === "system" ? "your system theme" : next === "ihasmail" ? "the ihasmail theme" : "dark mode";
return (
<button
className="icon-btn"
aria-label={`Switch to ${next} mode`}
title={`Switch to ${next} mode`}
aria-label={`Switch to ${label}`}
title={`Switch to ${label}`}
onClick={() => update({ theme: next })}
>
{effective === "dark" ? <Sun size={21} /> : <Moon size={21} />}
+19 -18
View File
@@ -1,8 +1,9 @@
import { useEffect, useState, type FormEvent } from "react";
import { Eye, EyeOff, LogIn, ShieldCheck } from "lucide-react";
import { Eye, EyeOff, LogIn } from "lucide-react";
import { useSession } from "@/store/session";
import { ApiError } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version";
export function LoginPage() {
const login = useSession((s) => s.login);
@@ -20,8 +21,6 @@ export function LoginPage() {
}, []);
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
const [password, setPassword] = useState("");
const [totp, setTotp] = useState("");
const [showTotp, setShowTotp] = useState(false);
const [showPw, setShowPw] = useState(false);
const [remember, setRemember] = useState(true);
const [busy, setBusy] = useState(false);
@@ -33,13 +32,14 @@ export function LoginPage() {
setBusy(true);
setError(null);
try {
await login(username.trim(), password, totp.trim(), remember);
// No two-factor code: the field is not on this form until the flow works
// end to end, and the server treats an absent code as none given.
await login(username.trim(), password, "", remember);
localStorage.setItem("ihasmail:lastUser", username.trim());
} catch (err) {
if (err instanceof ApiError) {
if (err.code === "invalid_credentials") {
setError(showTotp ? "Invalid credentials or verification code." : "Invalid username or password.");
if (!showTotp && password) setShowTotp(true);
setError("Invalid username or password.");
} else if (err.code === "rate_limited") setError("Too many attempts. Please wait a few minutes and try again.");
else setError(err.message || "Could not sign in.");
} else setError("Network error. Please check your connection.");
@@ -74,17 +74,6 @@ export function LoginPage() {
</button>
</div>
</div>
{showTotp ? (
<div className="field">
<label htmlFor="t">Two-factor code</label>
<input id="t" className="input" inputMode="numeric" autoComplete="one-time-code" placeholder="123456" value={totp} onChange={(e) => setTotp(e.target.value)} autoFocus />
<span className="hint">Enter the code from your authenticator app if your account uses 2FA.</span>
</div>
) : (
<button type="button" className="btn btn-ghost btn-sm" style={{ marginBottom: 12, color: "var(--fg-muted)" }} onClick={() => setShowTotp(true)}>
<ShieldCheck size={16} /> I have a two-factor code
</button>
)}
<label className="check" style={{ marginBottom: 12 }}>
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
<span>Keep me signed in on this device</span>
@@ -94,7 +83,19 @@ export function LoginPage() {
{busy ? "Signing in…" : "Sign in"}
</button>
<p className="foot">
ihasmail by <a href="https://linuxexpert.org" target="_blank" rel="noopener noreferrer">linuxexpert.org</a>
{/*
The version sits directly above the source link on purpose: the
AGPL's offer is for the source of *this* build, and naming the
build is what makes that offer something a person can act on. It
also means a bug report can name the build without anyone having
to sign in to find it.
One <p> with a break rather than two: .foot carries a 20px
margin-top, which a second paragraph would repeat as a gap.
*/}
ihasmail v{APP_VERSION}
<br />
<a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">ihasmail.org</a>
{" · "}
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">AGPL-3.0 source</a>
</p>
+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)); }} />
+48 -3
View File
@@ -1,8 +1,9 @@
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";
import { visibleIdentities } from "@/lib/identityVisibility";
import { RecipientInput } from "./RecipientInput";
import { RichEditor, type RichEditorHandle } from "./RichEditor";
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
@@ -11,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";
@@ -24,11 +28,18 @@ 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);
const focus = useCompose((s) => s.focus);
const identities = useMail((s) => s.identities);
const allIdentities = useMail((s) => s.identities);
const mailAccountId = useMail((s) => s.accountId);
const hiddenIdentities = useSettings((s) => s.settings.hiddenIdentities);
const defaultIdentityId = useSettings((s) => (mailAccountId ? s.settings.defaultIdentityByAccount[mailAccountId] : undefined));
const settings = useSettings((s) => s.settings);
const updateSettings = useSettings((s) => s.update);
const isMobile = useIsMobile();
@@ -118,6 +129,16 @@ export function Composer({ draft }: { draft: Draft }) {
if (files.length) addFiles(key, files);
};
/*
* The picker offers the visible identities, plus two that can never be
* hidden from it: the one this draft is already using, and the default a new
* draft starts on. Hiding either would leave the select with no matching
* option and silently move the From line. See lib/identityVisibility.
*/
const identities = useMemo(
() => visibleIdentities(allIdentities, hiddenIdentities, [d.identityId, defaultIdentityId]),
[allIdentities, hiddenIdentities, d.identityId, defaultIdentityId],
);
const ident = identities.find((i) => i.id === d.identityId) ?? identities[0];
const title = d.subject || (d.replyMode ? (d.replyMode === "forward" ? "Forward" : "Reply") : "New message");
const status = d.sending ? "Sending…" : d.saving ? "Saving…" : d.error ? "Error" : d.savedAt ? `Saved ${formatRelative(new Date(d.savedAt).toISOString())}` : d.dirty ? "Unsaved" : "";
@@ -155,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>}
@@ -225,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>
);
}
+12 -2
View File
@@ -33,11 +33,21 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
}
if (!ready) return <Dialog open onClose={onClose} title="Create filter" size="sm"><Spinner /></Dialog>;
const { rules } = sieve.rules();
const { rules, loaded } = sieve.rules();
if (rules === null) {
return (
<Dialog open onClose={onClose} title="Create filter" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
<p>Your active Sieve script was written by hand, so rules can't be added automatically. Open <b>Settings Filters & rules</b> to edit the script or switch to managed rules.</p>
{/*
Two different situations, and telling them apart matters: one is
permanent and one is a reload away. Saying "written by hand" when the
script merely failed to fetch sends someone looking for a problem
they do not have.
*/}
{loaded ? (
<p>Your active Sieve script was written by hand, so rules can't be added automatically. Open <b>Settings → Filters & rules</b> to edit the script or switch to managed rules.</p>
) : (
<p>Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.</p>
)}
</Dialog>
);
}
+58 -2
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) {
@@ -115,6 +136,37 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
(removed: boolean) => {
useMail.getState().clearSelection();
if (!removed) return;
/*
* Move the focused row off the message that just went away.
*
* Nothing did this before, so `focusId` kept pointing at a row that was
* no longer in the list, and two separate complaints in #71 fell out of
* it. `targetIds()` falls back to the focused id, so the next `#`
* re-targeted the deleted message -- which the optimistic update had
* already marked as being in Deleted Items, making it look like a
* permanent delete and raising a confirmation the setting had turned
* off. And `moveFocus` reads `ids.indexOf(focusId)`, which was -1, which
* it treats as "before the start" -- so `k` clamped to the top of the
* list.
*
* Clicking a row was unaffected, because that sets focus to a row that
* exists, which is why it only ever happened from the keyboard.
*
* `currentRowIndex` here is the value from the render that started this
* action, so it is the index the message had *before* it was removed.
* The row that slid into that slot is the one to focus.
*/
const wasAt = currentRowIndex;
const freshIds = useMail.getState().list?.ids ?? [];
if (!freshIds.length) {
setFocusId(null);
} else if (wasAt >= 0) {
const want = settings.autoAdvance === "newer" ? wasAt - 1 : wasAt;
const next = freshIds[Math.max(0, Math.min(want, freshIds.length - 1))];
if (next) setFocusId(next);
}
// auto-advance
if (threadId) {
const idx = currentRowIndex;
@@ -128,7 +180,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
}
}
},
[threadId, currentRowIndex, settings.autoAdvance, ids, rowThreadId, openThread],
[threadId, currentRowIndex, settings.autoAdvance, ids, rowThreadId, openThread, setFocusId],
);
const actions = useMemo(
@@ -191,7 +243,11 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
focusRef.current = focusId;
useEffect(() => {
const moveFocus = (delta: number) => {
const cur = focusRef.current ? ids.indexOf(focusRef.current) : currentRowIndex;
// A focused id that is no longer in the list gives -1, which must not be
// read as "just before the first row" -- that is what sent `k` to the
// top. Fall back to where the list thinks we are instead.
const fromFocus = focusRef.current ? ids.indexOf(focusRef.current) : -1;
const cur = fromFocus >= 0 ? fromFocus : currentRowIndex;
const next = Math.max(0, Math.min(ids.length - 1, (cur < 0 ? (delta > 0 ? -1 : 0) : cur) + delta));
const id = ids[next];
if (!id) return;
+11 -6
View File
@@ -2,6 +2,7 @@ import { useMemo, useState, type DragEvent, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { AlertOctagon, Archive, ChevronDown, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X } from "lucide-react";
import { useMail } from "@/store/mail";
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
import { isScheduledMailbox } from "@/store/scheduled";
import { useSettings } from "@/store/settings";
import type { Id, Mailbox } from "@/jmap/types";
@@ -291,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);
@@ -328,10 +330,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
toast.error((err as Error).message);
}
};
const empty = async () => {
const ok = await confirmDialog({ title: `Empty “${m.name}”?`, message: `All ${m.totalEmails} messages will be permanently deleted.`, confirmLabel: "Empty folder", danger: true });
if (ok) await useMail.getState().emptyMailbox(m.id);
};
const empty = () => confirmAndEmpty({ id: m.id, name: m.name, role: m.role, totalEmails: m.totalEmails });
const isSpecial = Boolean(m.role) && m.role !== "subscribed";
const color = folderColor(colors, m.id);
const setColor = (c: string | null) => {
@@ -356,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" }}>
@@ -372,7 +377,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
</div>
{color && <MenuItem icon={<X size={16} />} label="Use the default colour" onClick={() => setColor(null)} />}
<MenuSep />
{m.role === "trash" && <MenuItem icon={<Eraser size={16} />} label="Empty folder" onClick={() => void empty()} danger />}
{canEmpty(m.role) && <MenuItem icon={<Eraser size={16} />} label={emptyLabel(m)} onClick={() => void empty()} danger disabled={!m.totalEmails} />}
<MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
</>
);
+22 -8
View File
@@ -6,10 +6,10 @@ import { useMail, type ListState } from "@/store/mail";
import { dateTimeKey, useSettings } from "@/store/settings";
import type { Email, Id } from "@/jmap/types";
import { formatListDate } from "@/lib/format";
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
import { displayName, shortName } from "@/lib/address";
import { Avatar, Empty, useIsMobile } from "@/ui/misc";
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { confirmDialog } from "@/ui/dialog";
import { useCompose } from "@/store/compose";
import { FilterFromMessageDialog } from "./FilterFromMessage";
@@ -74,8 +74,6 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
const selCount = Object.keys(selected).length;
const mailbox = mailboxId ? mailboxes[mailboxId] : undefined;
const isTrashOrJunk = mailbox?.role === "trash" || mailbox?.role === "junk";
// Emptying in one action is for Deleted Items only; Junk is cleared by hand.
const isTrash = mailbox?.role === "trash";
const isDrafts = mailbox?.role === "drafts";
const rowHeight = twoLine ? (settings.density === "compact" ? 56 : settings.density === "comfortable" ? 78 : 66) : settings.density === "compact" ? 36 : settings.density === "comfortable" ? 52 : 44;
@@ -200,16 +198,15 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
<MenuSep />
<MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} />
<MenuItem icon={<MailOpen size={16} />} label="Mark all as read" onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} />
{isTrash && (
{mailbox && canEmpty(mailbox.role) && (
<>
<MenuSep />
<MenuItem
danger
icon={<Eraser size={16} />}
label={`Empty ${mailbox?.name}`}
onClick={async () => {
if (await confirmDialog({ title: `Empty ${mailbox?.name}?`, message: "All messages will be permanently deleted.", confirmLabel: "Empty", danger: true })) void useMail.getState().emptyMailbox(mailboxId!);
}}
label={emptyLabel(mailbox)}
disabled={!mailbox.totalEmails}
onClick={() => void confirmAndEmpty(mailbox)}
/>
</>
)}
@@ -223,6 +220,23 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
<button onClick={() => void doRefresh()}>Retry</button>
</div>
)}
{/*
Junk Mail's own banner, the way every other mail client offers it:
clearing spam is the one thing people come to this folder to do, and
making them find it in a menu is making them hunt for it.
Only here, and only with something to delete. It says "permanently"
because that is the part worth knowing before clicking — these do not
pass through Deleted Items on the way out.
*/}
{mailbox?.role === "junk" && !!mailbox.totalEmails && !selCount && (
<div className="list-hint">
<span className="grow">
Deleting spam is permanent it does not go to Deleted Items first.
</span>
<button onClick={() => void confirmAndEmpty(mailbox)}>Delete all spam now</button>
</div>
)}
<div ref={parentRef} className={`mail-list ${selCount ? "has-selection" : ""} ${twoLine ? "two-line" : ""} ${settings.density === "compact" ? "compact" : ""}`} tabIndex={-1}>
{list?.loading && ids.length === 0 ? (
<div style={{ padding: 8 }}>
+36 -4
View File
@@ -28,12 +28,14 @@ import { sendReadReceipt } from "@/store/mdn";
interface Props {
email: Email;
expanded: boolean;
/** Unread when the conversation was opened, which is what the bar marks. */
wasUnread?: boolean;
onToggle: () => void;
isLast: boolean;
actions: ListActions;
}
export const MessageView = memo(function MessageView({ email: e, expanded, onToggle, actions }: Props) {
export const MessageView = memo(function MessageView({ email: e, expanded, wasUnread, onToggle, actions }: Props) {
const accountId = useMail((s) => s.accountId)!;
const settings = useSettings((s) => s.settings);
const updateSettings = useSettings((s) => s.update);
@@ -43,6 +45,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
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();
@@ -134,7 +140,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
};
return (
<article className={`message ${expanded ? "" : "collapsed"} ${!e.keywords.$seen ? "unread-msg" : ""}`} data-msg-id={e.id} onClick={collapsedClick}>
/* `wasUnread` rather than `$seen`: the bar marks what was unread when the
conversation was opened, and keeps marking it after the auto-mark-read
timer has told the server otherwise. Losing it mid-read was half of #69. */
<article className={`message ${expanded ? "" : "collapsed"} ${wasUnread ?? !e.keywords.$seen ? "unread-msg" : ""}`} data-msg-id={e.id} onClick={collapsedClick}>
<header className="message-head" onClick={(ev) => { if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}>
<Avatar who={from ?? null} />
<div className="who">
@@ -264,7 +273,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
{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 && (
@@ -400,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;
+81 -7
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;
@@ -65,15 +69,41 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
return (filtered.length ? filtered : all).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
}, [thread, emails, fullIds, mailboxId]);
// Default expansion: unread + last message expanded, others collapsed
/*
* Which messages were unread when this conversation was opened.
*
* Expansion and the unread bar used to read `$seen` directly, so the moment
* the auto-mark-read timer fired, every message expanded *because* it was
* unread collapsed again -- all but the last -- and the only record of which
* ones they were disappeared with them (#69). Opening a thread with several
* unread messages gave you a few seconds before the view rearranged itself
* underneath you.
*
* Marking read on the server is still right: opening the thread is the signal
* that you are reading it. What was wrong was letting that change the shape
* of what you are looking at. The set only ever grows while a thread is open
* -- a message that arrives unread joins it -- and is discarded on the way to
* another thread.
*
* Accumulated during render rather than in an effect because it is derived
* purely from `messages`, and adding an id twice does nothing. An effect
* would repaint a frame later, which is the flicker this exists to remove.
*/
const threadKey = thread?.id ?? null;
const unreadAtOpen = useRef<{ key: Id | null; ids: Set<Id> }>({ key: null, ids: new Set() });
if (unreadAtOpen.current.key !== threadKey) unreadAtOpen.current = { key: threadKey, ids: new Set() };
for (const m of messages) if (!m.keywords.$seen) unreadAtOpen.current.ids.add(m.id);
const wasUnread = unreadAtOpen.current.ids;
// Default expansion: unread when opened + last message expanded, others collapsed
const lastId = messages[messages.length - 1]?.id;
const isExpanded = useCallback(
(e: Email) => {
if (e.id in expanded) return expanded[e.id]!;
if (allExpanded) return true;
return !e.keywords.$seen || e.id === lastId || messages.length === 1;
return wasUnread.has(e.id) || e.id === lastId || messages.length === 1;
},
[expanded, allExpanded, lastId, messages.length],
[expanded, allExpanded, lastId, messages.length, wasUnread],
);
// Mark as read after delay
@@ -89,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]);
@@ -190,6 +263,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
key={e.id}
email={e}
expanded={isExpanded(e)}
wasUnread={wasUnread.has(e.id)}
onToggle={() => setExpanded((x) => ({ ...x, [e.id]: !isExpanded(e) }))}
isLast={i === messages.length - 1}
actions={actions}
+10 -9
View File
@@ -1,6 +1,7 @@
import { useSession } from "@/store/session";
import { client } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version";
export function AboutSettings() {
const session = useSession((s) => s.session);
@@ -14,7 +15,7 @@ export function AboutSettings() {
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
<img src="/img/logo.png" alt="ihasmail" width={96} />
<div>
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail 2.0</div>
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail v{APP_VERSION}</div>
<div className="hint">AGPL-3.0-or-later · <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a></div>
</div>
</div>
@@ -28,7 +29,8 @@ export function AboutSettings() {
<tr><td>Image privacy proxy</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
</tbody>
</table>
<p className="hint" style={{ marginTop: 6 }}>Stalwart does not publish its version number to mail clients, so ihasmail reports the API generation it detected instead.</p>
<p className="hint" style={{ marginTop: 6 }}>Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.</p>
<p className="hint">The middle number of ihasmail's own version is the Stalwart generation it is built for: <strong>v2.16.x</strong> targets Stalwart 0.16. The last is the pull request it was built from, and a trailing <code>+g</code> and short commit means the build is past that pull request rather than exactly it.</p>
<h2>Server capabilities</h2>
<div className="row wrap gap-4">
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
@@ -39,12 +41,11 @@ export function AboutSettings() {
/**
* Stalwart deliberately withholds its version from clients (it reports a fixed
* "1.0.0" wherever it publishes one at all), so the most honest thing we can
* show is which generation of its API answered us, plus the edition where the
* server reports it.
* "1.0.0" wherever it publishes one at all), so the edition is all there is to
* show. The generation used to be reported here too, back when ihasmail spoke
* to both 0.15 and 0.16; it requires 0.16 now, so signing in at all is the
* answer to that question.
*/
function describeServer(server: { generation?: "0.16+" | "pre-0.16" | null; edition?: string | null } | undefined): string {
if (!server?.generation) return "not detected";
const generation = server.generation === "0.16+" ? "0.16 or newer" : "older than 0.16";
return server.edition ? `${generation} (${server.edition})` : generation;
function describeServer(server: { edition?: string | null } | undefined): string {
return server?.edition ? `0.16 or newer (${server.edition})` : "0.16 or newer";
}

Some files were not shown because too many files have changed in this diff Show More