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.

Based on the trace, the conclusion that this is not a message-content problem is right. One correction to the mechanism though: the calendar scheduling settings cannot be left unset or false by a migration, because the loader falls back to the struct default on any read or validation error and that default has scheduling enabled. I also diffed the iTIP ingest path between 0.16.9 and 0.16.13 and it is byte-identical, so the upgrade itself changed nothing there.

Inbound iTIP is gated on four things: the scheduling enable flag, the message not being classified as spam, the sender being authenticated, and the recipient holding the calendar scheduling receive permission. Only the first is cached at boot, which makes your alternate candidate the stronger one: saving the calendar section in the admin UI writes the whole object, so if the enable flag had been stored as false it would have been flipped true by the same save that turned on autoAddInvitations. Can you check what that flag reads now, and whether you recall it being off before the 25 July change? Worth checking the receiving account’s role for the scheduling receive permission at the same time.

On your side finding 1: the received event is emitted on every successful ingest regardless of autoAddInvitations, so the flag-on path is not invisible in the way you describe. What is genuinely invisible is the opposite case: when ingest deliberately discards a message (nothing to send, or another scheduling agent owns it) nothing is logged at any level, which is a gap worth closing since it is exactly what would have shortened this investigation.

Answering both questions.

What the flag reads now, checked against the live server rather than from memory:

Enable Scheduling: Yes
Automatically Add Invitations: No

The User role carries calendarSchedulingReceive with no disabling override, and my account
runs on User role inherited permissions, so the receive permission is held.

Whether it was off before the 25 July change: my recollection is that it was on. The logs
agree, and I think they rule the flag out as the cause.

These are the Calendar iTIP events still retained on the server, oldest first:

2026-06-29 10:29:02Z sent
2026-06-29 10:29:46Z received
2026-07-05 21:55:45Z sent
2026-07-05 21:58:51Z sent
2026-07-08 19:06:01Z sent
2026-07-09 13:50:16Z received
2026-07-16 00:41:10Z sent
2026-07-16 00:41:47Z received
… nothing at all …
2026-07-26 14:37:50Z received
(17 further events on 26 July, the controlled matrix)
2026-08-03 17:33:29Z received
2026-08-03 22:31:45Z sent

Three of the pre-upgrade events are inbound: 29 June, 9 July, 16 July. Inbound ingest does
not run with scheduling disabled, so the flag was effectively on across that period, which
is nine days before the 25 July admin UI save.

Between 17 and 25 July inclusive there are zero events in either direction, while calendar
mail kept arriving, including the 24 July UPDATE that started this thread.

So for the save-flips-the-flag theory to hold, something would have had to set enable false
between 16 and 17 July. You have said a migration cannot do that, and I did not touch the
setting in that window. The only toggling I did was during testing on 25 and 26 July, by
which point the outage had already been running eight days.

One thing I cannot settle from the logs. The container restart and the admin UI save both
happened on 25 July, and the first iTIP event after either is 26 July 14:37Z, so the
ordering does not tell you which one restored ingest. It is the pre-17 July evidence that
rules out the flag, not the restore ordering.

On the unlogged deliberate discard, agreed that is worth closing. It would have shortened
this a lot. What I had to work with was silence, and silence looks identical to mail that
never arrived.

Regarding tour flag argument, three inbound events in the nine days before the outage settle it, and since the loader falls back to a struct default of enabled, nothing could have set it false implicitly anyway. The flag is out.

On the received event I’ll stand by the emit site, but I think I can reconcile it with what you observed. It fires on every successful ingest with no reference to autoAddInvitations. What I didn’t mention, and should have, is a fifth gate that sits upstream of everything we’ve discussed: ingest only looks at a MIME part whose Content-Type is text/calendar and which carries a method parameter. A calendar payload delivered as application/ics, or as text/calendar with no method=, never enters the loop, and nothing is logged at any level. That produces precisely the signature you have, which is mail ingested normally, zero calendar processing, and total silence. Could you check the Content-Type line of the calendar part on the 23 July Google invites and the 24 July Exchange UPDATE against one that worked on the 26th?

