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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
"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.
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.
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.
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.
Three things from using it on two real accounts.
A calendar shared with you never appeared. Nothing was wrong with the
share -- the calendar had nowhere to be shown. Calendars loaded from one
account and one only, so the sharer's were reachable solely by switching
the whole app to their account, which is the door being closed below.
They now sit under "Shared with me" beside the reader's own, in their own
colour, with their events in the grid and a click to hide them like any
other calendar.
Their events go through `instancesIn`, the one funnel every view already
reads, so month, week, day and agenda got them without being touched.
Events and calendars from another account are keyed by account as well as
id, and hiding one is remembered under the same key: an id means nothing
outside the account holding it, and two accounts sharing an id is
ordinary rather than unlucky.
An account that shared nothing was listed in Files as though it had.
Every non-personal account was offered on the reasoning that its folders
could speak for themselves -- but an account whose *calendar* was shared
has no folders to speak with, and appeared as an invitation to open an
empty pane. Each is now asked for one file before being listed, and
silence is taken for an answer.
And the account switcher is gone from the profile menu. It existed to
reach what other people shared and was the wrong door: it moved the whole
app to somebody else's account, and since Stalwart advertises every
capability on a shared account, mail, calendar and contacts went with it
and were refused. Everything it was for is now in the module the share
belongs to, found without anyone needing to know an account was involved.
What this does not prove is that Stalwart delivers a calendar share at
all. The mock says the client handles one, which is the half that was
missing; whether the server behaves like address books, which work, or
like mail folders, which do not, needs the two accounts again.
Address book sharing was withdrawn a few hours ago on a report that it
behaved like mail folder sharing. That was wrong -- it works -- and it is
back, built the way Files is rather than the way it was.
Three things it inherits from Files. Shared books are listed in the app's
own left pane instead of behind an account switch in the profile menu.
The reader's books and other people's sit under separate headings, since
a book belonging to somebody else behaves differently and a single merged
list would be quiet about whose contacts you are reading. And opening
Contacts re-reads the session, so a book shared while the tab was open
turns up without signing out and in again.
The books pane the view kept to itself is gone, and with it the last
module that ignored the sidebar it was given.
The one thing Files does not need: shared contacts have to answer when
somebody types a name into a To field, so they are loaded up front rather
than when a book is opened, and they are offered by `suggest` and found
by `lookupByEmail` alongside the reader's own. Their own cards win a tie,
since a card someone wrote themselves should beat a colleague's copy of
the same person. That is the difference between a shared book you can
look at and one you can use.
Cards from a shared account are held apart from the reader's rather than
merged in, and keyed by account as well as id. Ids are only unique within
an account -- two accounts each having a book `ab1` is ordinary -- and a
flat map would have had one silently replace the other.
The mock grew an address book in its shared account, with contacts in it,
because none of this could be exercised otherwise.
KNOWN-ISSUES records the withdrawal as the mistake it was rather than
leaving it in the history looking like a finding. Mail folder sharing
stays withdrawn: that one really is broken.
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.
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.
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.
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.
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.