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; } }