Skip to content

Commit 313c843

Browse files
ndo84bwclaude
andcommitted
fix(dav): suppress iTip REQUEST for PARTSTAT-only occurrence overrides
When an attendee accepts or declines a single occurrence of a recurring event, the resulting RECURRENCE-ID override VEVENT carries only that attendee's PARTSTAT change. TipBroker previously flagged every emitted REQUEST as significant, causing IMipPlugin to send re-invite mails to all other attendees even though nothing material changed for them. Compute significantChange per attendee, comparing the master and existing overrides on the significantChangeProperties set, and -- for newly added overrides -- comparing against the inherited master state (asymmetric: the override's own DTSTART/DTEND are expected to differ from the master because they describe a different occurrence; what counts is a shift relative to the override's own RECURRENCE-ID, a duration change, or a date-independent property override). New attendees, partial-instance removals, and SCHEDULE-FORCE-SEND=REQUEST continue to be treated as significant. PARTSTAT changes alone never trigger significance per RFC 5546 -- that flow is REPLY-only. The existing gate at IMipPlugin.php:105 then suppresses the email. Resolves: #60452 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Nico Donath <ndo84bw@gmx.de>
1 parent 63680bd commit 313c843

2 files changed

Lines changed: 401 additions & 2 deletions

File tree

apps/dav/lib/CalDAV/TipBroker.php

Lines changed: 191 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,14 +215,199 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo,
215215
}
216216
}
217217

218+
$significant = $this->isRequestSignificantForAttendee($attendee, $eventInfo, $oldEventInfo);
218219
$messages[] = $this->generateMessage(
219-
$instances, $organizerHref, $organizerName, $eventInfo['attendees'][$attendee], $objectId, $objectType, $objectSequence, 'REQUEST', $template
220+
$instances, $organizerHref, $organizerName, $eventInfo['attendees'][$attendee], $objectId, $objectType, $objectSequence, 'REQUEST', $template, $significant
220221
);
221222
}
222223

223224
return $messages;
224225
}
225226

