Issue Description
CalendarEvent/set accepts an ifInState argument, parses it, and then never compares it to the current state. A client that supplies a superseded state gets its write applied, where RFC 8620 §5.3 requires the method to be aborted with a stateMismatch error:
ifInState: (type:
String|null) This is a state string as returned by theFoo/getmethod. If supplied, the string must match the current state, otherwise the method will be aborted and astateMismatcherror returned.
Email/set implements this correctly. So does CalendarEvent/copy. Only CalendarEvent/set is missing the call, which is what makes this look like an oversight at the call site rather than a deliberate choice.
Impact: JMAP calendar writes have no lost-update protection at all. This bites harder for calendars than it may appear, because a JMAP CalendarEvent carries no per-object revision: no ETag, no changeKey. ifInState is therefore the only concurrency guard the protocol offers a calendar client. With it unenforced, two clients editing the same event silently produce last-writer-wins, and the loser’s edit disappears with no error and no way to detect it. The same server protects the same event correctly over CalDAV (If-Match gives a 412), so the guarantee a client gets depends on which protocol it reaches the event through.
Root cause
The check already exists as a shared helper and is simply not called.
The helper, crates/jmap/src/changes/state.rs:31:
fn assert_state(&self, is_container: bool, if_in_state: &Option<State>) -> trc::Result<State> {
let old_state: State = self.get_state(is_container);
if let Some(if_in_state) = if_in_state
&& &old_state != if_in_state
{
return Err(trc::JmapEvent::StateMismatch.into_err());
}
Ok(old_state)
}
Correct call site, crates/jmap/src/email/set.rs:80-81:
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?
.with_state(cache.assert_state(false, &request.if_in_state)?);
Missing call site, crates/jmap/src/calendar_event/set.rs:99:
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?;
request.if_in_state is never read anywhere in that 999-line file. The only mention of state is response.new_state = State::Exact(change_id).into(); on line 521, which reports the new state without ever having checked the precondition.
The same type’s copy sibling gets it right, crates/jmap/src/calendar_event/copy.rs:75:
let cache = self
.fetch_dav_resources(access_token.account_id(), account_id, SyncCollection::Calendar)
.await?;
let old_state = cache.assert_state(false, &request.if_in_state)?;
That is the same DavResources cache, fetched the same way with SyncCollection::Calendar, and impl JmapCacheState for DavResources sits at changes/state.rs:80. So the helper is already in scope and already available on exactly the value that calendar_event/set.rs is holding.
Suggested fix
A one-liner, mechanically identical to email/set.rs. false for is_container matches copy.rs, since a CalendarEvent is a member rather than a container:
--- a/crates/jmap/src/calendar_event/set.rs
+++ b/crates/jmap/src/calendar_event/set.rs
@@ -99 +99,2 @@
- let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?;
+ let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?
+ .with_state(cache.assert_state(false, &request.if_in_state)?);
Happy to send this as a PR if that is easier for you.
Possibly the same omission elsewhere
Auditing every /set in crates/jmap/src for a call to assert_state, the split is striking. The original mail types enforce it and the newer DAV-backed types do not, while their own copy counterparts do:
| Method | enforces ifInState? |
|---|---|
Email/set, Email/import, Mailbox/set, Sieve/set, VacationResponse/set |
yes |
Email/copy, Contact/copy, File/copy, CalendarEvent/copy |
yes |
CalendarEvent/set |
no |
Calendar/set, Contact/set, AddressBook/set, File/set |
no |
CalendarEventNotification/set, ParticipantIdentity/set, ShareNotification/set |
no |
I have only reproduced CalendarEvent/set. The rest of that table is read from the source rather than tested, so please treat it as a lead and not a finding. Glad to split those into separate topics if you would prefer.
Expected Behavior
A CalendarEvent/set whose ifInState does not match the current state is rejected, per RFC 8620 §5.3, and nothing is written:
["error", {
"type": "stateMismatch",
"description": "An \"ifInState\" argument was supplied, but it does not match the current state."
}, "0"]
Actual Behavior
The stale write is applied, and a fresh newState is returned as though nothing were wrong:
["CalendarEvent/set", {
"accountId": "c",
"newState": "sn2",
"updated": { "v": null }
}, "0"]
A follow-up CalendarEvent/get confirms the clobbering write landed.
Reproduction Steps
Stock Docker server, account c, calendar b. A throwaway event is created and destroyed, so nothing else is touched.
1. Read the current state.
// CalendarEvent/get { "accountId": "c", "ids": [] }
→ "state": "snu" // call this S1
2. Create an event, which moves the state past S1.
// CalendarEvent/set { "accountId": "c", "create": { "p": { ... } } }
→ { "created": { "p": { "id": "v" } }, "newState": "sny" } // S1 is now stale
3. Update using the stale ifInState. This must be rejected.
["CalendarEvent/set", {
"accountId": "c",
"ifInState": "snu",
"update": { "v": { "title": "CLOBBERED" } }
}, "0"]
The write is accepted:
["CalendarEvent/set", {
"accountId": "c",
"newState": "sn2",
"updated": { "v": null }
}, "0"]
4. Confirm the stale write landed.
// CalendarEvent/get { "ids": ["v"], "properties": ["title"] }
→ [{ "id": "v", "title": "CLOBBERED" }]
5. Control: the identical pattern on Email/set, which behaves correctly.
// Email/set { "accountId": "c", "ifInState": "<stale>", "update": { "<id>": { "subject": "CLOBBERED" } } }
→ ["error", {
"type": "stateMismatch",
"description": "An \"ifInState\" argument was supplied, but it does not match the current state."
}, "0"]
Same request shape, same shared SetRequest type, opposite behaviour.
6. The argument is definitely parsed, it is just never compared. This is not an unknown field being dropped: a wrong-typed value is rejected at deserialization.
$ curl ... -d '{...,"methodCalls":[["CalendarEvent/set",{"accountId":"c","ifInState":12345,"update":{}},"0"]]}'
HTTP 400
{"type":"urn:ietf:params:jmap:error:notRequest","status":400, ...}
Stalwart Version
v0.16.x
Installation Method
Docker
Database Backend
RocksDB
Blob Storage
RocksDB
Search Engine
Internal
Directory Backend
Internal
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