Issue Description
I have a problem with all-day recurring events over JMAP.
When I write a change for one occurrence of an all-day recurring event, the change is not
applied to that occurrence only. Instead the base event is replaced by the override. The
event loses its title and its recurrenceRule, the start is moved to the date of the
override, and the series becomes one single all-day event.
CalendarEvent/set gives no error. The event is returned in updated and there is
nothing in notUpdated.
With a timed recurring event the same requests work correctly. Only the occurrence
changes and the series stays. The only difference between the two cases is
showWithoutTime: true, so I think the problem is in the whole-day storage (in iCalendar
DTSTART;VALUE=DATE).
I tried four different ways to write the change. All four give the same result:
- patch the occurrence with its own synthetic id (from
CalendarEvent/querywith
expandRecurrences: true); - write the complete
recurrenceOverridesmap on the base event; - a JSON pointer patch
recurrenceOverrides/<recurrenceId>/<property>; - a
createthat already has one entry inrecurrenceOverrides.
Cancellations are no problem. An override with {"excluded": true} works correctly in all
four ways. Only overrides with content (title, start, duration, location, …) break the
event.
For context: we have a groupware application that syncs calendars to Stalwart with JMAP,
and from there to phones with ActiveSync. So when a user renames or moves one day of an
all-day series, the complete series is gone. For now we block these writes in our own
client, which means such a change never arrives at the server or at the device.
Expected Behavior
RFC 8984 (JSCalendar) section 4.3.3 says that recurrenceOverrides maps a recurrence id
to a PatchObject, and that this patch is only for that one occurrence.
So after the write I expect:
- the base event still has its
title,recurrenceRule,startandduration; - expanding the series still gives all occurrences;
- only the occurrence from the override has the new values.
This is also what Stalwart does for a timed recurring event.
Actual Behavior
After the write, CalendarEvent/get on the base event gives:
title: the title from the override, or empty when the override has no title;recurrenceRule:null;start: moved to the date of the override;recurrenceOverrides: empty.
CalendarEvent/query with expandRecurrences: true then gives one single all-day event
instead of the series.
The CalendarEvent/set itself reports success: the event is in updated, there is no
notUpdated and no error. So the series is lost without any warning.
Reproduction Steps
The JSON below is the methodCalls payload. and are the account
and the calendar that I write to.
- Create an all-day daily series.
["CalendarEvent/set", {
"accountId": "<accountId>",
"create": {
"series": {
"calendarIds": { "<calendarId>": true },
"title": "All-day series",
"start": "2026-03-09T00:00:00",
"duration": "P1D",
"showWithoutTime": true,
"timeZone": null,
"recurrenceRule": { "@type": "RecurrenceRule", "frequency": "daily", "count": 5 }
}
}
}, "c0"]
Check with CalendarEvent/get that the event comes back with showWithoutTime: true and
with the recurrence rule.
- Change one occurrence, with one of these four ways:
Way A; the complete override map on the base event:
["CalendarEvent/set", {
"accountId": "<accountId>",
"update": {
"<baseEventId>": {
"recurrenceOverrides": {
}
}
}
}, "c0"]
Way B; a JSON pointer patch in the map:
["CalendarEvent/set", {
"accountId": "<accountId>",
"update": {
"<baseEventId>": {
"recurrenceOverrides/2026-03-11T00:00:00/title": "Changed day"
}
}
}, "c0"]
Way C; the occurrence with its own id. First I look it up:
["CalendarEvent/query", {
"accountId": "<accountId>",
"filter": { "after": "2026-03-10T23:59:00Z", "before": "2026-03-11T00:01:00Z" },
"expandRecurrences": true,
"calculateTotal": true
}, "q0"],
["CalendarEvent/get", {
"accountId": "<accountId>",
"#ids": { "resultOf": "q0", "name": "CalendarEvent/query", "path": "/ids" },
"properties": ["baseEventId", "recurrenceId"]
}, "g0"]
Then I patch the id where baseEventId is the series and recurrenceId is
2026-03-11T00:00:00:
["CalendarEvent/set", {
"accountId": "<accountId>",
"update": { "<occurrenceId>": { "title": "Changed day" } }
}, "c0"]
Way D; a create that already has an override: the same body as step 1, plus
"recurrenceOverrides": { "2026-03-11T00:00:00": { "title": "Changed day" } }.
- Read the base event again.
["CalendarEvent/get", { "accountId": "<accountId>", "ids": ["<baseEventId>"] }, "c0"]
The title is now “Changed day”, recurrenceRule is null, start is
2026-03-11T00:00:00, and the series is gone.
-
Test to compare, with a timed event. The same steps, but with
"start": "2026-03-09T09:00:00", "duration": "PT1H", "showWithoutTime": false, "timeZone": "Europe/Amsterdam". Now the override is applied to the occurrence only and
the series stays, in all four ways. -
Test to compare, with an exclusion on the all-day series. Use
{ "excluded": true }as
override. This works correctly: the day is gone and the series stays.
the series stays, in all four ways.
- Test to compare, with an exclusion on the all-day series. Use
{ "excluded": true }as
override. This works correctly: the day is gone and the series stays.
Relevant Log Output
There is no error in the log, because the request is successful. This is the response of
step 2, way A:
["CalendarEvent/set", {
"accountId": "<accountId>",
"updated": { "<baseEventId>": null },
"notUpdated": null
}, "c0"]
Stalwart Version
v0.16.x
Installation Method
Docker
Database Backend
RocksDB
Blob Storage
RocksDB
Search Engine
Internal
Directory Backend
Internal
Additional Context
- Client: we use our own PHP JMAP client, but the requests above are normal JMAP. They
also work with curl against /jmap, there is nothing client specific in them. - The phones use EAS to an ActiveSync gateway (based on Z-Push) in front of the JMAP API.
But the problem is already in JMAP, before a device is involved, and you can see it
directly inCalendarEvent/get. - Maybe related, we work around it separately: an all-day event only stays all-day when
the write has a midnightstartand a whole-dayduration(P1D). WithPT24H, or
with a start that is not midnight, the event is accepted without error but stored with
showWithoutTime: false, so it becomes a timed event. I can understand this one,
because ofDTSTART;VALUE=DATEin iCalendar, and we normalise for it. I only mention it
because it is also about whole-day storage. - I tested on 0.16.21, all four ways. I did not test again on 0.16.22, but in the
changelog I see no change for the calendar write path (theCalendarEvententries there
are aboutCalendarEvent/getand aboutuseDefaultAlerts), so I do not expect a
difference. If it helps, I can test it again on 0.16.22.
I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.
on
I have searched this support forum (open and closed topics) and confirm this is not a duplicate.
on
I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.
on