CalDAV scheduling: inbound iTIP UPDATE (higher SEQUENCE) is dropped. Event not rescheduled and attendee PARTSTAT not reset

Issue Description

When an organiser sends a rescheduling UPDATE for an existing meeting (same UID, SEQUENCE incremented, new DTSTART, METHOD:REQUEST), Stalwart does not process it. The attendee’s existing calendar copy keeps the old DTSTART and its PARTSTAT=ACCEPTED; the update is neither applied to the event nor placed in the scheduling inbox for the client. This occurs even though the organiser is fully authenticated (SPF+DKIM+DMARC pass) and present in the recipient’s CardDAV address book.

Expected Behavior

A REQUEST with an incremented SEQUENCE supersedes the prior version of the same UID. For a meeting already on the attendee’s calendar:

the attendee’s copy should be updated to the new DTSTART, and
because the meeting materially changed, the attendee’s PARTSTAT should reset to NEEDS-ACTION so re-acceptance is required.
This should happen regardless of autoAddInvitations — the meeting already exists, so this is an update, not a first-time add. (With autoAddInvitations=true it should auto-apply; with the default it should at least be reflected/surfaced for re-confirmation rather than silently discarded.)

Actual Behavior

Stored event keeps the SEQUENCE:1 DTSTART (old time).
PARTSTAT stays ACCEPTED (never reset to NEEDS-ACTION).
The update .ics is not in /dav/itip//inbox/ (which contains only unrelated METHOD:REPLY items).
Net effect: the reschedule is silently lost.

Reproduction Steps

  1. Receive and accept an iTIP REQUEST (SEQUENCE:1) from an authenticated, in-address-book organiser. The event appears with PARTSTAT=ACCEPTED.
  2. The organiser sends a reschedule: same UID, SEQUENCE:2, new DTSTART, METHOD:REQUEST.
  3. Inspect the stored calendar object — it still shows the SEQUENCE:1 time and PARTSTAT=ACCEPTED.

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

PostgreSQL

Blob Storage

PostgreSQL

Search Engine

Internal

Directory Backend

SQL

Additional Context

  1. Attendee REPLYs to meetings the user organises also accumulate unprocessed in /dav/itip//inbox/ over weeks (the JMAP webmail client never drains them).
  2. An outbox POST of a METHOD:REPLY to /dav/itip//outbox/ is rejected with 400 .
  3. See #2700 and #2734 — may share an underlying scheduling-processing gap.

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

I can’t reproduce the core symptom from the code as described. The inbound REQUEST merge does accept an incremented SEQUENCE (SEQUENCE:2 over a stored SEQUENCE:1 passes the comparison), the changed DTSTART lands in the merge actions, and updates to an existing event aren’t gated by autoAddInvitations (that gate is only on the new-event branch), so a matched higher-SEQUENCE REQUEST should both update the stored event and write a scheduling-inbox entry. That means the drop is happening earlier than the merge: the most likely candidates are the message being filtered/classified before it reaches iTIP ingest, the UID not matching an existing event, or an ORGANIZER mismatch against the stored copy.

Could you grab a trace-level log of the inbound REQUEST (so we can see whether iTIP ingest is reached, whether the event is matched by UID, and whether processing returns an error), plus the two .ics files (the stored one and the inbound UPDATE) so I can check the ORGANIZER values line up? On the rest: you’re right that there’s no automatic reset of the attendee’s PARTSTAT to NEEDS-ACTION on a material reschedule, and I’ll treat that as its own gap. The undrained REPLYs in the inbox and the outbox POST of METHOD:REPLY returning 400 are separate issues from the reschedule drop; happy to take those too, ideally each with a short trace.

Thanks for looking at this. You were right that the drop happens before the merge. I have now traced it, and I can show why you cannot reproduce it from the code: the message content was never the problem. Inbound iTIP ingest on my server was silently inoperative from the day I upgraded to v0.16.13 until a later container restart. The merge code appears to be fine.

##Your three hypotheses, tested against the original failing message

The failing UPDATE (Exchange/M365 organiser) is still in the mailbox, so I extracted the artifacts:

- UID mismatch: no. The UID is byte-identical across the SEQUENCE:1 invite, the SEQUENCE:2 update and the stored event.

