All-day recurring event is replaced by the occurrence override (JMAP)

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:

  1. patch the occurrence with its own synthetic id (from CalendarEvent/query with
    expandRecurrences: true);
  2. write the complete recurrenceOverrides map on the base event;
  3. a JSON pointer patch recurrenceOverrides/<recurrenceId>/<property>;
  4. a create that already has one entry in recurrenceOverrides.

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, start and duration;
  • 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.

  1. 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.

  1. 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" } }.

  1. 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.

  1. 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.

  2. 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.

  1. 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 in CalendarEvent/get.
  • Maybe related, we work around it separately: an all-day event only stays all-day when
    the write has a midnight start and a whole-day duration (P1D). With PT24H, 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 of DTSTART;VALUE=DATE in 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 (the CalendarEvent entries there
    are about CalendarEvent/get and about useDefaultAlerts), 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

This looks like a genuine bug in how Stalwart converts an all-day recurring event’s recurrenceOverrides back into iCalendar. Looking at the JMAP set logic, the override patch itself is applied correctly to the recurrenceOverrides map in the JSCalendar representation (the code that resolves the override key and inserts the patch treats all-day and timed events the same way). The divergence you’re seeing must happen afterward, in the JSCalendar-to-iCalendar serialization step (the into_icalendar() conversion, which lives in the calcard library that Stalwart depends on rather than in the JMAP handler code itself), where a DTSTART;VALUE=DATE override appears to be mishandled and ends up overwriting the master VEVENT instead of being emitted as a separate override instance with RECURRENCE-ID;VALUE=DATE.

I can’t find anything in the current docs or in the JMAP-layer source that explains or excuses this, and your reproduction steps are precise and consistent with the behavior described in RFC 8984 not being honored for all-day series. This is not something a config change or workaround at the docs level can fix.

This isn’t covered in the current docs. A community member or staff member will follow up.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

Stalwart is on draft-ietf-calext-jscalendarbis-20 - JSCalendar 2.0: A JSON Representation of Calendar Data - make sure your requests follow that spec

Anyway, the issue you are describing is not related to JSCalendar-bis (although you should use as a reference instead of the RFC). The bug has been fixed in the v1.0.0 branch as part of a large calendaring refactor. v1.0 is expected at the end of October or early November.