*/ public function getWritableCalendars(string $userId): array { $principal = 'principals/users/' . $userId; $calendars = $this->calendarManager->getCalendarsForPrincipal($principal); $result = []; foreach ($calendars as $calendar) { if ($calendar instanceof ICreateFromString) { $result[] = [ 'uri' => $calendar->getUri(), 'name' => $calendar->getDisplayName() ?? $calendar->getUri(), 'color' => $calendar->getDisplayColor(), ]; } } return $result; } /** * Search for an event by its UID across a user's calendars or in a specific calendar. * * @return array|null Array with 'calendar' (ICreateFromString) and 'calendarData' (string ICS) or null */ public function findEventByUid(string $userId, string $eventUid, ?string $calendarUri = null): ?array { $principal = 'principals/users/' . $userId; // Use the calendar search API to find the event by UID $query = $this->calendarManager->newQuery($principal); if ($calendarUri !== null) { $query->addSearchCalendar($calendarUri); } // Search by UID-based property $query->setSearchPattern($eventUid); $query->addSearchProperty('UID'); $query->setLimit(5); $results = $this->calendarManager->searchForPrincipal($query); foreach ($results as $result) { $calData = $result['calendardata'] ?? null; if ($calData === null) { continue; } // Parse and verify UID matches try { $vcalendar = Reader::read($calData); $vevent = $vcalendar->VEVENT ?? null; if ($vevent === null) { continue; } $foundUid = (string)($vevent->UID ?? ''); if ($foundUid === $eventUid) { // Find the matching writable calendar $calendar = $this->getWritableCalendarByUri($userId, $result['calendar-uri'] ?? ''); if ($calendar !== null) { return [ 'calendar' => $calendar, 'calendarData' => $calData, 'vcalendar' => $vcalendar, 'calendarUri' => $result['calendar-uri'] ?? '', ]; } } } catch (\Throwable $e) { $this->logger->warning('Failed to parse calendar data while searching for UID', [ 'uid' => $eventUid, 'exception' => $e, ]); } } return null; } /** * Process an iMIP REPLY - update attendee status on an existing event. */ public function processReply(string $userId, VCalendar $replyVcal, string $fromEmail): bool { $replyEvent = $replyVcal->VEVENT ?? null; if ($replyEvent === null) { $this->logger->debug('No VEVENT in REPLY'); return false; } $eventUid = (string)($replyEvent->UID ?? ''); if ($eventUid === '') { $this->logger->debug('No UID in REPLY VEVENT'); return false; } $summary = (string)($replyEvent->SUMMARY ?? 'Unknown event'); // Find the existing event in the user's calendar $existing = $this->findEventByUid($userId, $eventUid); if ($existing === null) { $this->logMapper->log( $userId, $eventUid, 'SKIPPED', $summary, null, $fromEmail, 'Event not found in any calendar — REPLY ignored', ); $this->logger->info('Event UID not found in calendar, skipping REPLY', [ 'uid' => $eventUid, 'user' => $userId, ]); return false; } /** @var VCalendar $existingVcal */ $existingVcal = $existing['vcalendar']; $existingEvent = $existingVcal->VEVENT; // Extract attendee status from the reply $updated = false; if (isset($replyEvent->ATTENDEE)) { foreach ($replyEvent->ATTENDEE as $replyAttendee) { $replyEmail = $this->extractEmail((string)$replyAttendee->getValue()); $partstat = $replyAttendee['PARTSTAT'] ? (string)$replyAttendee['PARTSTAT'] : null; if ($replyEmail === '' || $partstat === null) { continue; } // Find and update the matching attendee in the existing event if (isset($existingEvent->ATTENDEE)) { foreach ($existingEvent->ATTENDEE as $existingAttendee) { $existingEmail = $this->extractEmail((string)$existingAttendee->getValue()); if (strtolower($existingEmail) === strtolower($replyEmail)) { $existingAttendee['PARTSTAT'] = $partstat; $updated = true; $this->logMapper->log( $userId, $eventUid, $partstat, $summary, $replyEmail, $fromEmail, "Attendee {$replyEmail} responded {$partstat}", ); $this->logger->info('Updated attendee status', [ 'uid' => $eventUid, 'attendee' => $replyEmail, 'partstat' => $partstat, 'user' => $userId, ]); } } } } } if (!$updated) { $this->logMapper->log( $userId, $eventUid, 'SKIPPED', $summary, null, $fromEmail, 'No matching attendee found in existing event', ); return false; } // Write the updated event back to the calendar try { /** @var ICreateFromString $calendar */ $calendar = $existing['calendar']; $icsFilename = $eventUid . '.ics'; $calendar->createFromString($icsFilename, $existingVcal->serialize()); $this->logger->info('Successfully updated calendar event from REPLY', [ 'uid' => $eventUid, 'user' => $userId, ]); return true; } catch (\Throwable $e) { $this->logMapper->log( $userId, $eventUid, 'ERROR', $summary, null, $fromEmail, 'Failed to write updated event: ' . $e->getMessage(), ); $this->logger->error('Failed to write updated event to calendar', [ 'uid' => $eventUid, 'user' => $userId, 'exception' => $e, ]); return false; } } /** * Process an iMIP REQUEST - add new event or update existing one in the user's calendar. * Only processes if auto_accept is enabled or the event already exists. */ public function processRequest( string $userId, VCalendar $requestVcal, string $fromEmail, ?string $targetCalendarUri = null, bool $autoAccept = false, ): bool { $requestEvent = $requestVcal->VEVENT ?? null; if ($requestEvent === null) { return false; } $eventUid = (string)($requestEvent->UID ?? ''); if ($eventUid === '') { return false; } $summary = (string)($requestEvent->SUMMARY ?? 'Unknown event'); // Check if this event already exists $existing = $this->findEventByUid($userId, $eventUid, $targetCalendarUri); if ($existing !== null) { // Event exists — update it with the new data (e.g. time change) try { /** @var ICreateFromString $calendar */ $calendar = $existing['calendar']; $icsFilename = $eventUid . '.ics'; $calendar->createFromString($icsFilename, $requestVcal->serialize()); $this->logMapper->log( $userId, $eventUid, 'UPDATED', $summary, null, $fromEmail, 'Existing event updated from incoming REQUEST', ); return true; } catch (\Throwable $e) { $this->logMapper->log( $userId, $eventUid, 'ERROR', $summary, null, $fromEmail, 'Failed to update existing event: ' . $e->getMessage(), ); return false; } } // Event doesn't exist yet if ($autoAccept && $targetCalendarUri !== null) { // Create the event in the target calendar $calendar = $this->getWritableCalendarByUri($userId, $targetCalendarUri); if ($calendar === null) { $this->logMapper->log( $userId, $eventUid, 'ERROR', $summary, null, $fromEmail, "Target calendar '{$targetCalendarUri}' not found or not writable", ); return false; } try { $icsFilename = $eventUid . '.ics'; $calendar->createFromString($icsFilename, $requestVcal->serialize()); $this->logMapper->log( $userId, $eventUid, 'CREATED', $summary, null, $fromEmail, 'New event auto-added to calendar from REQUEST', ); return true; } catch (\Throwable $e) { $this->logMapper->log( $userId, $eventUid, 'ERROR', $summary, null, $fromEmail, 'Failed to create event: ' . $e->getMessage(), ); return false; } } // Not auto-accepting and event doesn't exist — skip $this->logMapper->log( $userId, $eventUid, 'SKIPPED', $summary, null, $fromEmail, 'New invitation REQUEST not auto-accepted (enable auto-accept to add new events)', ); return false; } /** * Process an iMIP CANCEL - remove or cancel an existing event. */ public function processCancel(string $userId, VCalendar $cancelVcal, string $fromEmail): bool { $cancelEvent = $cancelVcal->VEVENT ?? null; if ($cancelEvent === null) { return false; } $eventUid = (string)($cancelEvent->UID ?? ''); if ($eventUid === '') { return false; } $summary = (string)($cancelEvent->SUMMARY ?? 'Unknown event'); $existing = $this->findEventByUid($userId, $eventUid); if ($existing === null) { $this->logMapper->log( $userId, $eventUid, 'SKIPPED', $summary, null, $fromEmail, 'CANCEL received but event not found in calendar', ); return false; } // Mark the existing event as CANCELLED /** @var VCalendar $existingVcal */ $existingVcal = $existing['vcalendar']; $existingEvent = $existingVcal->VEVENT; $existingEvent->STATUS = 'CANCELLED'; try { /** @var ICreateFromString $calendar */ $calendar = $existing['calendar']; $icsFilename = $eventUid . '.ics'; $calendar->createFromString($icsFilename, $existingVcal->serialize()); $this->logMapper->log( $userId, $eventUid, 'CANCELLED', $summary, null, $fromEmail, 'Event marked as CANCELLED per organizer request', ); return true; } catch (\Throwable $e) { $this->logMapper->log( $userId, $eventUid, 'ERROR', $summary, null, $fromEmail, 'Failed to cancel event: ' . $e->getMessage(), ); return false; } } /** * Extract email address from a mailto: URI or plain email. */ private function extractEmail(string $value): string { $value = trim($value); if (str_starts_with(strtolower($value), 'mailto:')) { $value = substr($value, 7); } return trim($value); } /** * Get a writable calendar by URI for a user. */ private function getWritableCalendarByUri(string $userId, string $uri): ?ICreateFromString { $principal = 'principals/users/' . $userId; $calendars = $this->calendarManager->getCalendarsForPrincipal($principal); foreach ($calendars as $calendar) { if ($calendar instanceof ICreateFromString && $calendar->getUri() === $uri) { return $calendar; } } return null; } }