- ORGANIZER mismatch: no. `ORGANIZER;CN=:mailto:` is identical in both .ics files and matches the stored event.

- Filtered before ingest: not as mail. Full auth pass (SPF, IPREV, DKIM, DMARC), classified ham, `message-ingest.ham` into the inbox. The MIME is ordinary Outlook shape: `text/calendar; charset=“utf-8”; method=REQUEST`, base64, inline part, Windows VTIMEZONE (`TZID=GMT Standard Time`).

## Controlled test matrix (trace-level logging on, v0.16.13, run today)

Two organiser types (Google Calendar and a different Exchange/M365 tenant), both `autoAddInvitations` states, all action types:

| Organiser | Action | autoAddInvitations | itip-message-received | Stored event |

|—|—|—|—|—|

| Google | reschedule | ON | not emitted (see note 1) | applied |

| Google | reschedule (SEQ bump) | OFF | fired | applied |

| Google | location change | OFF | fired | applied |

| Google | description change | OFF | fired | applied |

| Google | METHOD:CANCEL | OFF | fired | marked cancelled |

| Exchange | new invite | ON | fired | auto-added |

| Exchange | reschedule | ON | fired | applied |

| Exchange | reschedule | OFF | fired | applied |

| Exchange | new invite | OFF | fired | surfaced, applied on client accept |

| Exchange | reschedule of client-accepted event | OFF | fired | applied |

| Exchange | METHOD:CANCEL | OFF | fired | marked cancelled |

Everything works today, including the exact sender shape and flag state of the original failure. So the failing dimension was not content, flag or event lifecycle.

## Log analysis

`calendar.itip-message-*` events log at INFO, so their absence is meaningful. Counts per daily log file:

| Date(s) | itip-message events | Notes |

|—|—|—|

| 29 Jun / 5 Jul / 8 Jul / 9 Jul / 16 Jul | 2 / 2 / 1 / 1 / 2 | v0.16.9, processing working (16 Jul is the original invite being processed plus my REPLY going out) |

| 17 Jul | 0 | upgraded v0.16.9 to v0.16.13 |

| 17 Jul to 25 Jul | 0 every day | calendar mail kept arriving in this window: two Google invites on the 23rd and the failing Exchange UPDATE on the 24th. All ingested as plain mail, zero calendar processing |

| 25 Jul ~12:25Z | (restart) | container restart, same binary and config |

| 26 Jul | 16 | fully functional, the whole matrix above |

Conclusion: the first boot after the v0.16.13 upgrade left inbound iTIP ingest dead, and the next restart restored it. To be precise about bounds: the restore happened somewhere between 25 Jul 12:25Z and 26 Jul 14:29Z, with no calendar traffic in between to narrow it further. The only restart in that window is the 25 Jul one. The other state change that day was enabling `autoAddInvitations` in the webadmin, so a first settings write to the calendar section re-initialising something is the alternate candidate. Either way this is initialisation or runtime state, not message handling, which would explain why it does not reproduce from code. The place to look is whatever registers the calendar ingest hook on the first boot after an upgrade, on the v0.16.9 to v0.16.13 path specifically.

## Side findings from the same traces

1. Observability gap: with `autoAddInvitations` ON, a Google reschedule applied correctly but emitted no `calendar.itip-message-received`. The flag-OFF path emits it. This makes the flag-ON path invisible in logs.

2. PARTSTAT on reschedule: the applied update leaves my copy at `needs-action` because the organiser’s .ics carries `PARTSTAT=NEEDS-ACTION` for me. In practice re-confirmation is requested whenever the update path works. The separate gap you acknowledged (no server-side reset policy) only bites when ingest is dead anyway.

3. The webmail accept and outbox issues I mentioned turn out to be origin-dependent client behaviour (an Exchange-origin accept worked end to end including the outbound REPLY; a Google-origin accept fails client-side without reaching the server). I will take those to the webmail project rather than your tracker.

## Artifacts

Available on request: the stored and inbound .ics pair from the original failure (organiser details redacted consistently so equality checks survive), full trace windows for every matrix row, the per-day log counts, and the delivery-log excerpt for the original failing message. One caveat: the originally failing stored event was manually repaired via CalDAV PUT before this investigation, so its current stored .ics is post-repair. UID and ORGANIZER equality was verified via JMAP against the repaired copy.