227+
/**
228+
* Decide whether a REQUEST is materially relevant for a specific attendee.
229+
*
230+
* A REQUEST broadcast is meaningful only when something the attendee would
231+
* actually care about changed: scheduling-significant properties on the
232+
* master or on an instance they participate in, the introduction of a new
233+
* override that overrides inherited master state, the attendee being added
234+
* or partially removed, or a forced SCHEDULE-FORCE-SEND=REQUEST.
235+
*
236+
* PARTSTAT changes alone are NOT significant for organizer→attendee
237+
* REQUEST broadcasts; that flow is REPLY-only per RFC 5546. Suppressing
238+
* here fixes the per-occurrence accept/decline re-invite spam.
239+
*/
240+
protected function isRequestSignificantForAttendee(
241+
string $attendeeHref,
242+
array $eventInfo,
243+
array $oldEventInfo,
244+
): bool {
245+
$newAttendee = $eventInfo['attendees'][$attendeeHref] ?? null;
246+
$oldAttendee = $oldEventInfo['attendees'][$attendeeHref] ?? null;
247+
248+
if (($newAttendee['forceSend'] ?? null) === 'REQUEST') {
249+
return true;
250+
}
251+
if ($oldAttendee === null) {
252+
return true;
253+
}
254+
255+
$newMaster = $eventInfo['instances']['master'] ?? null;
256+
$oldMaster = $oldEventInfo['instances']['master'] ?? null;
257+
if (($newMaster === null) !== ($oldMaster === null)) {
258+
return true;
259+
}
260+
if ($newMaster !== null && $oldMaster !== null
261+
&& $this->instancesDifferInSignificantProperties($newMaster, $oldMaster)) {
262+
return true;
263+
}
264+
265+
foreach (($newAttendee['instances'] ?? []) as $instanceId => $_) {
266+
if ($instanceId === 'master') {
267+
continue;
268+
}
269+
$newInstance = $eventInfo['instances'][$instanceId] ?? null;
270+
if ($newInstance === null) {
271+
continue;
272+
}
273+
$oldInstance = $oldEventInfo['instances'][$instanceId] ?? null;
274+
if ($oldInstance !== null) {
275+
if ($this->instancesDifferInSignificantProperties($newInstance, $oldInstance)) {
276+
return true;
277+
}
278+
} elseif ($oldMaster !== null
279+
&& $this->overrideDiffersFromInheritedMaster($newInstance, $oldMaster)) {
280+
return true;
281+
}
282+
}
283+
284+
foreach (($oldAttendee['instances'] ?? []) as $instanceId => $_) {
285+
if (!isset($newAttendee['instances'][$instanceId])) {
286+
return true;
287+
}
288+
}
289+
290+
// Synthesized-EXDATE diff: parseEventForOrganizer injects a per-attendee
291+
// EXDATE on the outbound master for every override the attendee is not
292+
// part of. If this synthesized set changed between old and new state
293+
// (a brand-new override now excludes them, or an override they weren't
294+
// on was removed), the REQUEST content materially changes and they
295+
// must receive it.
296+
$newSynthExdates = $this->synthesizedExdatesForAttendee($eventInfo, $newAttendee);
297+
$oldSynthExdates = $this->synthesizedExdatesForAttendee($oldEventInfo, $oldAttendee);
298+
if ($newSynthExdates !== $oldSynthExdates) {
299+
return true;
300+
}
301+
302+
return false;
303+
}
304+
305+
/**
306+
* @param array $eventInfo An eventInfo dict as produced by parseEventInfo()
307+
* @param array $attendee The merged attendee dict for the recipient
308+
* @return list<string> Sorted list of RECURRENCE-ID values for overrides
309+
* this attendee is not on (the EXDATE values that would
310+
* be injected into their REQUEST master).
311+
*/
312+
protected function synthesizedExdatesForAttendee(array $eventInfo, array $attendee): array {
313+
$exdates = [];
314+
foreach (($eventInfo['instances'] ?? []) as $instanceId => $instance) {
315+
if ($instanceId === 'master'
316+
|| isset($attendee['instances'][$instanceId])
317+
|| !isset($instance->{'RECURRENCE-ID'})) {
318+
continue;
319+
}
320+
$exdates[] = (string)$instance->{'RECURRENCE-ID'}->getValue();
321+
}
322+
sort($exdates);
323+
return $exdates;
324+
}
325+
326+
/**
327+
* Decide whether a newly-introduced override actually changes anything the
328+
* attendee would have inherited from the master.
329+
*
330+
* Unlike a generic significant-property diff, this is asymmetric: the
331+
* override's DTSTART/DTEND are *expected* to differ from the master's
332+
* (they describe a different occurrence). What matters is whether the
333+
* override shifts the time relative to its own RECURRENCE-ID, changes
334+
* duration, or rewrites any date-independent property.
335+
*/
336+
protected function overrideDiffersFromInheritedMaster(Component $override, Component $master): bool {
337+
foreach (['SUMMARY', 'LOCATION', 'DESCRIPTION', 'STATUS'] as $prop) {
338+
$oVal = isset($override->$prop) ? (string)$override->$prop->getValue() : null;
339+
$mVal = isset($master->$prop) ? (string)$master->$prop->getValue() : null;
340+
if ($oVal !== $mVal) {
341+
return true;
342+
}
343+
}
344+
345+
if (isset($override->DTSTART, $override->{'RECURRENCE-ID'})) {
346+
try {
347+
$dtstart = $override->DTSTART->getDateTime();
348+
$recurId = $override->{'RECURRENCE-ID'}->getDateTime();
349+
if ($dtstart->getTimestamp() !== $recurId->getTimestamp()) {
350+
return true;
351+
}
352+
} catch (\Exception $e) {
353+
return true;
354+
}
355+
}
356+
357+
try {
358+
$overrideDuration = $this->computeDurationSeconds($override);
359+
$masterDuration = $this->computeDurationSeconds($master);
360+
if ($overrideDuration !== null && $masterDuration !== null
361+
&& $overrideDuration !== $masterDuration) {
362+
return true;
363+
}
364+
} catch (\Exception $e) {
365+
return true;
366+
}
367+
368+
// An override should not carry its own recurrence rules; if it does,
369+
// the organizer is doing something unusual and we should not suppress.
370+
foreach (['RRULE', 'RDATE', 'EXDATE'] as $prop) {
371+
if (isset($override->$prop)) {
372+
return true;
373+
}
374+
}
375+
376+
return false;
377+
}
378+
379+
protected function computeDurationSeconds(Component $vevent): ?int {
380+
if (isset($vevent->DTSTART, $vevent->DTEND)) {
381+
return $vevent->DTEND->getDateTime()->getTimestamp()
382+
- $vevent->DTSTART->getDateTime()->getTimestamp();
383+
}
384+
if (isset($vevent->DURATION)) {
385+
$interval = $vevent->DURATION->getDateInterval();
386+
$ref = new \DateTimeImmutable('@0');
387+
return $ref->add($interval)->getTimestamp();
388+
}
389+
return null;
390+
}
391+
392+
protected function instancesDifferInSignificantProperties(Component $a, Component $b): bool {
393+
foreach ($this->significantChangeProperties as $prop) {
394+
$aValues = [];
395+
foreach ($a->select($prop) as $val) {
396+
$aValues[] = (string)$val->getValue();
397+
}
398+
$bValues = [];
399+
foreach ($b->select($prop) as $val) {
400+
$bValues[] = (string)$val->getValue();
401+
}
402+
sort($aValues);
403+
sort($bValues);
404+
if ($aValues !== $bValues) {
405+
return true;
406+
}
407+
}
408+
return false;
409+
}
410+
226411
/**
227412
* Generates an iTip message for a specific attendee
228413
*
@@ -241,6 +426,9 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo,
241426
* @param int $objectSequence The sequence number of the event
242427
* @param string $method The iTip method ('REQUEST', 'CANCEL', 'REPLY', etc.)
243428
* @param VCalendar $template The template calendar object (without event components)
429+
* @param bool|null $significantChange Whether the change is significant for this attendee.
430+
* Defaults to true to preserve historical behavior for
431+
* CANCEL paths. REQUEST callers compute it explicitly.
244432
* @return Message The generated iTip message ready to be sent
245433
*/
246434
protected function generateMessage(
@@ -253,6 +441,7 @@ protected function generateMessage(
253441
int $objectSequence,
254442
string $method,
255443
VCalendar $template,
444+
?bool $significantChange = null,
256445
): Message {
257446

258447
$recipientAddress = $attendee['href'] ?? '';
@@ -277,7 +466,7 @@ protected function generateMessage(
277466
$message->senderName = $organizerName;
278467
$message->recipient = $recipientAddress;
279468
$message->recipientName = $recipientName;
280-
$message->significantChange = true;
469+
$message->significantChange = $significantChange ?? true;
281470
$message->message = $vObject;
282471

283472
return $message;

0 commit comments

Comments
 (0)