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.
A notification link now waits up to 20 s for its login to reconnect.
If the user opened another folder or message, switched account or left
Mail in the meantime, the late link used to switch account anyway and
take them away from it. It now stays put.
When the account switch was overtaken by another one or failed, a
folder link was resolved against whichever folders were on screen, so
an Inbox link could open the wrong account's Inbox. It now stops there.
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.
Three files carried a literal NUL byte inside a string: the admin
check's cache key in `app/api/admin/auth/route.ts`, the global search
hit key in `lib/global-search/types.ts` and two TNEF fixtures in
`lib/__tests__/tnef.test.ts`. Git treats any file containing a NUL as
binary, so it showed no diffs for them, never normalised their line
endings, and reported every pull request that touched the admin route
as a conflict even when a text merge was clean.
The escape produces the same string at runtime. The admin route was
stored with CRLF while git saw it as binary; it is now stored with LF
like the rest of the tree, so its diff covers the whole file (the only
content change is on line 70).
`/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.
The push effect re-runs every time a login connects, which while a browser
with many logins restores means once per account. Each run closed every
client's push and set it up again: the live streams were torn down and
reopened and every slow poll re-primed its baseline with a full state read
(Mailbox, Email, Calendar, CalendarEvent, SieveScript) - quadratic in the
number of accounts. With ten logins that was 21 stream openings and about
180 state-poll methods in the first seconds.
Reconcile instead: bind the clients that are new, unbind the ones that are
gone, and on an account switch re-bind only the two logins whose role
changed, handing the live stream to the newly active one when it has
none. Handlers are read through a ref at event time, so a binding never has
to be re-created because a callback changed identity.
The reconciliation is a pure function (lib/push-bindings.ts) with its own
tests; the effect only supplies the callbacks.
Once a link had waited for its login and switched to it, the folder was
looked up at once, in a store that still held the previous mailbox's
folders or none at all: an inbox link opened on a mailbox that was just
switched to reported "this folder is no longer available". Wait for the
new mailbox's folder list to arrive before resolving the link against it.
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.
The preview hands the attachment's blob to the browser's own viewer,
which picks the viewer from the blob's type and downloads anything it
cannot show - under the blob's UUID. It only corrected the exact
application/octet-stream declaration from the file name. Other
declarations kept the preview from working: IBM i (AS/400) systems send
every attachment as application/x-as400attachment, some senders use
application/x-pdf or force-download, and the octet-stream check was
case-sensitive.
Move the decision into previewBlobType(): a PDF, image, audio or video
preview takes the first type its viewer can show - declared, then
served, then the one the file name implies. Other previews keep the
declared type unless it is generic, and the name never yields a
script-capable type.
App store terms put limits on recipients, such as device caps and DRM,
that section 10 of the AGPL does not allow, so a Bulwark app built from
this code could not be shipped through the Apple App Store or Google
Play without breaking the licence. The mobile app reuses the webmail
locale catalogs and several ported modules, so the permission is needed
here as well as in the native repository.
Add a section 7 additional permission that allows app store
distribution as long as the source of every shipped version is public,
free, linked from the listing or the app, and not further restricted.
The permission is not final yet: not every earlier contributor has
answered the consent request. A reservation limits it to contributions
made on or after 30 September 2026 and to older parts whose authors
have agreed. The README licence line and CONTRIBUTING.md say the same.