Copying across accounts went through Blob/copy, which failed with
`unknownMethod` on Stalwart 0.16.10+ and 1.0 because
`urn:ietf:params:jmap:blob` was not in `using`. Even with it, the copy
would have been reserved to the target account, which only its members
may use, so copying into another user's shared drive stayed refused.
Copies now point at the source's blobId: Stalwart resolves a FileNode
blob from another account for anyone who may read that node, in both
directions and on every supported version.
A folder copy lays out the whole subtree before the first create. If a
create fails part-way, the half-made copy is destroyed with its
children and the server's error is reported, instead of leaving part
of the tree behind. Files created over WebDAV inside a copied folder
are no longer copied under their percent-encoded names (#869).
New files are created without `size`: every server takes it from the
blob, and 1.0 refuses a size that differs from the blob's.
Stalwart 1.0 reads a text filter value that starts and ends with a
quote as one exact phrase, and a value ending in `*` as one prefix over
the whole string, not split into words and capped at 16 bytes. Every
search path sent the typed input as one `text` value, so on 1.0
`"annual report" 2024 "draft"` became a single four-word phrase and
`project budg*` a prefix containing a space: both found nothing.
`"Q3 planning" budget` lost its phrase on every version.
`splitSearchText` (`lib/jmap/search-utils.ts`) now sends each quoted
phrase and each `word*` as a condition of its own, ANDed with the rest.
The remaining words stay one condition, so a query without quotes or
stars goes out as before. Outer single quotes are stripped from that
part, since 1.0 reads them as a phrase too. The split applies to:
- the search bar, tag views, cross-account and all-folders search;
- advanced search, including the from/to/subject/body fields;
- contact search;
- global search for mail, contacts and calendar.
`inMailbox` and the calendar bounds sit next to the text conditions in
one AND, with one key per condition: Stalwart rejects an operator that
also carries property keys, and 1.0 allows only one AND group when it
expands recurrences.
SearchSnippet/get switches the whole request to exact matching once a
term is quoted, which marks nothing for a phrase ANDed with other
words, so the snippet filter unquotes phrases.
On 1.0 and on external search backends each phrase is now a real
phrase. The 0.16 built-in index reads it as an AND of its words,
without stemming. The dev mock and the demo client now evaluate AND
filters and quoted phrases, so they keep working with the new shapes.
Stalwart 1.0 counts every EmailSubmission record of an account against
`maxSubmissions` (500 by default), whatever its status or age, and its
own purge of old records never runs. Bulwark never destroyed a
submission, so after about 500 JMAP sends an account could not send at
all: every send, read receipt and reschedule failed with a raw "There
are too many email submissions" error, and each failed attempt left one
more copy of the message in Drafts.
Bulwark now removes the records that can no longer be cancelled.
Destroying a record never stops its delivery, but a pending one could
no longer be cancelled, so pending records are never touched:
- an immediate send (no hold, no undo window) destroys its own record
right after the send, riding along with the old-draft cleanup; only
where `urn:stalwart:jmap` is advertised, and never decided by the
created `undoStatus`, which 0.16.10-0.16.16 report as final for held
sends
- a throttled background prune destroys canceled records and final
records whose `sendAt` has passed, going by `EmailSubmission/get`, in
every submission account; it runs at startup, after sends, after
undo and on EmailSubmission state changes
- a confirmed cancel (undo, cancel, reschedule) destroys the canceled
record in a request of its own
- an `overQuota` on a submission create prunes at once, on Stalwart
walks the change log for records older than the query's three-day
window, retries once, and otherwise throws `SubmissionQuotaError`,
shown as a translated explanation in the composer, quick reply, read
receipts and the scheduled-send actions
Failed sends now clean up after themselves and say so:
- a failed send removes the Drafts copy it created and keeps the
user's draft; the raw send paths remove their import the same way
- a read receipt that did not go out is taken back out of Sent, a
method-level error counts as a failure, `$MDNSent` is only set after
a sent receipt, and the automatic receipt no longer retries on every
refresh of the open message
- quick-reply failures, "Send now", "Reschedule" and a failed undo are
shown instead of only logged
- the scheduled list reads every page of `EmailSubmission/query`; it
stopped after the first because `total` needs `calculateTotal`
A PatchObject key is a JSON Pointer (RFC 8620 section 5.3), so a slash
inside the member name it points at has to be escaped as `~1`. Keyword
patches were built as `keywords/${keyword}` verbatim, which is fine for
every flat keyword but wrong for a nested tag: `$label:work/clients`
became the pointer `keywords/$label:work/clients`, asking for the
`clients` member of the `$label:work` keyword rather than for the keyword
itself. The patch is not a boolean at that path, so the server rejects or
ignores it and the tag never lands.
Route the keyword patch sites - setKeyword, removeKeyword, migrateKeyword
and the category-tab batch patch - through `keywordPointer`, which escapes
per RFC 6901 the way the calendar store already does for participant ids.
Flat keywords are untouched, so `$seen`/`$flagged` and friends keep the
exact pointers they had. The dev JMAP mock reads the escaping back, or
nested tags would behave differently against the mock than against a real
server.
migrateKeyword also took any answer as success: a method-level error on
the query read as "no message carries this keyword", one on the write was
ignored outright, and a per-message refusal was invisible - so a rename
that moved nothing still reported a clean migration. It now answers with
{ migrated, refused }, because the work is batched and a failure partway
through cannot undo the batches already applied. A whole batch the server
refuses stops the rest, which it would refuse too; a single message it
refuses does not, the mail behind it being unaffected. A query error
throws, nothing having been written yet, and so does a run that migrated
nothing at all - the case where the caller must not rename a tag over
messages that all kept the old keyword.
The walk over tagged mail was not paginating safely either. It stopped at
the first page shorter than the limit it asked for, but RFC 8620 section
5.5 lets a server clamp that limit, so a clamping server left most of the
mail unmigrated. Positions are also only comparable within one queryState,
which the walk ignored: mail arriving mid-walk shifts every later page. It
now restarts when the state changes under it, and gives up - before any
write, so the caller can simply repeat - once a mailbox has moved under it
three times, rather than mixing ids from states that no longer agree. A
page carrying no id it has not already seen ends the walk, which keeps it
bounded against a server that answers every position with the same page.
If you have a large number of tags, getTagCounts would not be able to get the
unread counts because it did not respect maxCallsInRequest, even though the
value was actually read out, it was just ignored. There are also other places
where the limits were not respected.
Batching is now generalized in a helper that also takes maxObjectsInSet, which
also was ignored, into account and is applied to all functions.
In addition, the dev mock now also advertises and enforces the limits, so these
issues can get picked up during development.
Possible closes#699
Possibly closes#399
Give email-002 ("Project Update - Q1 Review") a sender and CC with
"Lastname, Firstname" display names so Reply/Reply-All in dev mode
exercises the comma-in-name recipient case end to end.
The dev mock's Identity/set discarded its payload and Identity/get always
returned a static list, so saved identities never round-tripped in local
development. Persist create (with mayDelete: true), update, and destroy in
place, mirroring handleMailboxSet, so signature edits stick when testing
without a real JMAP server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The signature separator is already controlled by the
signatureSeparatorEnabled setting (lib/email-composer), which prepends
'-- ' at compose time when enabled. Baking it into the fixture
double-prefixed it.
Replace all mock data in the dev JMAP server with European-themed
content: 42 emails (was 13), 20 contacts across 2 address books
(was 3), 5 calendars and 35 events with recurrence, overlaps,
and all-day entries.
Move keyword tags in email/thread list items from the subject line
to the header row alongside the date for a cleaner layout.
Update test assertions to match the new mock data.
Resizable Columns
- Add ResizeHandle component with mouse drag, keyboard (Arrow keys),
and double-click to reset to default width
- Add sidebarWidth/emailListWidth to ui-store with clamping + persistence
- Wire resize handles between sidebar/email-list panels on desktop
Navigation Rail Overhaul
- Move StorageQuotaCircle, push-status, sign-out from Sidebar to NavigationRail
- Interactive SVG ring with popover breakdown (used/free/total)
- Sidebar collapse state lifted to ui-store
- Show total email count per mailbox alongside unread badge
Email Multi-Selection & Drag-and-Drop
- Ctrl+Click (toggle) and Shift+Click (range) on all list items
- Add selectRangeEmails and lastSelectedEmailId to email-store
- Enable drag-and-drop on thread items and thread headers
- useEmailDrag accepts optional threadEmails for full-thread drag
Email Viewer Layout
- Remove card wrapper for cleaner full-width reading
- Always render HTML body when available
- Adjust skeleton loader to match flat layout
Modal & UI Polish
- Standardise backdrops, close buttons, padding, border-radius, transitions
- Migrate template-string classNames to cn() in settings
- Unify focus-ring token to ring-ring on form controls
i18n
- Add storage_used/free/total keys to all 8 locales
Dev Mock JMAP Server (new, gated by DEV_MOCK_JMAP=true)
- Session, Mailbox/Email/Thread/Identity CRUD, back-references, upload
- GET /download with Content-Disposition, GET /eventsource SSE
Tests (46 new)
- ui-store (13), email-selection (10), resize-handle (9), mock-server (14)