After a reload the unified mailbox showed only the active account's mail;
other accounts appeared only once visited. Intermittent (~1 in 14), and
not a timeout - nothing on this path uses setTimeout/AbortSignal/race.
checkAuth awaits only the active account and restores the rest in a
fire-and-forget background loop, registering each client only after
connect() resolves. buildUnifiedAccountClients drops accounts with no
live client (`if (!c) continue`). And isConnected was persisted, so it
rehydrated stale-true: the account passed the isConnected filter while
failing the client lookup, and connectedAccountsSignature already looked
complete at mount so nothing re-fired - and the effects keyed on it only
recompute counts, never the list.
Clear isConnected/hasError on rehydrate so both the filter and the
signature describe this session, and refetch the open unified/cross list
when an account joins the connected set (growth only, not on first
render, not while a search owns the list). Extracted
connectedAccountsGrew so the membership check is testable without
rendering MailApp.
Verified the reset cannot block reconnection: checkAuth reads the full
account list and never filters on isConnected.
Fixes#950
Stalwart seeds the default identity name from the principal "Full name" only
once, and addAccount never rewrote displayName after first login, so an admin
rename never showed up in Bulwark. Read the live name via x:Account/get
through the passthrough (falling back to the identity name elsewhere) and
prefer the synced registry name in account settings.
Quick search fired one request per keystroke with no AbortController and no
staleness check, so whichever response landed last won regardless of which
query it belonged to. Typing an address slowly showed hits belonging to a
short prefix while the box held the full string; retyping the same string in
one go correctly showed none, which reads as "search works sometimes".
advancedSearch already guarded every write with controller.signal.aborted.
This gives searchEmails the same treatment, reusing the existing
searchAbortController so the two search paths supersede each other rather
than race. The catch branch is guarded too: without it a superseded request
that rejects clears results belonging to the current query and surfaces a
misleading error.
Adds stores/__tests__/email-store-search-race.test.ts with three cases, all
of which fail on main and pass with this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shorter auth waterfall makes refreshAccessToken skip the server-side
token cache by default, appending force=true to the PUT. The logout
redirect tests still mocked the bare slot URL, so every refresh hit the
mock's unexpected-call guard and three tests failed. Match the PUT on
the forced URL; the DELETE paths are unchanged.
Review follow-up on the shared-folder mutation routing.
The owner lookup replaced the resolved client with any connected client whose
JMAP account id matched. Account ids are opaque per-server values, and personal
mailboxes carry an accountId too, so this ran on every folder mutation and could
hand a mutation to a client of a different server that happens to issue the same
id - where the bare mailbox id may exist and belong to someone else. Keep the
resolved client when it already is the owner, and require a candidate to match
the server URL as well as the account id.
Recover the owner account and the bare mailbox id from the namespaced store id
when no mailbox object is available. Previously the raw `accountId:id` went to
the server as a mailbox id with the owner dropped, so the mutation targeted the
active personal account; in setMailboxRole that cleared the personal role before
failing.
Drop the shared account header context menu when the account carries no usable
id. buildMailboxTree groups shared mailboxes without an accountId under the
placeholder "unknown", which cannot be a mutation target.
fetchEmails sent a unified/cross view's client-only virtual mailbox id
(__cross_all__ / __unified_*) straight to getEmails as `inMailbox`. The server
rejects it (Stalwart: "filter.inMailbox = '__cross_all__'") and the foreground
catch wiped the list to "no messages found" - notably after actions (mark read,
delete, move) in the aggregated views.
Mirror the loadMoreEmails / refreshCurrentMailbox guard: when the target id is
virtual, fan out via fetchCrossViewEmails / fetchUnifiedEmails (search-aware) at
page 0 instead of querying the virtual id. If the view state isn't resolved yet,
stop loading without sending the id or wiping the list. Real folders are
unaffected.
+5 unit tests (email-store-fetch-unified): cross/unified fan-out, explicit
virtual-id arg, unresolved-view no-op, and the real-folder path still using
getEmails. tsc + eslint clean; all store suites green (389).
Tags, pins and $answered/$forwarded/$mdnsent set on a message in a
shared/group mailbox opened directly from the "Shared" sidebar section
did not persist: they showed until the next reload and were then gone.
The call sites resolved the target account as
`isUnifiedView ? email.sourceAccountId : undefined`. Outside the unified
view that is always `undefined`, so the write went to the reaching
client's own account, which the server answers with `notUpdated` /
`updated: null` and no error - the same silent no-op that
`email-store-shared-folder-actions.test.ts` already covers for the batch
actions. Emails fetched from a shared folder this way are undecorated
(no `sourceAccountId`); the owner is only recoverable from the selected
mailbox.
`toggleStar` never had the bug because it routes through
`resolveEmailActionContext`, which resolves the owner from the email's
source in an aggregate view and from the selected mailbox otherwise.
That resolver was reimplemented inline at each keyword call site, and
the inline copies only ever covered the aggregate half - so starring a
shared message persisted while tagging the same message did not.
Move the two keyword writes into store actions built on that resolver
and route every call site through them, including the read-receipt and
Pro-compose paths that previously passed no accountId at all.
The store actions resolve only for an email the store actually knows.
A Pro tab fetches its own email and never enters `emails` or
`selectedEmail`; for those the selected mailbox says nothing about the
message's owner, so guessing from it would send an own-account write to
a shared account whenever a shared folder happened to be open. An
unknown email keeps the previous behaviour instead.
Behaviour on own-account and unified-view messages is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Folder management performed on a shared/group account was sent to the
logged-in user's primary JMAP account instead of the mailbox owner's
account. Subfolder creation therefore failed with invalidProperties for
parentId, root folders could be created silently in the personal account,
and rename, delete, role, and reorder targeted the wrong mailbox tree.
Shared mailboxes use namespaced store ids to avoid cross-account
collisions, while Mailbox/set requires the owner's accountId and the bare
server mailbox id. Resolve that mutation context centrally: recover the
mailbox originalId and owner account, use a directly connected owner
client when one exists, otherwise reach delegated/group accounts through
the active client with an explicit accountId.
Thread the account target through create, update, and delete JMAP methods
and all folder-management actions. Scope role replacement to the same
owner account, and expose explicit root-folder creation from shared
account headers without making personal root creation depend on the
currently selected folder.
Add unit coverage for JMAP request scoping, shared store routing, role
isolation, direct-client handling, and reorder. Add a Stalwart integration
scenario that creates root and nested group folders, renames them, and
deletes them through the UI.
With multiple accounts and settings sync on, the composer's default sender
would switch depending on login order and get disturbed by logging a third
account off. Cause: the per-account maps preferredIdentityIds (#507) and
allMailFolderIds live in EVERY account's synced settings blob, and
loadFromServer(username, serverUrl) runs once per connected account calling
importSettings(), which REPLACED the whole map with that account's server copy.
So the last account to load clobbered the map (last-writer-wins by login
order), and a stale blob from a since-removed account could win too.
Fix: importSettings takes an optional { serverAccountId }; loadFromServer passes
generateAccountId(username, serverUrl). For the per-account maps, a server load
now merges only that account's OWN entry into the existing map instead of
replacing it - each account's server is authoritative only for itself, so other
accounts' entries survive. File imports (no serverAccountId) still replace
wholesale.
+ regression tests: a per-account load preserves other accounts' entries,
only trusts the loading account, file import still replaces, same for
allMailFolderIds. tsc + eslint clean.
Calendar/contact aggregation left the ACTIVE account's ids raw while
namespacing background accounts (<localAccountId>::id). So an entity's id
depended on which account was active; switching flipped the id form and
invalidated persisted references (selectedCalendarIds), broke isSubscription
matching, and destabilised selection - the account-switch bug users saw in
Calendar and Address Book (mail was fine: it keys per account + stable
virtual ids).
Invariant now: an entity is namespaced iff it carries a localAccountId;
originalId always holds the raw JMAP id. Consumers/mutations already resolve
the raw id via `originalId || stripLocalAccountPrefix`, so they are unchanged.
- prefix* functions (calendar + contacts) always namespace + set originalId;
drop the isActiveAccount branch and the now-dead activeLocalAccountId params
(callers in the hooks + calendar-app/contacts-app updated).
- mapServerEventToStoreEvent namespaces the event id with the matched calendar's
localAccountId, so createEvent/updateEvent results are consistent.
- reconcileSelectedIds(): remap selectedCalendarIds by raw identity on every
fetch, so a raw->namespaced transition (switch, just-created calendar, or the
one-time migration from old persisted raw ids) keeps the selection instead of
resetting to "all".
- isSubscriptionCalendar(): namespace-robust and collision-safe - resolve the
calendar and match by raw id scoped to the owning JMAP accountId (Stalwart's
raw ids like "b"/"c" collide across accounts).
+9 unit tests (selection stability across switch/create, subscription
robustness, no cross-account false positives). tsc + eslint clean; 368 store
tests green.
In the unread ('unread') and starred ('starred') cross views, marking a mail
read or removing its star flips the very keyword the view filters on
(notKeyword:$seen / hasKeyword:$flagged). The push-triggered refresh re-runs
that filtered query, so the mail was absent from the fresh page and the merge
dropped it - the row vanished from under the cursor on the user's own action.
Keep such rows in place: only their status + the counters update (both already
applied by markAsRead/toggleStar/batchMarkAsRead), they stay visible until the
view is re-opened. A new retainedInViewIds set records the ids the user just
read/unstarred while in these views; refreshCurrentMailbox re-inserts those rows
at their prior position via mergeRetainedRows. The set is scoped to the two
keyword cross views, cleared on navigation (fetchEmails/fetchUnifiedEmails/
fetchCrossView/exitUnifiedView), and dropped per-id when the action is reversed
(mark unread / re-star). totalEmails counts the retained rows so hasMore stays
correct.
Genuinely removed rows (deleted/moved -> absent from the local list) are not
resurrected, and a change the user did NOT make in this view (e.g. read on
another device, retain set empty) still drops as before.
Tests: stores/__tests__/email-store-retained-in-view.test.ts - read keeps the
row (unread view), unstar keeps the row (starred view), reversing/ navigation
clear retention, external read still drops, 'all' view does not retain. The
row-keeping cases fail without the fix.
refreshCurrentMailbox runs on every Email state push (delete, star, and
mark-read all raise one). It only handled real single mailboxes: in a
unified ("All Mail") or cross-account view, selectedMailbox is a virtual
id (e.g. __cross_all__ / __unified_inbox__) that no folder matches, so it
was sent to getEmails as an inMailbox filter, returned an empty page, and
the first-page merge wiped the whole aggregated list. The empty query was
previously masked by the length-based merge cutoff; af2b00dd (#592)
changed the cutoff to the page size and unmasked it.
Make refreshCurrentMailbox view-aware, mirroring loadMoreEmails /
performSearch: fan out page 0 via fetchCrossViewEmails / fetchUnifiedEmails
(search-aware) when in those views, then run the existing merge so loaded
pages are preserved. Surface cross-view fetch errors via unifiedErrors.
Test: email-store-refresh-unified asserts a push refresh in the cross-All
and unified-inbox views keeps the list and never queries the virtual id
(fails without the fix: the list wipes to []).
Previously tags where very much focused on color coding email and less about
adding additional information. They were also visualized in different ways in
different locations.
This commit gets rid of all "Color-coding" references, aligns visualization of
the tags across the whole project and tries to improve user experience of using
tags in general.
A search box is shown in the tagging control so the user can quickly search for
a tag if they have a huge (more than 10) amount of tags.
- levels are joined by forward slashes in the keywords
- behaviour is opt-in for now
- long paths are shortened if there is not enough display room
Closes#687.