@@ -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