Thomas Faour a9023d29d9 Working
2026-02-10 23:31:24 -05:00

295 lines
10 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;
}
// Check that Horde IMAP libraries are available
if (!$this->mailService->isImapAvailable()) {
$stats['errors']++;
$stats['messages'][] = 'Horde IMAP libraries not found. Is the Nextcloud Mail app installed and enabled?';
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;
}
if (empty($messages)) {
return $stats;
}
// Fetch ICS data from ALL messages using a single IMAP connection
try {
$allIcsResults = $this->mailService->fetchIcsFromMessages($mailAccountId, $userId, $messages);
} catch (\Throwable $e) {
$stats['errors']++;
$stats['messages'][] = 'Failed to connect to IMAP: ' . $e->getMessage();
$this->logger->error('IMAP connection failed', [
'userId' => $userId,
'exception' => $e,
]);
return $stats;
}
// Track event UIDs across ALL messages in this batch to avoid
// processing the same event UID multiple times (e.g., duplicate
// REPLY emails for the same invitation)
$batchSeenUids = [];
// Process each message's ICS results
foreach ($messages as $idx => $message) {
$stats['processed']++;
$messageId = $message['messageId'];
// Track UIDs within this message to avoid duplicate ICS parts
// (emails often include the same ICS both inline and as attachment)
$seenUids = [];
try {
$icsResults = $allIcsResults[$idx] ?? [];
if (empty($icsResults)) {
$stats['messages'][] = "No ICS data found in message: {$message['subject']}";
$this->markProcessedSafe($userId, $mailAccountId, $messageId);
continue;
}
foreach ($icsResults as $icsResult) {
// Deduplicate: emails often contain the same ICS both
// inline and as an attachment. Skip if we already
// processed this UID from this message.
$icsUid = $this->extractUidFromIcs($icsResult['ics']);
if ($icsUid !== '' && isset($seenUids[$icsUid])) {
continue;
}
if ($icsUid !== '') {
$seenUids[$icsUid] = true;
}
// Batch-level deduplication: skip if we already processed
// this event UID from a different message in this batch.
// This prevents duplicate processing when multiple emails
// contain the same calendar invitation (common with Gmail).
if ($icsUid !== '' && isset($batchSeenUids[$icsUid])) {
$this->logger->debug('Skipping duplicate event UID from different message in batch', [
'uid' => $icsUid,
'messageId' => $messageId,
]);
continue;
}
if ($icsUid !== '') {
$batchSeenUids[$icsUid] = true;
}
$this->processIcs(
$userId,
$icsResult['ics'],
$icsResult['method'],
$icsResult['from'],
$calendarUri,
$autoAccept,
$stats,
);
}
$this->markProcessedSafe($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;
}
/**
* Mark a message as processed, handling unique constraint violations
* gracefully (e.g. if processed by a concurrent run).
*/
private function markProcessedSafe(string $userId, int $mailAccountId, string $messageId): void {
if ($messageId === '') {
return;
}
try {
$this->processedMapper->markProcessed($userId, $mailAccountId, $messageId);
} catch (\Throwable $e) {
// Likely a unique constraint violation from concurrent processing — safe to ignore
$this->logger->debug('Could not mark message as processed (possibly already processed)', [
'messageId' => $messageId,
'userId' => $userId,
'error' => $e->getMessage(),
]);
}
}
/**
* 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)";
}
}
/**
* Extract the UID from raw ICS data without full parsing.
*/
private function extractUidFromIcs(string $icsData): string {
try {
$vcal = Reader::read($icsData);
$vevent = $vcal->VEVENT ?? null;
if ($vevent !== null) {
return (string)($vevent->UID ?? '');
}
} catch (\Throwable $e) {
// Fall through
}
return '';
}
/**
* 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;
}
}