TENTATIVE > DECLINED > NEEDS-ACTION */ namespace Calendars\SabreDav; use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; use Sabre\VObject\Reader; use Sabre\VObject\Component\VCalendar; class AttendeeNormalizerPlugin extends ServerPlugin { /** * Reference to the DAV server instance * @var Server */ protected $server; /** * Status priority map (higher = more definitive response) * @var array */ private const STATUS_PRIORITY = [ 'ACCEPTED' => 4, 'TENTATIVE' => 3, 'DECLINED' => 2, 'NEEDS-ACTION' => 1, ]; /** * Returns a plugin name. * * @return string */ public function getPluginName() { return 'attendee-normalizer'; } /** * Initialize the plugin. * * @param Server $server * @return void */ public function initialize(Server $server) { $this->server = $server; // Hook into calendar object creation and updates // Priority 90 to run before most other plugins but after authentication // Note: beforeCreateFile and beforeWriteContent have different signatures $server->on('beforeCreateFile', [$this, 'beforeCreateCalendarObject'], 90); $server->on('beforeWriteContent', [$this, 'beforeUpdateCalendarObject'], 90); } /** * Called before a calendar object is created. * Signature: ($path, &$data, \Sabre\DAV\ICollection $parent, &$modified) * * @param string $path The path to the file * @param resource|string $data The data being written * @param \Sabre\DAV\ICollection $parentNode The parent collection * @param bool $modified Whether the data was modified * @return void */ public function beforeCreateCalendarObject($path, &$data, $parentNode = null, &$modified = false) { $this->processCalendarData($path, $data, $modified); } /** * Called before a calendar object is updated. * Signature: ($path, \Sabre\DAV\IFile $node, &$data, &$modified) * * @param string $path The path to the file * @param \Sabre\DAV\IFile $node The existing file node * @param resource|string $data The data being written * @param bool $modified Whether the data was modified * @return void */ public function beforeUpdateCalendarObject($path, $node, &$data, &$modified = false) { $this->processCalendarData($path, $data, $modified); } /** * Process calendar data to normalize and deduplicate attendees. * * @param string $path The path to the file * @param resource|string &$data The data being written (modified in place) * @param bool &$modified Whether the data was modified * @return void */ private function processCalendarData($path, &$data, &$modified) { // Only process .ics files in calendar collections if (!preg_match('/\.ics$/i', $path)) { return; } try { // Get the data as string if (is_resource($data)) { $dataStr = stream_get_contents($data); rewind($data); } else { $dataStr = $data; } // Parse the iCalendar data $vcalendar = Reader::read($dataStr); if (!$vcalendar instanceof VCalendar) { return; } $wasModified = false; // Process all VEVENT components foreach ($vcalendar->VEVENT as $vevent) { if ($this->normalizeAndDeduplicateAttendees($vevent)) { $wasModified = true; } } // If we made changes, update the data if ($wasModified) { $newData = $vcalendar->serialize(); $data = $newData; $modified = true; error_log("[AttendeeNormalizerPlugin] Normalized attendees in: " . $path); } } catch (\Exception $e) { // Log error but don't block the request error_log("[AttendeeNormalizerPlugin] Error processing calendar object: " . $e->getMessage()); } } /** * Normalize and deduplicate attendees in a VEVENT component. * * @param \Sabre\VObject\Component\VEvent $vevent * @return bool True if the component was modified */ private function normalizeAndDeduplicateAttendees($vevent) { if (!isset($vevent->ATTENDEE) || count($vevent->ATTENDEE) === 0) { return false; } $attendees = []; $attendeesByEmail = []; $wasModified = false; // First pass: collect and normalize all attendees foreach ($vevent->ATTENDEE as $attendee) { $email = $this->normalizeEmail((string)$attendee); $status = isset($attendee['PARTSTAT']) ? strtoupper((string)$attendee['PARTSTAT']) : 'NEEDS-ACTION'; $priority = self::STATUS_PRIORITY[$status] ?? 0; $attendeeData = [ 'property' => $attendee, 'email' => $email, 'status' => $status, 'priority' => $priority, ]; if (!isset($attendeesByEmail[$email])) { // First occurrence of this email $attendeesByEmail[$email] = $attendeeData; $attendees[] = $attendeeData; } else { // Duplicate found $existing = $attendeesByEmail[$email]; if ($priority > $existing['priority']) { // New attendee has higher priority - replace // Find and replace in the array foreach ($attendees as $i => $att) { if ($att['email'] === $email) { $attendees[$i] = $attendeeData; $attendeesByEmail[$email] = $attendeeData; break; } } } $wasModified = true; error_log("[AttendeeNormalizerPlugin] Found duplicate attendee: {$email} (keeping status: " . $attendeesByEmail[$email]['status'] . ")"); } } // Also normalize the email in the value (lowercase the mailto: part) foreach ($vevent->ATTENDEE as $attendee) { $value = (string)$attendee; $normalizedValue = $this->normalizeMailtoValue($value); if ($value !== $normalizedValue) { $attendee->setValue($normalizedValue); $wasModified = true; } } // If duplicates were found, rebuild the ATTENDEE list if ($wasModified && count($attendees) < count($vevent->ATTENDEE)) { // Remove all existing ATTENDEEs unset($vevent->ATTENDEE); // Add back the deduplicated attendees foreach ($attendees as $attendeeData) { $property = $attendeeData['property']; // Clone the property to the vevent $newAttendee = $vevent->add('ATTENDEE', $this->normalizeMailtoValue((string)$property)); // Copy all parameters foreach ($property->parameters() as $param) { $newAttendee[$param->name] = $param->getValue(); } } error_log("[AttendeeNormalizerPlugin] Reduced attendees from " . count($vevent->ATTENDEE) . " to " . count($attendees)); } return $wasModified; } /** * Normalize an email address extracted from a mailto: URI. * * @param string $value The ATTENDEE value (e.g., "mailto:User@Example.com") * @return string Normalized email (lowercase) */ private function normalizeEmail($value) { // Remove mailto: prefix if present $email = preg_replace('/^mailto:/i', '', $value); // Lowercase and trim return strtolower(trim($email)); } /** * Normalize the mailto: value to have lowercase email. * * @param string $value The ATTENDEE value (e.g., "mailto:User@Example.com") * @return string Normalized value (e.g., "mailto:user@example.com") */ private function normalizeMailtoValue($value) { if (stripos($value, 'mailto:') === 0) { $email = substr($value, 7); return 'mailto:' . strtolower(trim($email)); } return strtolower(trim($value)); } /** * Returns a list of features for the DAV: header. * * @return array */ public function getFeatures() { return []; } }