mail_calendar_sync/lib/Service/SyncService.php
2026-02-09 23:02:34 -05:00

212 lines
7.0 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Service;
use OCA\MailCalendarSync\Db\Config;
use OCA\MailCalendarSync\Db\ConfigMapper;
use OCA\MailCalendarSync\Db\LogMapper;
use OCA\MailCalendarSync\Db\ProcessedMessageMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use Psr\Log\LoggerInterface;
use Sabre\VObject\Reader;
/**
* Orchestrates the full mail → calendar sync process for a user.
*
* NOTE: This service is READ-ONLY on email. It never deletes or modifies
* any email messages. It only reads ICS attachments and applies them
* to the calendar.
*/
class SyncService {
public function __construct(
private ConfigMapper $configMapper,
private MailService $mailService,
private CalendarService $calendarService,
private ProcessedMessageMapper $processedMapper,
private LogMapper $logMapper,
private LoggerInterface $logger,
) {
}
/**
* Run sync for a specific user.
*
* @return array{processed: int, updated: int, errors: int, messages: string[]}
*/
public function syncForUser(string $userId): array {
$stats = ['processed' => 0, 'updated' => 0, 'errors' => 0, 'messages' => []];
try {
$config = $this->configMapper->findByUserId($userId);
} catch (DoesNotExistException $e) {
$stats['messages'][] = 'No configuration found. Please save your settings first.';
return $stats;
}
if (!$config->isEnabled()) {
$stats['messages'][] = 'Sync is disabled.';
return $stats;
}
$mailAccountId = $config->getMailAccountId();
$calendarUri = $config->getCalendarUri();
$autoAccept = $config->isAutoAccept();
if ($mailAccountId === null || $calendarUri === null) {
$stats['messages'][] = 'Incomplete configuration — please select both a mail account and calendar.';
return $stats;
}
$this->logger->info('Starting mail-calendar sync', [
'userId' => $userId,
'mailAccountId' => $mailAccountId,
'calendarUri' => $calendarUri,
]);
// Find recent iMip messages
try {
$messages = $this->mailService->findRecentImipMessages($userId, $mailAccountId);
$stats['messages'][] = 'Found ' . count($messages) . ' unprocessed iMIP message(s) in last 7 days.';
} catch (\Throwable $e) {
$stats['errors']++;
$stats['messages'][] = 'Failed to query mail: ' . $e->getMessage();
$this->logger->error('Failed to find iMIP messages', [
'userId' => $userId,
'exception' => $e,
]);
return $stats;
}
foreach ($messages as $message) {
$stats['processed']++;
$messageId = $message['messageId'];
try {
// Fetch ICS data from the actual email via IMAP (READ-ONLY)
$icsResults = $this->mailService->fetchIcsFromMessage(
$mailAccountId,
$userId,
$message['mailboxId'],
$message['uid'],
);
if (empty($icsResults)) {
$stats['messages'][] = "No ICS data found in message: {$message['subject']}";
// Still mark as processed so we don't retry
if ($messageId !== '') {
$this->processedMapper->markProcessed($userId, $mailAccountId, $messageId);
}
continue;
}
foreach ($icsResults as $icsResult) {
$this->processIcs(
$userId,
$icsResult['ics'],
$icsResult['method'],
$icsResult['from'],
$calendarUri,
$autoAccept,
$stats,
);
}
// Mark message as processed
if ($messageId !== '') {
$this->processedMapper->markProcessed($userId, $mailAccountId, $messageId);
}
} catch (\Throwable $e) {
$stats['errors']++;
$stats['messages'][] = "Error processing '{$message['subject']}': " . $e->getMessage();
$this->logger->error('Error processing message', [
'messageId' => $messageId,
'userId' => $userId,
'exception' => $e,
]);
}
}
$this->logger->info('Mail-calendar sync completed', [
'userId' => $userId,
'stats' => $stats,
]);
return $stats;
}
/**
* Process a single ICS payload.
*/
private function processIcs(
string $userId,
string $icsData,
string $method,
string $fromEmail,
string $calendarUri,
bool $autoAccept,
array &$stats,
): void {
try {
$vcalendar = Reader::read($icsData);
} catch (\Throwable $e) {
$this->logger->warning('Failed to parse ICS data', ['exception' => $e]);
$stats['errors']++;
$stats['messages'][] = 'Failed to parse ICS: ' . $e->getMessage();
return;
}
$vevent = $vcalendar->VEVENT ?? null;
$summary = $vevent ? (string)($vevent->SUMMARY ?? 'Unknown') : 'Unknown';
$uid = $vevent ? (string)($vevent->UID ?? '') : '';
$success = match ($method) {
'REPLY' => $this->calendarService->processReply($userId, $vcalendar, $fromEmail),
'REQUEST' => $this->calendarService->processRequest(
$userId, $vcalendar, $fromEmail, $calendarUri, $autoAccept
),
'CANCEL' => $this->calendarService->processCancel($userId, $vcalendar, $fromEmail),
default => false,
};
if ($success) {
$stats['updated']++;
$stats['messages'][] = "{$method}: Updated '{$summary}' from {$fromEmail}";
} else {
$stats['messages'][] = "{$method}: No action for '{$summary}' (event may not exist in calendar)";
}
}
/**
* Run sync for all enabled users.
*/
public function syncAll(): array {
$configs = $this->configMapper->findAllEnabled();
$results = [];
foreach ($configs as $config) {
$userId = $config->getUserId();
try {
$results[$userId] = $this->syncForUser($userId);
} catch (\Throwable $e) {
$this->logger->error('Sync failed for user', [
'userId' => $userId,
'exception' => $e,
]);
$results[$userId] = [
'processed' => 0,
'updated' => 0,
'errors' => 1,
'messages' => ['Sync failed: ' . $e->getMessage()],
];
}
}
return $results;
}
}