447 lines
14 KiB
PHP
447 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\MailCalendarSync\Service;
|
|
|
|
use OCA\MailCalendarSync\Db\LogMapper;
|
|
use OCP\Calendar\ICreateFromString;
|
|
use OCP\Calendar\IManager as ICalendarManager;
|
|
use Psr\Log\LoggerInterface;
|
|
use Sabre\VObject\Component\VCalendar;
|
|
use Sabre\VObject\Component\VEvent;
|
|
use Sabre\VObject\Reader;
|
|
|
|
class CalendarService {
|
|
|
|
public function __construct(
|
|
private ICalendarManager $calendarManager,
|
|
private LogMapper $logMapper,
|
|
private LoggerInterface $logger,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Get all writable calendars for a user.
|
|
*
|
|
* @return array<array{uri: string, name: string, color: string|null}>
|
|
*/
|
|
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;
|
|
}
|
|
}
|