On the silent discards: NothingToSend and OtherSchedulingAgent are the only two that log nothing, every other error emits an event. That’s getting closed.

Unfortuntately I still can’t explain an eight-day outage from the code. Nothing in this path is cached per boot except the config object itself, and both a container restart and an admin UI save refresh that, which is exactly why 25 July can’t be untangled from the logs. One more thing worth checking on your side: does the receiving account get calendarSchedulingReceive through inherited default-role permissions rather than explicitly? 0.16.15 fixed a bug where id references on defaultAdminRoleIds and similar fields failed to resolve, and an unresolved default role would strip inherited permissions at boot until something forced a reload. It’s a long shot, since it would have stripped most other permissions too, but it’s the only boot-scoped path I can find that silently drops that permission.

Thanks, that fifth gate was worth checking and I have now done it on both messages.

The 23 July Google invite carries Content-Type: text/calendar; charset="UTF-8"; method=REQUEST, 7bit. The 24 July Exchange update carries Content-Type: text/calendar; charset="utf-8"; method=REQUEST, base64. Both are text/calendar and both carry method=, so neither was skipped for the reason you describe.

There is a sharper comparison available. The 16 July invite from the same sender, same Exchange tenant, has a byte-identical Content-Type line to the 24 July update that failed. One was ingested, one was not.

That points away from message shape entirely, and the logs agree. Counting calendar.* events per day on my server: 2 on 16 July, then 0 on 23 July, 0 on 24 July, 0 on 25 July, then 17 on 26 July. Calendar mail arrived on 23 and 24 July, from Google and from Exchange respectively, and neither produced a single calendar event. Both shapes worked before that window and both worked after it. The window opens at the 17 July upgrade to 0.16.13 and closes at a container restart on 25 July.

So I think your default-role question is the more promising one. My receiving account gets calendarSchedulingReceive through the inherited User role rather than an explicit grant, which is the condition you described, and I am on 0.16.13, below the 0.16.15 that fixed it. A fault that starts at a boot, is silent throughout, and clears on a reload matches what I actually saw.

Two things would help me.

Would you expect 0.16.15 or later to fix this outright? I am happy to upgrade, but if you want any diagnostics captured from 0.16.13 first, tell me and I will get them before I touch it.

And if there is anything else you want tested, please spell out the steps. I run this server myself but I am not a deep expert in it, so I would rather be told exactly what to run than guess and send you something misleading.

That fix covers defaultAdminRoleIds, and your account is a User-role account, which resolves through a different field that was already handled before that release. It is also request-time id resolution rather than anything computed at boot, and an unresolved role would have errored the token build and deferred your mail rather than quietly skipping calendar processing. So no, 0.16.15 will not fix this by that mechanism, and there is no reason to hold off upgrading to preserve diagnostics.

There is one gate in the chain neither of us has checked against server-side data, and it fits your evidence better than anything so far. Inbound iTIP requires the sender to be authenticated, and for an external sender that means a DMARC pass specifically, not SPF or DKIM individually. If that check stops returning a pass, the entire calendar block is skipped with nothing logged at any level, which is the silence you have. Your window opens at the 0.16.13 upgrade, which contains a DMARCbis implementation and a major bump of the authentication library, and resolver state lives in the process, which is the only thing I have found that explains both an eight-day outage and a restart ending it.

Two things to run, and both use log files you already have. First, count smtp.dmarc-pass and smtp.dmarc-fail per day across 16, 23, 24, 25 and 26 July, exactly the way you counted the calendar events. Both log at INFO, so a flip to fail, or their absence, in that window is meaningful. Second, pull the Authentication-Results header off the stored 23 July Google invite and the 24 July Exchange update and look at the dmarc= token specifically, then compare with the 16 July and 26 July messages that worked. Your earlier note said full auth pass; I want that pinned to the DMARC token rather than to SPF, DKIM and IPREV.

Do those two before you upgrade, since those log files are the only surviving record of the window, then upgrade regardless.