Mentions change what Enter and Tab do right after `@word` and open a
list for everyone, so they get a setting next to the empty subject
warning (`recipientMentionsEnabled`, on by default, translated in
every locale). Turned off, the composer offers no candidates and the
editor keeps its keys.
While the list is open, the editor now carries `aria-autocomplete`,
`aria-controls` and `aria-activedescendant`, as the To/Cc
autocomplete does, so screen readers hear the list and the highlighted
name. Each row now leads with the `@name` that will be inserted,
followed by the full name and the address: for a surname-first name
such as "Nagy János" the inserted word is not what the name alone
suggests.
After a load the logins other than the one on screen reconnect in the
background, and until they are all back the unified counts cover only
some of them. With many logins that lasts seconds, and a partial total
looks exactly like the whole one.
While that restore runs, the "All accounts" header shows how many logins
are already in the scope, e.g. "4/10", with a translated label for screen
readers. It goes away when the scope is complete or the restore is over -
a login that failed to reconnect does not keep it up.
`restoringAccounts` in the auth store marks the background restore; the
count itself is a pure function with its own tests.
The folder-list cache from #1132 published a reused list only when it
was freshly fetched. When the Pro folder tree had loaded a login first,
the unified build reused that list and never stored it under the
login's JMAP id, so trash and archive for that login's mail were
resolved against the active login's folders until it pushed a change.
A reused list now fills any key nothing has published yet, without
overwriting a live one.
The Pro folder tree also marked a login as loaded before its first
fetch finished and never retried a failure, which left that login's
tree empty until an account switch. A failed load is now retried on
the next run, as the identities hook already does.
Clicking a folder's unread count turns on the unread filter and runs
the search. Since the search scope stopped following the open folder
(#788) its default is "all folders", and since #1082 that fans out over
every shared account too, so the Inbox badge listed unread mail from
every folder of every account the login reaches.
The badge now scopes the search to its folder. That scope moves with
the open folder, the way the search itself carries across folders
(#553); picking a scope in the dropdown or clearing the filters stops
it. Clicking the badge from a tag view or the Scheduled list now also
leaves that view, as clicking the folder does.
`/api/auth/sso/complete` only accepted cookie slots 0-4 and quietly
used slot 0 for anything higher. Since the account limit rose to
`MAX_ACCOUNT_SLOTS` (50) on HTTP/2, adding a sixth account through
server-side SSO wrote its refresh token over the first account's cookie,
while the new account was registered on slot 5 with no cookie behind it.
The route and the OAuth callback's pending-slot check now use the same
bound as every other auth route.
After the login on screen, the rest were restored one after the other, so
the last of them - and with it a complete unified inbox - waited on every
other login's round trips: with ten logins it arrived about five seconds
after the first. Restore them four at a time. Each restore already handles
its own failures, so a slow or broken login holds up only its own slot.
Two effects keyed on the account list reloaded every connected login each
time the list changed - and while a browser restores its logins it changes
once per connection, so the cost was 1 + 2 + … + N:
- useProMultiAccountMailboxes, which the Pro shell runs for its per-account
folder tree, reloaded every login's folders: 55 Mailbox/get with ten logins.
It now loads each login once per client, through the same cache as the
unified scope, and reloads all of them once on an account switch.
- useProMultiAccountIdentities reread every other login's identities: 45
Identity/get. Each mount now reads each account once; what is already
known shows at once, and a read that outlives the run that started it
still reaches the dropdown.
Both are keyed on the set of connected logins instead of `accounts`, which
also changes for display-name and login-time updates.
The unified scope is rebuilt on every connection while a browser restores
its logins, on every background push, and on every unified browse,
load-more and search - and each build asked every login for its folders
again, one login after the other. With ten logins that was ten full
Mailbox/get per build and well over a hundred in the first seconds, and the
slowest build waited on the sum of every login's round trip.
Fetch the logins in parallel, and fetch each login's list once: a build
shares a fetch that is already in flight and reuses a finished one until
that login reports a change - the push handlers invalidate the login they
hear from, so a background push now costs one Mailbox/get instead of one
per login. A 60 s limit backstops a change whose push was lost.
A reused list is never written back into `accountMailboxes`: optimistic
updates land there directly, and publishing an older list over them would
snap a just-changed counter back.
A notification tapped with the app closed reopens it on a link that names
its mailbox (`?slot=`). The browser reconnects its logins one at a time,
so that mailbox is often not connected yet when the link is read: with ten
logins, one on the Gmail bridge came back at 3.3 s and the link was judged
at 1.8 s. It was declared unavailable - "sign in to that account to open
this link" - and the app stayed on whichever mailbox was active.
Wait for the login instead, and give up only when it is not one of this
browser's logins, its restore fails, or 20 s go by.
Retry interrupted or invalid runtime configuration requests with bounded backoff and per-attempt timeouts. Validate the complete response before caching it, and prevent stale HTTP-cached configuration.
Cover request and body timeouts, shared hook consumers, retry bounds, the server response contract, and both Lite configuration paths.
Signing out of Lite only deleted the refresh token from web storage, so
a copy taken from the browser kept working until it expired. Sign-out
now revokes it at the endpoint the mail server (or, for an external
provider, its discovery document) advertises, once the token is gone
from storage.
A password login whose token exchange failed for any reason fell back to
Basic auth and kept the password in sessionStorage. The password is now
only kept for the tab when the server has no token login at all (or
cannot be reached for it); otherwise the Basic session lives in memory
and a reload asks to sign in again.
Several endpoints answered anyone with what fingerprints a deployment:
`/api/health?detailed=true` (versions, memory), the update status
(exact version and the text of a pending security advisory, even with
`loginShowVersion` off), `/api/plugins` (installed plugins and the hosts
they may reach), the admin policy (push relays, sidebar app URLs,
plugin lists) and `/api/config` (every domain each server serves).
Visitors without a session now get:
- the plain liveness answer from `/api/health`;
- the update status only where the login page shows the version, and
never the advisory text;
- a 401 from `/api/plugins`, which the client only syncs after signing
in;
- the policy without plugin lists, push relays and sidebar apps, marked
`X-Bulwark-Policy-Scope: public`; the client fetches the full policy
once it signs in;
- server domain lists only when servers are picked by domain.
A mailbox session, a remembered session cookie or the admin session
counts as signed in. Deployments without a session secret cannot tell
sessions apart and keep the old answers.
A full sign-out emptied the stores but left the accounts' traces in the
browser for the next person at the computer: search history, open tabs
with message subjects, Files favourites and recent files, calendar
alert acknowledgements, and attachment bytes a plugin had staged for an
upload that never started. Other open tabs kept showing the signed-out
accounts' mail, and the boot snapshot of the mailbox list was restored
for whichever account happened to be active next.
- A full sign-out now removes those keys and the staged files, and
tells the other tabs through a `storage` event so they leave for the
login page.
- The boot snapshot names its account and is only restored for it.
- The login form only remembers the username for suggestions when the
user did not decline "remember me".
Device preferences and `template-storage` stay: with settings sync off
they exist only on this browser.
`api.storage` kept one `plugin:<id>:` namespace for everyone signed in
to the browser and left it behind after signing out, so a plugin read
what it had stored for the previous account, such as quick-notes'
notes on someone else's mail.
Keys now carry the account (`plugin:<id>:@<account>:<key>`). Signing an
account out or removing it deletes its keys, and a full sign-out
deletes all plugin storage. Keys written before this change are handed
to the first account that uses the plugin. Uninstalling a plugin still
removes everything under its prefix.
Tagging and pinning replaced the message's whole keyword set with a
copy built from the list row. When the row was stale - the mail had
been read or answered on another device since - the write cleared
`$seen` or `$answered` again, and removed tags were written as `false`,
which a JMAP keyword map may not hold.
Keyword writes now go out as `keywords/<name>` patches for just the
keywords that change, removals as `null`, through the call that
reports a refused update. The local row gets the same patch.
Signing out switches sync off for the signed-out account. When another
account stayed signed in, the app switched to it without turning sync
back on, so every later settings change stayed local and was replaced
by the server copy on the next load. Sign-out now resumes sync for the
account it switches to, the same way an account switch does.
`getEmails` and `getSomeEmails` caught every failure and answered with
an empty result. A refresh during a server hiccup therefore emptied the
message list, and a push delta whose follow-up read failed treated every
updated row as gone from the folder while still moving the sync state
past those changes, so the missed mail only came back on a full reload.
Both now throw. A refresh that fails keeps the list on screen, a delta
that cannot be read falls back to the full refresh, and the unified
views already record per-account failures instead of showing nothing.
The unified inbox, cross-account views, tag view and "All folders"
search load the next page of every account with the merged list's
length as its position. The list interleaves the accounts, so each
account skipped the rows between its own count and the merged length:
two accounts of 120 mails showed 140 after scrolling to the end.
The fan-out loaders now take a per-account position map, and load
more passes the number of rows each account already has in the list.
The Account Security store was never reset on an account switch, and
the settings page only fetches while it has no verdict yet. After a
switch it kept showing the previous account's 2FA state, app passwords
and API keys, and "Remove" sent that account's credential ids through
the new account's session.
`clearAllStores` now resets the store, so the page re-probes and loads
the new account, and its fetches drop answers that arrive after the
stores were cleared for another account.
A folder or filter fetch started for one account and answered after a
switch wrote its result into the stores the next account had just
filled. The sidebar then showed the old account's folders, and the
filter store kept the old account's rules and script id, so the next
save uploaded them - a redirect to someone else's address included -
to the new account's server.
Clearing the stores for another account now bumps a store epoch.
`fetchMailboxes` and the email list's view check drop results from an
older epoch, and the filter store drops any fetch that is no longer the
latest one.
Clicking account B and then C before the first switch finished ran
both switches side by side, each holding the account that was active
when it started. The later one then snapshotted B's live stores under
A's key, so switching back to A restored B's identities (and mail) and
never re-fetched them: replies went out with B's From address.
Each switch now takes a generation and stops at its next await once a
newer switch has started, the outgoing account is read when the
snapshot is taken, and stores another switch already cleared are not
snapshotted over the cached state.
Persisted calendar subscriptions were matched to an account by the bare
JMAP account id, or not at all for subscriptions saved by older builds,
and to their calendar by a bare id. Stalwart hands out the same small
ids on every server, so refreshing a subscription through another login
diffed away the events of an unrelated calendar there, and "Remove"
destroyed that calendar. The secret feed URLs also stayed in
localStorage after signing out.
- new subscriptions record their owner (server plus login name), and
refresh, rename and remove act only through that login
- a subscription without an owner is adopted only when its calendar id
and name both exist in the account; otherwise it is left alone and
removing it only drops the local record
- signing out forgets that login's subscriptions, and a full sign-out
clears them all
A message sent or scheduled from a shared or group identity lives in
that account. Undo send, "Edit" on a scheduled message and cancelling
one cancelled the submission in the right account (#874), but then
restored the message to Drafts, fetched and deleted it through the
login's primary account. That hit whichever primary message had the
same id - pulling an unrelated mail into Drafts or deleting it - and
left the unsent message sitting in the group's Sent.
`restoreEmailToDraft` now takes the account, and the store passes the
submission's account to restore, fetch and delete, using that
account's Drafts and Sent.
Signing out when the next registered account had no live client (its
restore failed at boot) fell back to a full logout, but only cleared
the signed-out account's slot and removed just that next account. The
other account's remembered session survived, and on the next visit the
orphan-cookie adoption in `checkAuth` signed the browser back in as
that account and read its password back.
The fallback now drops every remaining account and clears each of
their slots together with the signed-out one. The full logout also
removes the persisted `account-registry` instead of a key that never
existed (`account-storage`).
The theme sanitizer was a short denylist. It missed protocol-relative
`url(//host)`, schemes and keywords spelled with CSS escapes
(`url(\68ttps:...)`, `@\69mport`), `image-set("https://...")` and
`@font-face` sources, so a theme could load remote resources from the
app document. `validateThemeSelectors` was never called, so theme CSS
could also target `input[type=password]` or restyle real UI. The admin
routes and the zip validator only sanitized when the safety check
failed, and CSS returned by the `onThemeBeforeApply` plugin hook was
injected as-is.
- every `url()` except a `#fragment` and every `image-set()` is removed
with its arguments; a banned construct spelled with escapes drops the
whole stylesheet
- theme CSS keeps only `:root` and `.dark` rules (no nested rules),
plus `@font-face`, `@keyframes` and `@media`/`@supports` wrappers
- sanitizing is unconditional everywhere, including plugin-transformed
CSS; compiled token values may not contain escapes or `url()`
Attachment thumbnails were object URLs typed with the sender's MIME
type. Object URLs share the webmail origin, so an `image/svg+xml`
attachment opened from its thumbnail ("Open image in new tab", or
dragged to the tab strip) ran the sender's script as the webmail in
Chromium and Firefox. The drag-out URL and Files thumbnails had the
same shape.
`fetchBlobAsObjectUrl` now re-types every blob to an inert type, and
image thumbnails in mail and Files go through `imageBlobUrl`, which
hands an SVG over as a `data:` URL: it still renders in the `<img>`,
but opened on its own it gets an opaque origin.
fetchUnifiedEmails and fetchCrossView set isUnifiedView:true synchronously
then await a network fetch with no check on completion that the view is
still current. If one resolved after the user had since selected a real
folder, it re-set isUnifiedView back to true while selectedMailbox stayed
on the folder - so a push-driven refreshCurrentMailbox (e.g. after
deleting a message) took the unified branch and blended in Inbox-tagged
messages instead of refreshing the folder.
Add a viewToken bumped by selectMailbox/selectKeyword/exitUnifiedView;
fetchUnifiedEmails/fetchCrossView capture it before fetching and discard
their result if it no longer matches on return. Also make selectMailbox/
selectKeyword defensively reset isUnifiedView/unifiedRole/crossView.
Upstream: bulwarkmail/webmail#1102