CalDAV scheduling sends invalid METHOD:ADD with RECURRENCE-ID when modifying one occurrence

Issue Description

When an organizer modifies a single occurrence of an existing recurring event through CalDAV, Stalwart sends the attendee an invalid iTIP message:

METHOD:ADD
RECURRENCE-ID:…

The newly added VEVENT override is present only in the modified calendar object, so organizer_handle_update() classifies it as a new instance and selects
ICalendarMethod::Add:

} else if instance_id != &InstanceId::Main {
let method = if matches!(
instance.comp.status(),
Some(ICalendarStatus::Cancelled)
) {
&ICalendarMethod::Cancel
} else {
&ICalendarMethod::Add
};

However, this override represents a modification of an occurrence already generated by the master event’s RRULE; it is not an additional occurrence.

RFC 5546 §3.2.4 prohibits RECURRENCE-ID in a METHOD:ADD component:

https://www.rfc-editor.org/rfc/rfc5546.html#section-3.2.4

RFC 5546 §4.4.2 represents a modified recurring occurrence as METHOD:REQUEST with RECURRENCE-ID:

https://www.rfc-editor.org/rfc/rfc5546.html#section-4.4.2

In our observed case, Stalwart delivered the generated email, but Google Calendar could not process the attached event and displayed “Unable to load event.” The changed time
was visible only by inspecting the raw ICS payload.

This was reproduced with Stalwart 0.16.19. The same branch is also present in the current upstream source.

Expected Behavior

When a new detached VEVENT override has a RECURRENCE-ID corresponding to an occurrence generated by the old calendar object’s recurrence set, Stalwart should send:

METHOD:REQUEST

The component should retain its RECURRENCE-ID, contain the moved DTSTART/DTEND, and have an appropriately advanced SEQUENCE.

METHOD:ADD should be reserved for introducing a genuinely additional occurrence, as described by RFC 5546 §3.2.4 and §4.4.6. Such an ADD component must not contain
RECURRENCE-ID.

Actual Behavior

Stalwart sends:

BEGIN:VCALENDAR
METHOD:ADD
…
BEGIN:VEVENT
UID:[email protected]
RECURRENCE-ID:20261006T090000Z
DTSTART:20261006T110000Z
DTEND:20261006T120000Z
SEQUENCE:2
…
END:VEVENT
END:VCALENDAR

The presence of RECURRENCE-ID makes this payload invalid for METHOD:ADD.

The email itself is delivered successfully, so Stalwart does not report a scheduling or SMTP error. However, the recipient’s Google Calendar cannot apply the occurrence
update.

Reproduction Steps

  1. Create a recurring event in a calendar owned by a local Stalwart user. Include an external attendee:

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Reproduction//EN
BEGIN:VEVENT
UID:[email protected]
DTSTAMP:20260903T120000Z
DTSTART:20261006T090000Z
DTEND:20261006T100000Z
RRULE:FREQ=WEEKLY;COUNT=4
SEQUENCE:0
ORGANIZER:mailto:[email protected]
ATTENDEE;RSVP=TRUE:mailto:[email protected]
SUMMARY:Recurring test
END:VEVENT
END:VCALENDAR

  1. Update the same CalDAV resource by retaining the master component and adding a detached override that moves the October 6 occurrence:

BEGIN:VEVENT
UID:[email protected]
DTSTAMP:20260903T121000Z
RECURRENCE-ID:20261006T090000Z
DTSTART:20261006T110000Z
DTEND:20261006T120000Z
SEQUENCE:1
ORGANIZER:mailto:[email protected]
ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:[email protected]
SUMMARY:Recurring test
END:VEVENT

  1. Inspect the raw scheduling email delivered to the external attendee.
  2. Observe that its calendar payload contains both:

METHOD:ADD
RECURRENCE-ID:20261006T090000Z

  1. Import or open the message in Google Calendar. In our observed case, Google Calendar reports “Unable to load event” instead of updating the occurrence.

Relevant Log Output

No error is logged because Stalwart successfully generates and delivers the message. The relevant evidence is the generated calendar payload:

BEGIN:VCALENDAR
METHOD:ADD
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
VERSION:2.0
BEGIN:VEVENT
UID:[email protected]
RECURRENCE-ID:20261006T090000Z
DTSTART:20261006T110000Z
DTEND:20261006T120000Z
SEQUENCE:2
ORGANIZER:mailto:[email protected]
ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:[email protected]
SUMMARY:Recurring test
END:VEVENT
END:VCALENDAR

Stalwart Version

v0.16.x

Installation Method

NixOS

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

Additional Context

The behavior is deterministic from the organizer diff logic: every non-cancelled component present only in the new snapshot is classified as ADD, without checking whether
its RECURRENCE-ID already belongs to the old master component’s recurrence set.

A possible fix is:

  • If the new component is an additional occurrence outside the old recurrence set, use ADD.
  • If its RECURRENCE-ID identifies an occurrence generated by the old recurrence set, use REQUEST.
  • Continue using CANCEL when the added override has STATUS:CANCELLED.

The repository’s existing RFC 5546 fixture currently expects the invalid combination, so it will also need updating:

METHOD:ADD
…
RECURRENCE-ID:19970701T210000Z

I reviewed the documentation and FAQ and searched the public support forum for METHOD:ADD, RECURRENCE-ID, single-occurrence updates, and related terms. I did not find an
existing report for this outbound scheduling defect.

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

Confirmed against current upstream source. In organizer_handle_update() (crates/groupware/src/scheduling/organizer.rs), the branch handling instance IDs not present in the old snapshot’s components map unconditionally selects ICalendarMethod::Add (or Cancel if the override’s STATUS is CANCELLED):

} else if instance_id != &InstanceId::Main {
    changed_properties.insert(&ICalendarProperty::Exdate);
    let method = if matches!(instance.comp.status(), Some(ICalendarStatus::Cancelled)) {
        &ICalendarMethod::Cancel
    } else {
        &ICalendarMethod::Add
    };
    ...

This lookup is keyed only on whether the instance_id (derived from RECURRENCE-ID) existed as a distinct override component in the previous snapshot of the calendar object. It does not check whether that RECURRENCE-ID corresponds to an occurrence already produced by the old master’s RRULE/RDATE expansion, so a detached override that simply moves an existing recurring occurrence is indistinguishable in this code path from a genuinely new added occurrence, and both get METHOD:ADD with a RECURRENCE-ID set, which is exactly the invalid combination you found (RFC 5546 §3.2.4 forbids RECURRENCE-ID on ADD).

Your diagnosis and suggested fix direction (checking the RRULE-expanded recurrence set of the old master before choosing ADD vs REQUEST) line up with what the code is doing. This is a genuine defect, not a documented/expected behavior, so it isn’t something covered in the docs to point you to.

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

Fixed in v0.16.21 (yet to be released).