Someone who writes to a calendar shared with them (read & write) changes
the owner's meeting there on the owner's behalf: Stalwart sends the
change from the addresses of the account the event is in. The app
decided who organizes by the user's own addresses only, so it took the
sharee for an attendee (0.16 and 1.0 alike):
- the editor wrote no participants, hid "Send invitations" and sent
nothing, so the attendees kept the old time and their answers
- "All events" asked nobody again, and "This and following events"
made the rest a new series that kept the old answers, which Stalwart
sends to nobody who had answered
The app now reads `isOrigin`, which both versions compute from the
addresses of the account the event is in: true for the owner's meeting
and every expanded occurrence of it, false for an invitation the owner
got. `userOrganizes` counts such a copy as the organizer's; an
invitation is still changed without messages. "This and following"
asks by the old series, since the new one carries no `isOrigin` yet.
The editor no longer leaves the user's own main address off the
attendees it opens with, only the organizer's (or the user's, for an
event that names none yet): a user who invited their main address to a
meeting organized under an alias lost it on the next save.
Every save in the event editor rebuilt the participants with new ids
and put every attendee back to needs-action, the user's own answer on
an invitation included. Saves now keep each attendee's id and answer;
new attendees are asked. As on the web, only an organizer who moves an
event (its start, end, all-day or recurrence rule; not a time zone
rewrite, and not a zero-length event) asks the others again.
The series scopes did not fit that:
- "All events" wrote the opened occurrence's start, length, zone and
participants onto the series: a stretched occurrence stretched every
one, and one day's decline spread to the whole series. The series now
keeps its own list and takes only the attendees the user added or
removed, and its times change only when the occurrence moved (by the
same delta), its rule only when it changed.
- "This and following" makes a new series, which Stalwart sends only to
attendees who have not answered: its attendees are asked again.
- Stalwart sends a new occurrence override, the first change to one
occurrence, only to attendees who have not answered (0.16.6 to 1.0).
A change that keeps answers now goes out in two writes: the override
as the occurrence stands without scheduling, then the change as an
update of it, to the occurrence looked up again. Checked live on 1.0
and 0.16.23.
- A save of an invitation someone else organizes writes no participants
while the user left them alone, and never asks for scheduling, so the
organizer gets no reply for a changed alert. The switch to send
invitations is hidden there.
Also:
- a reminder change on one occurrence of a series that uses the
calendar's default alerts turns the defaults off for the series first
(keeping what it shows under ids of its own); 1.0 kept showing the
defaults
- all-day and floating occurrences expanded on the device no longer
carry the master's utcStart/utcEnd, so their reminders are scheduled
for each day; master instants that are not the master's own (0.16.23's
year 32548, or the next occurrence's) are ignored
- the editor warns when the attendees and the organizer go over the
calendars capability's maxParticipantsPerEvent, and a refusal for it
(Stalwart 1.0 enforces it, answers included) is explained in the
editor, the series dialogs, the detail sheet and the invitation
banner, in all languages; a failed save used to fail silently
- refused event creates, updates and answers keep their SetError type
- Calendar/get asks for its properties by name
When the server behind an account is replaced (a fresh Stalwart 1.0
install at the address 0.16 ran on), its ids start over. The app
already dropped its caches then, but kept what the user set up with
server ids inside:
- iCal subscriptions kept the ids of their mirror calendars. The next
sync deleted the "stale" events of whatever calendar now had the id
and filled it with the feed, and "Remove subscription" destroyed it.
Now the account's subscriptions keep their feed, name, colour and
interval but lose their mirror, and the next sync makes a new one as
adding a subscription does. Renaming or removing an unlinked one
touches no calendar. Events are only pruned from, and remove only
destroys, a calendar that still carries the subscription's name.
Subscriptions now record the signed-in account, so accounts on two
servers that got the same JMAP id no longer share them.
- the preferred sender identity kept its id, which a new server can
give to another address (live: identity "b" is usera@ on 0.16.23 and
team@ on 1.0). It is forgotten for the account's old and new JMAP
id, which the app now records with the server fingerprint.
- hidden calendars kept their ids. The list is shared by all accounts,
so it is cleared.
Offline changes the drop discards are now announced once ("3 offline
changes were discarded because the mail server changed"), in all
languages.
Revoking this device from another one turns push off here again: a
subscription gone before its recorded expiry counts as revoked, as it
did before the server check came in. A replaced server forgets the
recorded expiry, so there the subscription is re-created instead.
Four smaller calendar changes that Stalwart 1.0 needs.
- Time zone: 1.0 fails every CalendarEvent get and query whose
timeZone its database lacks, with invalidArguments "Invalid timeZone".
It matches IANA names case-sensitively and knows no offsets, so a
"+01:00" or "europe/berlin" that Intl accepts empties the calendar.
0.16 reads such a zone as UTC. The zone is now checked with a get of
no events that rides along with the first calendar list; once refused,
reads ask in UTC and log one warning, and a read refused before the
check answered is repeated once in UTC. The comment that said Stalwart
ignores bad values is corrected.
- Default alerts: 1.0 fills a calendar's default alerts into the
alerts of an event that uses them. The editor wrote them back with
useDefaultAlerts false on every save, freezing today's defaults into
the event. It now writes nothing while the reminders are unchanged; a
change turns the defaults off and saves the alerts under ids of their
own, since 1.0 drops alerts saved under a default alert's id.
- Calendar colour: renaming sent the colour along, and 1.0 accepts only
#rgb, #rrggbb or a CSS name, so a calendar another client coloured
with an 8-digit hex could not be renamed. The edit sheet and the iCal
subscription settings now send only the fields that changed.
- Reply stubs: 0.16 stored an attendee's answer to one occurrence
without a title, place or description, and 1.0 builds that occurrence
from the override alone. Hydration now takes a missing title from the
series, and the place and description when the override removes the
title.
Stalwart 1.0 refuses series properties such as the organizer on a
single occurrence unless they repeat the series' value, and refuses
them inside a recurrence override on the base event. The event editor
wrote the user's first address as organizerCalendarAddress on every
save of an event with attendees, so editing one occurrence failed with
"This property cannot be modified on a single occurrence." whenever the
series was organized by someone else, under another alias or in
another letter case. Removing every attendee from one occurrence failed
the same way.
- leave series keys and per-event metadata out of changes to one
occurrence, as Stalwart's validate.rs lists them, and pointers at a
participant's calendarAddress
- write overrides on the base event as before (0.16.19 needs the
organizer there) and, when the server refuses series keys inside
them, repeat the write once without them and remember that
- answer one occurrence through its synthetic id without the organizer
- let the editor keep the organizer of an event: an invitation the
user received is no longer rewritten with the user as organizer, and
organizerCalendarAddress is only written for an event that has none
- keep the SetError type on a refused event update
0.16.20 to 0.16.23 dropped these keys on synthetic ids anyway; the
base-event override payload for older servers is unchanged.
Stalwart 1.0 refuses an expanded CalendarEvent/query that spans more
than the account's maxExpandedQueryDuration (P52W1D by default) with
expandDurationTooLarge, and one that expands to more than 3000
occurrences with cannotCalculateOccurrences. The scrolling views load
windows of up to about 460 days, and refresh() reloads the whole
window on every calendar push, after series edits and on pull to
refresh, so the calendar showed a raw error over stale events from
then on. The fallback to expanding on the device only knew 0.16's
invalidArguments.
Expanded range queries are now cut into chunks a day shorter than the
advertised limit, with overlapping borders, sent together as far as
maxCallsInRequest allows, and their ids de-duplicated. A chunk the
server still refuses is halved; if a week still expands to too many
occurrences, the whole range is expanded on the device as before.
The expanded query keeps a filter of the two bounds only: 1.0 refuses
operators there, so filtering several calendars would take a query per
calendar, and the store loads every calendar anyway. 0.16 advertises
the same limit without enforcing it and returns the same events.
Stalwart 1.0 refuses a CalendarEvent/get that asks for
recurrenceOverrides together with utcStart or utcEnd, and every event
read of the app did. The calendar showed a raw error over stale events,
and tasks, reminders, "all events" edits, contact activity, the
invitation banner's UID look-up and iCal subscriptions stopped working.
Subscriptions and imports also lost their UID check, so every sync tried
to create the whole feed again and ended in refusals.
- read a stored event's overrides with a second get in the same
request; server-expanded occurrences get them from their base event
as before
- read only ids, UIDs and calendars for the import and subscription
UID look-ups
- fail a read when either get answers with an error, so the calendar
keeps the events it has and shows the error
- keep the JMAP error type on calendar method errors (JMAPMethodError,
plus a shared jmapErrorType helper in jmap-result)
0.16 accepts the split reads and returns the same events.
Recurring series were always fetched whole and expanded on the device,
the webmail's fallback for Stalwart before 0.16.20. Newer servers
expand them with `expandRecurrences` and accept the synthetic
occurrence ids in `CalendarEvent/set`; the webmail uses that since
`lib/recurrence-instances.ts`.
- `supportsSyntheticCalendarIds` probes once per server and account
with an update of an id that cannot exist (`notFound` means
supported); the probe rides along with the first `Calendar/get`
- when supported, a range is loaded with `queryExpandedEvents`, the
occurrences are fetched with `baseEventId` and hydrated with their
base event's rules, overrides, all-day flag and duration; duplicate
listings of one occurrence are collapsed like the webmail does
- a range the server refuses to expand ("expanded recurrences
exceeds") and older servers keep the device-side expansion, which
now passes server occurrences through untouched
- changes, deletes and RSVPs resolve a synthetic id to its base event
(`resolveSeriesTarget`), including a master fetched for an
occurrence of a shared calendar, and reload the range afterwards
since synthetic ids reshuffle
- reminder keys use the base event and recurrence id, so a reshuffle
does not reschedule them
- client-only fields are stripped from `CalendarEvent/set` payloads
A partial import dropped the refused events silently: the sheet said
"Imported 3 events" for a file of five, and the server's reason never
reached the user. The webmail reports how many events failed and why.
- `batchCreateEvents` returns the refused events by index with the
server's reason instead of just the created count
- `importEvents` resolves with `{ imported, refused }`, also counting a
refused link of a UID that lives in another calendar and every event
of a chunk whose request failed; when nothing got in it rejects with
an `ImportRefusedError` carrying the list, so the invitation banner
still never reads a refusal as "Already in your calendar"
- the import sheet shows the imported count and lists each refused
event with its date and reason
`deleteEvents` never looked at the `CalendarEvent/set` answer, so a
refused delete (read-only calendar, method error) removed the event
from the screen and said nothing until the next refresh brought it
back. Clearing a calendar ignored `notDestroyed`/`notUpdated`, and an
import the server refused entirely returned 0, which the invitation
banner showed as "Already in your calendar".
- check the destroy result in `deleteEvents` and `clearCalendarEvents`
with `assertSetResult`, and the unlink result as well
- throw from `batchCreateEvents` when nothing was created and the
server refused events; a partial import still returns its count
- show an alert when deleting an event or a task fails, and refresh
after a partly failed import or clear
Accepting an invitation looked the event up in the calendar store,
which only holds the window the calendar last showed. For an event
further out the lookup came back empty, no response was sent, and the
banner still said "Added to calendar".
- find the event on the server with a `uid` filter, like webmail's
`queryCalendarEvents({ uid })`, both when the banner opens (so it
shows as already in the calendar) and after importing it
- let `rsvpEvent` take the event when it isn't in the store, so the
organizer repair still applies
- show "Could not send your response" whenever no response went out
Tasks created over CalDAV can come back with their iCalendar STATUS
spelling or as `canceled` (stalwartlabs/calcard#20). The app compared
against the JSCalendar values only, so a completed CalDAV task counted
as pending, still raised its reminder, and a tap on it completed it
instead of reopening it.
- normalize the known spellings when events are read, like webmail's
`normalizeCalendarTask` (5f1d2795), and keep unknown values as they
are
- reopen a cancelled task on tap, since the tasks sheet shows it ticked
queryEvents used to ignore its after/before arguments and ask Stalwart
for every event in the account with limit:1000. When an account holds
more than one page of events, the response was silently capped and the
grid rendered empty for the visible month — the missing events sat past
the truncation, never fetched. Meanwhile any recurring master whose
base start predates the window was returned exactly the same way, so
the client-side recurrence expander (already wired) never saw it.
Now the query sends after/before as a real filter ANDed with the
inCalendar restriction, and iterates position by page size until a
short page comes back (or 20 pages, as a bug backstop). Stalwart's
matcher accepts a master when any occurrence overlaps the window, so
the expander gets the raw event and produces the visible occurrences.
The refactor factors buildEventFilter() so the calendar / no-calendar
and window / no-window combinations produce the right filter shape
(single condition, AND, or absent). Tests updated to lock the new
filter shape and to cover the paging path (regression: 1000 events
that used to become 1000, now 1002).
Picked up from Jim Preston's uncommitted diff in the sister worktree
during the 2026-08-03 diagnostic session (artifact PkOWkT8C).