212 lines
7.0 KiB
PHP
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;
|
|
}
|
|
}
|