Extend the existing keyword write coverage to pin the three call sites
that clear a keyword. markAsRead, batchMarkAsRead and toggleStar must
patch with null and never with false, and must still send true when
setting the keyword.
The three removal cases fail against the previous boolean form, one per
call site. The three set cases pass either way, since that path was
already correct.
markAsRead, batchMarkAsRead and toggleStar cleared a keyword by patching
it to false, sending keywords/$seen: false or keywords/$flagged: false.
RFC 8620 section 5.3 removes a property from a PatchObject by setting it
to null, and RFC 8621 section 4.1.1 defines the keywords map as holding
true for every key, so false is not a valid way to clear one.
A server that enforces this rejects the update with invalidProperties.
The client does not inspect notUpdated, so marking a message unread or
removing a star looked like it had worked, the sidebar counts moved, and
the next refresh restored the old state from the server. Stalwart accepts
both forms, which is why this went unnoticed.
Send null instead, matching removeKeyword and the integration test
helpers, which already patch keywords this way.
mn/common.json carried 4 keys absent from the en reference
(login.setup_guide, language.mn, language.mongolian,
language.switch_to_mongolian), failing the key-parity test. They are unused in
source and not present in any other locale (Mongolian is not wired into the
language switcher in en), so remove them to restore parity.
translations.test green (50).
The Files store was the only account-scoped store never wired into the
account lifecycle: account-state-manager clears email/contacts/calendar/
filter/identity/vacation on a switch, but not files. Because the Files
store is a global that outlives the FilesApp component, the previous
account's client + resources lingered in memory and resurfaced whenever
the drive was reopened after a switch - most visibly when the switch
happened on the mail view (FilesApp unmounted), the common normal-shell
flow that the FilesApp re-init effect alone does not cover.
Clear the Files store centrally in clearAllStores() alongside the other
account-scoped stores, so the reset happens on every switch path.
Tests:
- lib/__tests__/account-state-manager.test.ts: assert clearAllStores()
resets the Files drive (client, resources, pathStack, selection,
supportsFiles) with no cross-account leak.
- integration/tests/13-files-account-switch.spec.ts (dockerized Stalwart,
JMAP FileNode): seeds a distinct top-level folder in alice's and bob's
drives and asserts the on-screen drive swaps on a switch with no stale
folder - both switching off the Files view (mail -> switch -> open
Files) and on it (rail account switcher). Verified: passes with the fix,
fails (path 1) without it. New FileNode seeding helpers in helpers/jmap.ts;
openFiles/openMailView/driveEntry nav helpers in helpers/app.ts.
Follow-up to the calendar/contacts consistent-namespacing fix. The multi-account
identity aggregation left the ACTIVE account's identity ids raw while namespacing
the others, so an identity id changed form when the active account changed. Now
every aggregated account is namespaced (`<accountId>::<id>`), matching the
calendar/contact stores.
Safe: the composer already resolves the raw id + owning client via
stripCrossAccountIdentityPrefix everywhere it hits JMAP (currentIdentityRawId /
composerClient), reply-identity resolves by email, and preferredIdentityIds is
applied against the raw identity store (unaffected). No consumer relies on the
active account's identity id being raw. Single-account mode (hook disabled) keeps
the store's raw ids.
Full unit suite + typecheck clean.
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).
Editing an event's start now shifts the end by the same amount, preserving the
current duration (end - start). Moving the start forward never leaves the end
before it, and moving it either way never silently changes the length. Guards
against an already-negative-duration state.
Extracted a pure `shiftedEnd(oldStart, oldEnd, newStart)` for the start
date/time onChange handlers. +7 unit tests. tsc + eslint clean.
The keep-alive test parked one shared Response instance in
mockResolvedValue, but a Response body is single-use - as soon as more
than one request lands in the 30s window the second read dies with
"Body is unusable" and the ping reports a lost connection. Serve a
fresh Response per call via mockImplementation, the pattern the rest
of this file already uses.
The client-side IntlProvider keeps its own build-time message map next
to the ones in i18n/routing.ts and i18n/request.ts, and ca and mn never
made it in. Picking either language merged an empty object over the
English base, so the UI stayed English no matter what the picker said
(#756); the server request config and routing both knew the locales,
the client map was the only gap. Register both and pin the map to the
routing list with a test so a locale cannot ship half-wired again.
The Mongolian locale added language.mn, language.mongolian and
language.switch_to_mongolian only to mn/common.json, so every other
locale failed the translations completeness check (extra keys absent
from en). Add the three keys to all locales - native name for mn and
mongolian, localized wording for switch_to_mongolian, following the
Farsi entries - and drop mn's login.setup_guide, which no code
references and no other locale carries.
When the server refuses the draft save, the composer resets its explicit-
close flag so the unmount stash hands the text to the continue-draft slot
(#702). The close follow-up must be dropped in that case - running it
would open the new session on top of the rescue. This was the one branch
of the close guard without a test.
Opening a mailto: link while the composer held unsaved text used to
replace the session outright and drop the text on the floor. The mailto
entry point now hands a follow-up to the composer's dirty-aware close
handler, so the "Save or discard draft?" dialog runs first: save and
discard continue into the mailto draft, cancel leaves the current draft
untouched. When a failed save hands the text back to the continue-draft
slot (#702), that rescue wins and the follow-up is dropped.
DOMPurify URI-tests srcset as a single string, so an allowed first
candidate waved every later one through - an SVG data: URI could ride
in behind a raster candidate on <img srcset> and <picture><source>.
Check every data: occurrence against the raster allowlist and drop the
whole attribute on one disallowed hit, including the external blocker's
data-blocked-srcset stash, which shares the hook pass with no ordering
guarantee. The token is captured through the metadata-ending comma so
the plain (non-base64) raster form keeps the same verdict as in src.
next-intl has no "../" relative-namespace escape. A key like
t('../context_menu.not_spam') is not resolved relative to anything -
it is concatenated literally into the lookup key and always misses,
throwing MISSING_MESSAGE. email-list.tsx, about-data-settings.tsx,
and account-settings.tsx all used this pattern to reach keys outside
their own translation namespace.
Fixed by calling useTranslations() again for each additional
namespace needed, which is the supported way to reach sibling
namespaces, instead of trying to walk out of the current one.
The translations test already verifies that every key referenced in
source resolves in the en locale, but its extractor skipped any key
starting with a dot, which is exactly the shape of the broken calls,
so this class of bug never failed CI. Since no legitimate key can
start with a dot, the exclusion is removed. With it gone the test
reports all thirteen call sites fixed here when run against the old
code, and passes with the fix, so a reintroduced relative path fails
CI with the offending file listed.
contactUsing(), calendarUsing(), and fileUsing() checked the base
"principals" capability but then declared the separate
"principals:owner" sub-capability (RFC 9670) in the request's "using"
array. RFC 8620 section 3.3 requires a server to reject the whole
request if it declares a capability the server does not advertise, so
a server that supports base principals without the owner
sub-capability rejects every one of these requests with
unknownCapability.
Fixed to check the specific sub-capability being declared instead of
assuming it from the broader one. The calendar agenda route had the
same mismatch and is fixed the same way. Note that RFC 9670 section
1.5.2 places principals:owner in accountCapabilities rather than the
session capabilities, so on compliant servers the sub-capability is
simply never declared, which is the intent. Nothing is lost by
omitting it.
The file share test asserted the old behavior by expecting the owner
capability to be declared against a server that advertises only base
principals. It now asserts the capability is omitted in that case and
declared only when the server advertises it. The same pair of
assertions is added for the contacts, calendars, and agenda route
request builders, so each site this commit touches is covered against
regression.
RFC 8620 does not require Session object URLs to be absolute, but
rewriteSessionUrl() called new URL(url) with no base, which throws on a
relative apiUrl or downloadUrl. The throw was silently caught and the
raw relative string returned, so requests resolved against the app's
own origin instead of the JMAP server.
Resolving with the URL parser and reading back href or pathname is not
an option: downloadUrl, uploadUrl and eventSourceUrl are RFC 6570
level 1 URI Templates (RFC 8620 section 2) whose literal {accountId},
{blobId}, {name} and {type} placeholders would be percent-encoded to
%7B/%7D, silently defeating the string replace() expansion at every
call site. Instead, anchor the path, query and fragment of the original
string at the server origin, which also covers network-path references
(//host/path) and does not fold a path prefix on the configured server
URL into relative resolution.
An empty URL is returned untouched: rewriting it into the bare server
origin would make it truthy and defeat the "not available" guards on
apiUrl and downloadUrl downstream.