mail_calendar_sync/lib/Service/CalendarService.php
Thomas Faour a9023d29d9 Working
2026-02-10 23:31:24 -05:00

380 lines
13 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 OCP\IDBConnection;
use Psr\Log\LoggerInterface;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Reader;
class CalendarService {
public function __construct(
private ICalendarManager $calendarManager,
private IDBConnection $db,
private LogMapper $logMapper,
private LoggerInterface $logger,
) {
}
/**
* Get all writable calendars for a user.
*/
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;
}
/**
* 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;
}
}