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; } /** * Find an event by UID via direct database query. * * Returns calendarId (numeric) + objectUri needed for CalDavBackend::updateCalendarObject. * * @return array|null With keys: calendarId, calendarUri, objectUri, calendarData, vcalendar */ public function findEventByUid(string $userId, string $eventUid, ?string $calendarUri = null): ?array { $principal = 'principals/users/' . $userId; $qb = $this->db->getQueryBuilder(); $qb->select('co.calendardata', 'co.uri AS object_uri', 'c.uri AS calendar_uri', 'c.id AS calendar_id') ->from('calendarobjects', 'co') ->innerJoin('co', 'calendars', 'c', $qb->expr()->eq('co.calendarid', 'c.id')) ->where($qb->expr()->eq('co.uid', $qb->createNamedParameter($eventUid))) ->andWhere($qb->expr()->eq('c.principaluri', $qb->createNamedParameter($principal))); if ($calendarUri !== null) { $qb->andWhere($qb->expr()->eq('c.uri', $qb->createNamedParameter($calendarUri))); } $qb->setMaxResults(1); $result = $qb->executeQuery(); $row = $result->fetch(); $result->closeCursor(); if ($row === false) { $this->logger->debug('Event UID not found in database', [ 'uid' => $eventUid, 'user' => $userId, ]); return null; } $calData = $row['calendardata'] ?? null; if ($calData === null) { return null; } try { $vcalendar = Reader::read($calData); } catch (\Throwable $e) { $this->logger->warning('Failed to parse stored calendar data', [ 'uid' => $eventUid, 'exception' => $e, ]); return null; } return [ 'calendarId' => (int)$row['calendar_id'], 'calendarUri' => $row['calendar_uri'] ?? '', 'objectUri' => $row['object_uri'] ?? '', 'calendarData' => $calData, 'vcalendar' => $vcalendar, ]; } /** * Strip METHOD property from a VCalendar before storing in CalDAV. */ private function prepareForStorage(VCalendar $vcalendar): string { if (isset($vcalendar->METHOD)) { $vcalendar->remove('METHOD'); } return $vcalendar->serialize(); } /** * Get the CalDavBackend instance for direct calendar operations. * * CalDavBackend supports both createCalendarObject and updateCalendarObject, * whereas ICreateFromString only supports create (which fails on existing objects). */ private function getCalDavBackend(): \OCA\DAV\CalDAV\CalDavBackend { return \OC::$server->get(\OCA\DAV\CalDAV\CalDavBackend::class); } /** * Update an existing calendar object using CalDavBackend. */ private function updateExistingEvent(int $calendarId, string $objectUri, string $icsData): void { $backend = $this->getCalDavBackend(); $backend->updateCalendarObject($calendarId, $objectUri, $icsData); } /** * Create a new calendar object using ICreateFromString. */ private function createNewEvent(ICreateFromString $calendar, string $eventUid, string $icsData): void { $filename = $eventUid . '.ics'; $calendar->createFromString($filename, $icsData); } /** * 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) { return false; } $eventUid = (string)($replyEvent->UID ?? ''); if ($eventUid === '') { return false; } $summary = (string)($replyEvent->SUMMARY ?? 'Unknown event'); $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", ); return false; } /** @var VCalendar $existingVcal */ $existingVcal = $existing['vcalendar']; $existingEvent = $existingVcal->VEVENT; $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; } 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}", ); } } } } } if (!$updated) { $this->logMapper->log( $userId, $eventUid, 'SKIPPED', $summary, null, $fromEmail, 'No matching attendee found in existing event', ); return false; } // UPDATE existing object (not create) try { $this->updateExistingEvent( $existing['calendarId'], $existing['objectUri'], $this->prepareForStorage($existingVcal), ); return true; } catch (\Throwable $e) { $this->logMapper->log( $userId, $eventUid, 'ERROR', $summary, null, $fromEmail, 'Failed to write updated event: ' . $e->getMessage(), ); return false; } } /** * Process an iMIP REQUEST - add new event or update existing one. */ 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'); $existing = $this->findEventByUid($userId, $eventUid, $targetCalendarUri); if ($existing !== null) { // UPDATE existing event try { $this->updateExistingEvent( $existing['calendarId'], $existing['objectUri'], $this->prepareForStorage($requestVcal), ); $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; } } // CREATE new event if ($autoAccept && $targetCalendarUri !== null) { $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 { $this->createNewEvent( $calendar, $eventUid, $this->prepareForStorage($requestVcal), ); $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; } } $this->logMapper->log( $userId, $eventUid, 'SKIPPED', $summary, null, $fromEmail, 'New invitation not auto-accepted (enable auto-accept to add new events)', ); return false; } /** * Process an iMIP CANCEL - mark existing event as cancelled. */ 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; } /** @var VCalendar $existingVcal */ $existingVcal = $existing['vcalendar']; $existingVcal->VEVENT->STATUS = 'CANCELLED'; try { $this->updateExistingEvent( $existing['calendarId'], $existing['objectUri'], $this->prepareForStorage($existingVcal), ); $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; } } private function extractEmail(string $value): string { $value = trim($value); if (str_starts_with(strtolower($value), 'mailto:')) { $value = substr($value, 7); } return trim($value); } 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; } }