Recurring events defaulted skip:"omit"+rscale:"gregorian" on every rule,
which Stalwart serialises into the CalDAV RRULE as RSCALE=GREGORIAN;SKIP=OMIT.
DAVx5 rejects that ("Invalid recurrence rule"), breaking Android sync. Those
RFC 7529 fields only matter for non-Gregorian scales / invalid-date handling,
so stop emitting them for plain Gregorian rules.
- lib/recurrence-rule.ts: createRecurrenceRule() helper with no skip/rscale.
- CalendarRecurrenceRule.rscale/skip are now optional.
- event-modal + recurrence-editor build rules via the helper.
- demo fixture cleaned; unit test asserts no skip/rscale.
Closes#805.
Stalwart lazily creates an account's default calendar on the first request
that touches it. Bulwark's login fires Calendar/get and CalendarEvent/query
concurrently, and on multi-node servers each node can create its own default,
leaving users with two identical "Personal" calendars. Hold later calendar and
address-book requests per account until the first one settles.
addToast() minted toast ids with crypto.randomUUID(), which only exists in
secure contexts (https/localhost). On a plain-http LAN origin every
toast.success/toast.error threw a TypeError, so e.g. calendar event Save
crashed AFTER a successful CalendarEvent/set — before the dialog-close ran —
leaving the dialog open reporting failure while the server had the event;
retries created duplicates. Same class as the generateMessageId send bug.
Also guards the same-class contact uid fallback in lib/jmap/client.ts
(createContact). Both now use the guarded generateUUID() from lib/utils.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtFkcUA8VM2XAKZKzptpqy
A delayed send composed from a shared/group identity is created in the
*shared* JMAP account, because sendEmail() passes the target account to
getSubmissionAccountId(). Every read/write on the scheduled surface,
however, asked the primary submission account only:
getScheduledEmails / cancelEmailSubmission / rescheduleEmailSubmission
-> getSubmissionAccountId() // no argument = primary account
So a message scheduled from a shared address is accepted and stays
pending on the server — it *will* be delivered at sendAt — while the UI
cannot list it, cancel it or reschedule it. Cancelling appears to work:
EmailSubmission/set against the primary account answers `notFound` in
notUpdated, which the client never surfaced, and the mail goes out
anyway. Silent unrecallable mail is worse than a failed send.
Stalwart advertises urn:ietf:params:jmap:submission (with FUTURERELEASE
and a non-zero maxDelayedSend) on group accounts as well as personal
ones, so scheduling from a shared address is legitimate.
Data layer:
- getSubmissionAccountIds() enumerates every submission-capable account;
getScheduledEmails() queries each, tags every result with the account
that owns it, and fetches each submission's Email from that same
account (it lives beside the submission, not in the primary account).
It also returns per-account totals computed over all pending
submissions, so a badge stays correct past the first page.
- resolveSubmissionAccountId() locates a submission's owning account so
cancel/reschedule (and the sendAt/envelope helpers) act on the right
one. Callers that know the account pass it and skip the lookup.
- sendEmail() reports the submission's account in SendEmailResult, and
the pending-undo record carries it, so the "Undo send" / "Send now"
toast actions — which know only a submission id — address the right
account directly rather than searching for it inside the undo window.
- The delayed-send gate considers any reachable submission account
rather than only the primary one.
UI:
- Each shared account gets its own "Scheduled" sidebar row, scoped to
that account; the own row keeps showing the combined view. The scope
travels beside the virtual mailbox id, so the store's single
__scheduled__ id is unchanged, and it also drives which row renders as
selected and whether the scoped list has more to load.
Tested against a real Stalwart (integration/tests/11-shared-scheduled-send):
two server-truth tests pin the mechanism (the submission is invisible to
the primary account, and cancelling through it is a silent no-op) and two
UI tests cover the fix. All three UI-level assertions — the combined
list, the scoped row, and which row is selected — fail without this
change.
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.
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.
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.
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.
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.
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.
Opening a message with a calendar invitation printed the whole parsed
event to the console on every render - title, description, attendees and
location included - through a leftover `[PARSE DEBUG]` console.log.
The surrounding file already logs via debug.log('calendar', ...) in 22
places, which honours the debugMode setting and its category filters, so
this one line was the outlier. It also ran JSON.stringify with
indentation unconditionally, even though nothing was listening.
Passing the object to debug.log instead leaves the formatting to the
logger, so nothing is serialised unless the calendar debug category is
actually switched on.
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
supportsContacts and supportsCalendars checked the server-wide session
capability. An account with jmap-contact-* / dav-card-* revoked still got
Contacts, and one with jmap-calendar-* / dav-cal-* revoked still got
Calendars, with every action failing on an authorization error.
The explicit Email/set destroy workaround for the duplicate-on-move bug is
removed now that the root cause is filed upstream (support.stalw.art #1150:
onSuccessDestroyOriginal destroys the copy's create-id instead of the source
id). copyEmailAcrossAccounts keeps requesting onSuccessDestroyOriginal, so the
move self-heals once Stalwart ships the fix.
Kept: the keyword-preservation fix (carry the source keywords into Email/copy)
so the moved message keeps its read state.
Tests: 08-shared-moves still asserts delivery + read-state on every
cross-account case; the source-removal checks are re-pinned test.fail, scoped
to a nested describe, until #1150 is fixed. Suite green (5 pass, 3 expected-fail).
Moving a message across the account boundary (own ↔ shared folder, or
between two owners' shared folders) left the original in the source
folder and showed the moved copy as unread. Same-account moves were fine.
Cause (verified against a live Stalwart):
- Email/copy drops keywords unless the create sets them, so the copy lost
$seen and arrived unread.
- onSuccessDestroyOriginal is unreliable — the implicit destroy reports
notFound and leaves the original behind (flaky), so the move duplicated.
Fix (copyEmailAcrossAccounts): read the source keywords and carry them
into the Email/copy create, then destroy the original with an explicit
Email/set on the source account instead of onSuccessDestroyOriginal.
Tests: 08-shared-moves now asserts the source is gone and the read state
survives on every cross-account case, and adds a cross-owner shared →
shared move (alice's folder → bob's folder). Confirmed red on the old
code (3 cross-account cases fail), green with the fix.
Moving a message to a folder in another account (own ↔ delegated/shared) via the
"Move to" context menu was a no-op — the handlers always issued a single-account
Email/set, which can't move between JMAP accounts. Drag-and-drop already routed
these correctly; the context menu never did.
Add moveToMailboxCrossAware: it detects a cross-account destination (own and
shared mailboxes both carry accountId) and routes through the drag-and-drop
crossAccountMoveEmails pipeline, else falls back to the single-account move.
Fix the pipeline for delegated folders too: a client can't stage a blob in a
delegated account (Blob/upload → blobNotFound), so importing into a shared folder
failed. When one client reaches both accounts, use a server-side JMAP Email/copy
(+ destroy original) instead of blob copy+import; the blob path is kept only for
separate cross-server login accounts. Adds client.copyEmailAcrossAccounts.
Unit tests for the dispatch; the two 08-shared-moves specs are un-pinned. Full
docker integration suite green (37 passed).