Stalwart 0.16.20 (2026-08-30) added CalendarEvent/set support for updating and deleting synthetic ids — stalwartlabs/stalwart#2925, commit dfb157d4. Editing a single occurrence of a series is now something the server does, so it is something ihasmail should do.
Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
Today every entry point resolves baseEventId before touching the server, so colour, category, edit and delete all hit the whole series by design. The delete dialog says so out loud — "Delete all occurrences?" in web/src/views/calendar/CalendarContextMenu.tsx. What is missing is the other choice.
What the server now does
Read off crates/jmap/src/calendar_event/set.rs at v0.16.20:
Update at a synthetic id merges the patch into recurrenceOverrides[recurrenceId]. If the patch omits start or duration, the server fills them in from the expansion, so an override always carries its own timing.
Destroy at a synthetic id writes recurrenceOverrides[recurrenceId] = { "excluded": true }. The occurrence disappears; the series survives.
The recurrenceId key is matched against the existing override keys first, by naive timestamp or by resolving the local time in the event's zone, and only synthesised when the instance has no override yet.
Three refusals to design around
A base event and its instances cannot be modified in the same request. Sending both yields invalidProperties on id with "A base event and its instances cannot be modified in the same request." Two ids for the same event in one update map is "Duplicate event id." So "this occurrence" and "the whole series" have to be separate calls.
Occurrences of a RANGE=THISANDFUTURE override cannot be modified individually — "Occurrences of a this-and-future change cannot be modified individually." Nothing we write creates one of these today, but an event synced in from another client can carry one, so the UI needs to survive the refusal.
A set of properties is rejected outright per occurrence, with "This property cannot be modified on a single occurrence.": baseEventId, calendarIds, isDraft, isOrigin, utcStart, utcEnd, useDefaultAlerts, mayInviteSelf, mayInviteOthers, hideAttendees.
There is a fourth list, and it fails silently. is_inherited_property — type, method, organizerCalendarAddress, privacy, prodId, recurrenceId, recurrenceIdTimeZone, sentBy, uid, recurrenceOverrides, recurrenceRule, relatedTo — returns Ok(false), which means those keys are dropped from the patch and the response still says the update succeeded. A pointer patch at participants/{key}/calendarAddress is dropped the same way.
This is exactly how #26 got as far as a live server: a participant map addressed by the RFC's spelling was discarded without an error, and a client that trusts a successful response showed the guests as saved. web/src/views/calendar/EventEditor.tsx builds its patch by spreading every field of the form object, privacy and recurrenceRule among them, so aimed at an occurrence it would report a save and quietly discard several of them.
So the per-occurrence patch has to be built from an allow-list rather than by spreading the form, and anything in the inherited list has to be either withheld from the occurrence UI or routed to the series.
RSVP is unaffected: participants/{key}/participationStatus and participationComment pass the validator, so rsvp() in web/src/store/calendar.ts keeps working whether it is aimed at the base or at an occurrence. Aiming it at an occurrence is now a real option — an RSVP that applies to one date rather than the series — but that is a separate decision, not part of this.
Work
A scope prompt on edit and delete of a recurring event: this occurrence, or the whole series. The delete confirm already asks a question, so it grows a third button rather than a new dialog.
Per-occurrence patches built from an allow-list, never by spreading the editor's form object.
store/calendar.ts learns to send a synthetic id deliberately, which depends on the resolution being explicit rather than incidental — see the companion issue.
Handle the this-and-future refusal by falling back to the series with the reason shown, rather than a bare error toast.
Mock: the mock does not expand recurrences at all today, so per-occurrence editing cannot be developed or demoed against it. It needs enough expansion to hand out synthetic ids, and it needs to refuse what the real server refuses — including the silent drop, since a mock that accepts an inherited property agrees with the belief that would ship.
Verify live against 0.16.20: an occurrence edited, an occurrence deleted, the series intact afterwards, and the override surviving a re-read. Nothing here counts as confirmed on the mock alone.
Requires Stalwart 0.16.20. On 0.16.19 and below a synthetic id is refused outright — "Updating synthetic ids is not yet supported" / "Deleting synthetic ids is not yet supported" — so the scope prompt has to be gated on the server generation, or the feature waits until 0.16.20 is the floor.
Rebuilt from: session transcript.
Stalwart 0.16.20 (2026-08-30) added `CalendarEvent/set` support for updating **and** deleting synthetic ids — [stalwartlabs/stalwart#2925](https://github.com/stalwartlabs/stalwart/issues/2925), commit `dfb157d4`. Editing a single occurrence of a series is now something the server does, so it is something ihasmail should do.
This retires the line in [KNOWN-ISSUES.md](KNOWN-ISSUES.md):
> Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
Today every entry point resolves `baseEventId` before touching the server, so colour, category, edit and delete all hit the whole series by design. The delete dialog says so out loud — `"Delete all occurrences?"` in `web/src/views/calendar/CalendarContextMenu.tsx`. What is missing is the other choice.
## What the server now does
Read off `crates/jmap/src/calendar_event/set.rs` at v0.16.20:
- **Update at a synthetic id** merges the patch into `recurrenceOverrides[recurrenceId]`. If the patch omits `start` or `duration`, the server fills them in from the expansion, so an override always carries its own timing.
- **Destroy at a synthetic id** writes `recurrenceOverrides[recurrenceId] = { "excluded": true }`. The occurrence disappears; the series survives.
- The `recurrenceId` key is matched against the existing override keys first, by naive timestamp or by resolving the local time in the event's zone, and only synthesised when the instance has no override yet.
## Three refusals to design around
1. **A base event and its instances cannot be modified in the same request.** Sending both yields `invalidProperties` on `id` with *"A base event and its instances cannot be modified in the same request."* Two ids for the same event in one `update` map is *"Duplicate event id."* So "this occurrence" and "the whole series" have to be separate calls.
2. **Occurrences of a `RANGE=THISANDFUTURE` override cannot be modified individually** — *"Occurrences of a this-and-future change cannot be modified individually."* Nothing we write creates one of these today, but an event synced in from another client can carry one, so the UI needs to survive the refusal.
3. **A set of properties is rejected outright per occurrence**, with *"This property cannot be modified on a single occurrence."*: `baseEventId`, `calendarIds`, `isDraft`, `isOrigin`, `utcStart`, `utcEnd`, `useDefaultAlerts`, `mayInviteSelf`, `mayInviteOthers`, `hideAttendees`.
## The trap, and it is the shape of #26
There is a fourth list, and it fails *silently*. `is_inherited_property` — `type`, `method`, `organizerCalendarAddress`, `privacy`, `prodId`, `recurrenceId`, `recurrenceIdTimeZone`, `sentBy`, `uid`, `recurrenceOverrides`, `recurrenceRule`, `relatedTo` — returns `Ok(false)`, which means those keys are **dropped from the patch and the response still says the update succeeded**. A pointer patch at `participants/{key}/calendarAddress` is dropped the same way.
This is exactly how #26 got as far as a live server: a participant map addressed by the RFC's spelling was discarded without an error, and a client that trusts a successful response showed the guests as saved. `web/src/views/calendar/EventEditor.tsx` builds its patch by spreading every field of the form object, `privacy` and `recurrenceRule` among them, so aimed at an occurrence it would report a save and quietly discard several of them.
So the per-occurrence patch has to be built from an allow-list rather than by spreading the form, and anything in the inherited list has to be either withheld from the occurrence UI or routed to the series.
RSVP is unaffected: `participants/{key}/participationStatus` and `participationComment` pass the validator, so `rsvp()` in `web/src/store/calendar.ts` keeps working whether it is aimed at the base or at an occurrence. Aiming it at an occurrence is now a real option — an RSVP that applies to one date rather than the series — but that is a separate decision, not part of this.
## Work
- [ ] A scope prompt on edit and delete of a recurring event: this occurrence, or the whole series. The delete confirm already asks a question, so it grows a third button rather than a new dialog.
- [ ] Per-occurrence patches built from an allow-list, never by spreading the editor's form object.
- [ ] `store/calendar.ts` learns to send a synthetic id deliberately, which depends on the resolution being explicit rather than incidental — see the companion issue.
- [ ] Handle the this-and-future refusal by falling back to the series with the reason shown, rather than a bare error toast.
- [ ] Mock: the mock does not expand recurrences at all today, so per-occurrence editing cannot be developed or demoed against it. It needs enough expansion to hand out synthetic ids, and it needs to refuse what the real server refuses — including the silent drop, since a mock that accepts an inherited property agrees with the belief that would ship.
- [ ] Verify live against 0.16.20: an occurrence edited, an occurrence deleted, the series intact afterwards, and the override surviving a re-read. Nothing here counts as confirmed on the mock alone.
Requires Stalwart 0.16.20. On 0.16.19 and below a synthetic id is refused outright — *"Updating synthetic ids is not yet supported"* / *"Deleting synthetic ids is not yet supported"* — so the scope prompt has to be gated on the server generation, or the feature waits until 0.16.20 is the floor.
<sub>Rebuilt from: session transcript.</sub>
This repo is archived. You cannot comment on issues.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Stalwart 0.16.20 (2026-08-30) added
CalendarEvent/setsupport for updating and deleting synthetic ids — stalwartlabs/stalwart#2925, commitdfb157d4. Editing a single occurrence of a series is now something the server does, so it is something ihasmail should do.This retires the line in KNOWN-ISSUES.md:
Today every entry point resolves
baseEventIdbefore touching the server, so colour, category, edit and delete all hit the whole series by design. The delete dialog says so out loud —"Delete all occurrences?"inweb/src/views/calendar/CalendarContextMenu.tsx. What is missing is the other choice.What the server now does
Read off
crates/jmap/src/calendar_event/set.rsat v0.16.20:recurrenceOverrides[recurrenceId]. If the patch omitsstartorduration, the server fills them in from the expansion, so an override always carries its own timing.recurrenceOverrides[recurrenceId] = { "excluded": true }. The occurrence disappears; the series survives.recurrenceIdkey is matched against the existing override keys first, by naive timestamp or by resolving the local time in the event's zone, and only synthesised when the instance has no override yet.Three refusals to design around
invalidPropertiesonidwith "A base event and its instances cannot be modified in the same request." Two ids for the same event in oneupdatemap is "Duplicate event id." So "this occurrence" and "the whole series" have to be separate calls.RANGE=THISANDFUTUREoverride cannot be modified individually — "Occurrences of a this-and-future change cannot be modified individually." Nothing we write creates one of these today, but an event synced in from another client can carry one, so the UI needs to survive the refusal.baseEventId,calendarIds,isDraft,isOrigin,utcStart,utcEnd,useDefaultAlerts,mayInviteSelf,mayInviteOthers,hideAttendees.The trap, and it is the shape of #26
There is a fourth list, and it fails silently.
is_inherited_property—type,method,organizerCalendarAddress,privacy,prodId,recurrenceId,recurrenceIdTimeZone,sentBy,uid,recurrenceOverrides,recurrenceRule,relatedTo— returnsOk(false), which means those keys are dropped from the patch and the response still says the update succeeded. A pointer patch atparticipants/{key}/calendarAddressis dropped the same way.This is exactly how #26 got as far as a live server: a participant map addressed by the RFC's spelling was discarded without an error, and a client that trusts a successful response showed the guests as saved.
web/src/views/calendar/EventEditor.tsxbuilds its patch by spreading every field of the form object,privacyandrecurrenceRuleamong them, so aimed at an occurrence it would report a save and quietly discard several of them.So the per-occurrence patch has to be built from an allow-list rather than by spreading the form, and anything in the inherited list has to be either withheld from the occurrence UI or routed to the series.
RSVP is unaffected:
participants/{key}/participationStatusandparticipationCommentpass the validator, sorsvp()inweb/src/store/calendar.tskeeps working whether it is aimed at the base or at an occurrence. Aiming it at an occurrence is now a real option — an RSVP that applies to one date rather than the series — but that is a separate decision, not part of this.Work
store/calendar.tslearns to send a synthetic id deliberately, which depends on the resolution being explicit rather than incidental — see the companion issue.Requires Stalwart 0.16.20. On 0.16.19 and below a synthetic id is refused outright — "Updating synthetic ids is not yet supported" / "Deleting synthetic ids is not yet supported" — so the scope prompt has to be gated on the server generation, or the feature waits until 0.16.20 is the floor.
Rebuilt from: session transcript.