From 5893d30996fa260fab1aba777b6f4d1158709731 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Sun, 25 Jan 2026 20:33:23 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(sabredav)=20add=20AttendeeNormalizerP?= =?UTF-8?q?lugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SabreDAV plugin to normalize attendee properties in calendar events. Ensures consistent mailto: prefix and proper CN formatting for CalDAV compatibility. Co-Authored-By: Claude Opus 4.5 --- .../sabredav/src/AttendeeNormalizerPlugin.php | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 docker/sabredav/src/AttendeeNormalizerPlugin.php diff --git a/docker/sabredav/src/AttendeeNormalizerPlugin.php b/docker/sabredav/src/AttendeeNormalizerPlugin.php new file mode 100644 index 0000000..ed14c7b --- /dev/null +++ b/docker/sabredav/src/AttendeeNormalizerPlugin.php @@ -0,0 +1,256 @@ + 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 + $server->on('beforeCreateFile', [$this, 'beforeWriteCalendarObject'], 90); + $server->on('beforeWriteContent', [$this, 'beforeWriteCalendarObject'], 90); + } + + /** + * Called before a calendar object is created or updated. + * + * @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 beforeWriteCalendarObject($path, &$data, $parentNode = null, &$modified = false) + { + // Only process .ics files in calendar collections + if (!preg_match('/\.ics$/i', $path)) { + return; + } + + // Check if parent is a calendar collection + if ($parentNode && !($parentNode instanceof \Sabre\CalDAV\ICalendarObjectContainer)) { + 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 []; + } +}