Review follow-ups on the unclosed-angle split:
- The paste path fell back to splitMailbox for every entry, which keeps the
last angle run only and folds everything ahead of it into a display name.
`Ann <a@x.com Bob <b@y.com` became one chip and a@x.com disappeared without
even landing in `invalid`. Take that path only when the prefix is not an
address itself.
- The new separator branch ignored `inGroup`, so an unclosed `<` inside RFC
5322 group syntax shattered the group its sibling branch keeps intact.
- angleRunCloses now scans quote-aware, so a `>` inside a quoted display name
no longer counts as closing the run, and it starts past `from` so the helper
is correct whether it gets the separator or the opening `<`.
The suite intermittently failed "settingsSync ... SESSION_SECRET_FILE" under
full parallel load (passed in isolation). Two causes:
- env vars were mutated directly on the shared process.env, so other test
files in the same worker could leak in. Use vi.stubEnv + unstubAllEnvs so
each test's env is isolated and auto-restored, and stub the managed vars to
undefined in beforeEach for a clean baseline.
- both SESSION_SECRET_FILE tests wrote a shared relative ./session-secret and
removed it with a fire-and-forget async unlink; under load the pending
unlink from the first test fired during the second (after write, before
read), so the file was missing. Use a unique temp file per test, removed
synchronously in afterEach.
Verified: 17/17, stable across repeated runs and with hostile env
(APP_NAME/SETTINGS_SYNC_ENABLED/SESSION_SECRET) set in the real environment.
The "This event only" edit path built nested JMAP patch pointers
(recurrenceOverrides/<recurrenceId>/<field>). On the first edit of an
occurrence the override object doesn't exist yet, and JMAP pointers can't
create a missing intermediate object, so Stalwart returned notUpdated and
the edit failed with two error toasts.
Build the override as a single pointer recurrenceOverrides/<recurrenceId>
whose value is the occurrence's field set (dropping identity/series keys),
mirroring the working "delete this occurrence" path. Extracted as the pure,
tested helper buildRecurrenceOverridePatch.
Closes#774.
A calendar holding only tasks (VTODO, e.g. from tasks.org) appeared in the
event calendar UI as an empty calendar. Stalwart's Calendar/get exposes no
supported-component set, so tasks-only-ness is derived from the calendar's
objects (mirroring the existing event/task split): a calendar is tasks-only
iff it holds >=1 object and every object is a task.
- getTasksOnlyCalendarIds scans an account's objects (CalendarEvent/query +
minimal /get), early-exits once every calendar has an event, only marks
after a full scan, and fails open on the cap or any error.
- getAllCalendars/getCalendars annotate calendars with isTasksOnly.
- Such calendars stay in the store for the tasks view (task picker, task-list
grouping) but are filtered from the event sidebar and default selection.
Verified against Stalwart v0.16.16: the tasks-only calendar is flagged, the
default (with an event) is not.
Closes#761.
The self-sent handling from #703 (reply to your own thread message ->
address the original recipients, ignore Reply-To) was entered whenever
From matched one of the user's identities. Contact-form / helpdesk mail
commonly arrives From the user's own address with the real correspondent
in Reply-To, so that branch ignored the Reply-To and looped the reply
back to the user's own To.
Only take the self-sent branch when there is no EXTERNAL Reply-To: a
Reply-To resolving to a non-own address is an explicit RFC 5322 directive
and now wins even for self-sent messages (#776). A Reply-To that is only
our own address stays ignored for self-sent (#703 preserved).
Closes#776.
connectSSE() overwrote sseAbortController with a new controller without
aborting the previous one, so re-running setupPushNotifications (which
happens per connected account and on fairly frequent effect changes)
orphaned SSE streams. readSSEStream only unwinds on abort/done, so each
orphan held its HTTP/1.1 socket forever; with multiple accounts this
starved normal JMAP POSTs, which queued and timed out (~30s).
- connectSSE: abort any prior stream (+ its ping monitor / pending
reconnect) before opening a new one -> exactly one live SSE per client.
- startPollingFallback: bail if a polling interval already exists.
- checkForStateChanges: skip while a poll is already in flight
(stateCheckInFlight) so overlapping triggers don't stack POSTs.
Closes#781.
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.
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.
Composing a new message while a shared/group folder was selected started
it as the reaching login's primary address instead of the shared one,
even when a matching send-as identity existed.
`composeFromAccountEmail` was resolved from
`getAccountById(viewingAccountId ?? activeAccountId)`. A delegated
mailbox has no separate login and no AccountEntry, and
`setViewingAccount` is only ever called with `null`, so selecting a
shared folder leaves both ids pointing at the primary account:
`findComposeIdentityId` was handed the wrong address and correctly
matched the primary identity.
Resolve the address from the selected mailbox instead, falling back to
the account lookup for own folders. `accountName` is the JMAP
`Account.name`, which RFC 8620 describes as "e.g., the email address of
the account"; a server that puts a human label there produces no
identity match and the composer keeps the primary identity, which is the
previous behaviour. Replies are unaffected - `composeFromAccountEmail`
is only read when `mode === 'compose'`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
A `<` with no matching `>` latched splitRecipients into "inside an
address" for the rest of the string, so every later separator was kept
literal and the whole list collapsed into a single entry. Pasting
Ap Reinders <ap@x.com, Erwin Beets <erwin@x.com, jaco@x.com
therefore dropped ap@ and erwin@ into `invalid` and kept only jaco@.
The text left behind in the input can then be committed as one chip
whose address is the entire list, which reaches the server as a single
malformed mailbox and gets the message rejected at DATA:
550 (0.0.0) 'Invalid To email address'
Treat a separator as a separator when the open angle run cannot close
before the next one opens, and fall back to splitMailbox so a
`Name <email` entry keeps its display name instead of falling apart
into bare words.
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.
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.
The rights-first resolver already made EXISTING events in subscription /
read-only calendars read-only, but the create path still offered them:
event-modal's calendar dropdown listed every calendar. iCal subscriptions are
real Stalwart calendars (client-side feed written into a created calendar) with
writable myRights, so they appeared as selectable create destinations.
- canCreateEventsIn(calendar, isSubscriptionCalendar): writable AND not a
subscription. Exported from lib/calendar-editability.
- event-modal: the calendar picker now lists only creatable calendars (plus the
event's own calendar when editing), and new events never default into a
subscription / read-only calendar.
+4 unit tests. tsc + eslint clean; calendar-editability suite 30 green.