diff --git a/appinfo/info.xml b/appinfo/info.xml index c7638b6..825a1e1 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -4,8 +4,10 @@ mail_calendar_sync Mail Calendar Sync Automatically apply calendar invitation responses from Mail - - 1.1.0 + + 1.2.0 agpl Mail Calendar Sync Contributors MailCalendarSync diff --git a/lib/BackgroundJob/ProcessImipResponsesJob.php b/lib/BackgroundJob/ProcessImipResponsesJob.php index ac32cca..dee6578 100644 --- a/lib/BackgroundJob/ProcessImipResponsesJob.php +++ b/lib/BackgroundJob/ProcessImipResponsesJob.php @@ -4,20 +4,21 @@ declare(strict_types=1); namespace OCA\MailCalendarSync\BackgroundJob; +use OCA\MailCalendarSync\AppInfo\Application; use OCA\MailCalendarSync\Db\ConfigMapper; use OCA\MailCalendarSync\Db\LogMapper; use OCA\MailCalendarSync\Db\ProcessedMessageMapper; use OCA\MailCalendarSync\Service\SyncService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; +use OCP\IConfig; use Psr\Log\LoggerInterface; /** - * Background job that scans for new iMIP calendar responses - * in connected mail accounts and applies them to calendars. + * Background job that scans for new iMIP calendar responses. * - * The job itself runs every 5 minutes (the minimum), but - * per-user sync intervals are respected via last-sync tracking. + * Runs every 5 minutes (minimum Nextcloud interval), but respects + * each user's configured sync_interval via IConfig timestamps. */ class ProcessImipResponsesJob extends TimedJob { @@ -27,11 +28,12 @@ class ProcessImipResponsesJob extends TimedJob { private ConfigMapper $configMapper, private LogMapper $logMapper, private ProcessedMessageMapper $processedMapper, + private IConfig $appConfig, private LoggerInterface $logger, ) { parent::__construct($time); - // Job checks every 5 minutes; per-user interval handled in run() + // Job wakes up every 5 minutes; per-user interval enforced in run() $this->setInterval(300); $this->setTimeSensitivity(self::TIME_SENSITIVE); } @@ -45,19 +47,23 @@ class ProcessImipResponsesJob extends TimedJob { $totalProcessed = 0; $totalUpdated = 0; $totalErrors = 0; + $synced = 0; foreach ($configs as $config) { $userId = $config->getUserId(); - - // Check if enough time has passed since last sync for this user $syncInterval = $config->getSyncInterval(); - $lastSync = $config->getUpdatedAt(); - if ($lastSync !== null && $syncInterval > 300) { - $lastSyncTime = strtotime($lastSync); - if ($lastSyncTime !== false && (time() - $lastSyncTime) < $syncInterval) { - continue; // Not time yet for this user - } + // Check per-user last sync time (stored in oc_preferences) + $lastSyncStr = $this->appConfig->getUserValue( + $userId, + Application::APP_ID, + 'last_synced_at', + '0' + ); + $lastSync = (int)$lastSyncStr; + + if ((time() - $lastSync) < $syncInterval) { + continue; // Not time yet for this user } try { @@ -65,6 +71,15 @@ class ProcessImipResponsesJob extends TimedJob { $totalProcessed += $stats['processed']; $totalUpdated += $stats['updated']; $totalErrors += $stats['errors']; + $synced++; + + // Record this sync time + $this->appConfig->setUserValue( + $userId, + Application::APP_ID, + 'last_synced_at', + (string)time() + ); } catch (\Throwable $e) { $totalErrors++; $this->logger->error('Sync failed for user', [ @@ -75,13 +90,14 @@ class ProcessImipResponsesJob extends TimedJob { } $this->logger->info('Mail-calendar sync completed', [ - 'users' => count($configs), + 'usersChecked' => count($configs), + 'usersSynced' => $synced, 'processed' => $totalProcessed, 'updated' => $totalUpdated, 'errors' => $totalErrors, ]); - // Periodic cleanup of old records + // Periodic cleanup $this->logMapper->cleanupOld(30); $this->processedMapper->cleanupOld(90); diff --git a/lib/Migration/Version1000Date20250209000000.php b/lib/Migration/Version1000Date20250209000000.php index ef15689..dd418d0 100644 --- a/lib/Migration/Version1000Date20250209000000.php +++ b/lib/Migration/Version1000Date20250209000000.php @@ -47,6 +47,11 @@ class Version1000Date20250209000000 extends SimpleMigrationStep { 'default' => 0, 'unsigned' => true, ]); + $table->addColumn('sync_interval', Types::INTEGER, [ + 'notnull' => true, + 'default' => 600, + 'unsigned' => true, + ]); $table->addColumn('created_at', Types::STRING, [ 'notnull' => true, 'length' => 32, diff --git a/lib/Migration/Version1003Date20250210000000.php b/lib/Migration/Version1003Date20250210000000.php index 7b3219a..aba4d6b 100644 --- a/lib/Migration/Version1003Date20250210000000.php +++ b/lib/Migration/Version1003Date20250210000000.php @@ -11,8 +11,9 @@ use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; /** - * Fresh migration that creates tables if they don't exist. - * Safe to run even if the original migration partially succeeded. + * Migration that: + * - Creates tables if they don't exist (fresh install) + * - Adds sync_interval column to mcs_config if missing (upgrade from v1.0) */ class Version1003Date20250210000000 extends SimpleMigrationStep { @@ -66,6 +67,16 @@ class Version1003Date20250210000000 extends SimpleMigrationStep { $table->setPrimaryKey(['id']); $table->addUniqueIndex(['user_id'], 'mcs_config_user_idx'); + } else { + // Table exists — ensure sync_interval column is present (upgrade path) + $table = $schema->getTable('mcs_config'); + if (!$table->hasColumn('sync_interval')) { + $table->addColumn('sync_interval', Types::INTEGER, [ + 'notnull' => true, + 'default' => 600, + 'unsigned' => true, + ]); + } } if (!$schema->hasTable('mcs_log')) { diff --git a/lib/Service/CalendarService.php b/lib/Service/CalendarService.php index 6685d99..0491ed1 100644 --- a/lib/Service/CalendarService.php +++ b/lib/Service/CalendarService.php @@ -7,15 +7,16 @@ 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\Component\VEvent; use Sabre\VObject\Reader; class CalendarService { public function __construct( private ICalendarManager $calendarManager, + private IDBConnection $db, private LogMapper $logMapper, private LoggerInterface $logger, ) { @@ -23,8 +24,6 @@ class CalendarService { /** * Get all writable calendars for a user. - * - * @return array */ public function getWritableCalendars(string $userId): array { $principal = 'principals/users/' . $userId; @@ -45,63 +44,98 @@ class CalendarService { } /** - * Search for an event by its UID across a user's calendars or in a specific calendar. + * Find an event by UID via direct database query. * - * @return array|null Array with 'calendar' (ICreateFromString) and 'calendarData' (string ICS) or null + * 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; - // Use the calendar search API to find the event by UID - $query = $this->calendarManager->newQuery($principal); + $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) { - $query->addSearchCalendar($calendarUri); + $qb->andWhere($qb->expr()->eq('c.uri', $qb->createNamedParameter($calendarUri))); } - // Search by UID-based property - $query->setSearchPattern($eventUid); - $query->addSearchProperty('UID'); - $query->setLimit(5); + $qb->setMaxResults(1); - $results = $this->calendarManager->searchForPrincipal($query); + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); - foreach ($results as $result) { - $calData = $result['calendardata'] ?? null; - if ($calData === null) { - continue; - } - - // Parse and verify UID matches - try { - $vcalendar = Reader::read($calData); - $vevent = $vcalendar->VEVENT ?? null; - if ($vevent === null) { - continue; - } - - $foundUid = (string)($vevent->UID ?? ''); - if ($foundUid === $eventUid) { - // Find the matching writable calendar - $calendar = $this->getWritableCalendarByUri($userId, $result['calendar-uri'] ?? ''); - if ($calendar !== null) { - return [ - 'calendar' => $calendar, - 'calendarData' => $calData, - 'vcalendar' => $vcalendar, - 'calendarUri' => $result['calendar-uri'] ?? '', - ]; - } - } - } catch (\Throwable $e) { - $this->logger->warning('Failed to parse calendar data while searching for UID', [ - 'uid' => $eventUid, - 'exception' => $e, - ]); - } + if ($row === false) { + $this->logger->debug('Event UID not found in database', [ + 'uid' => $eventUid, + 'user' => $userId, + ]); + return null; } - 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); } /** @@ -110,34 +144,22 @@ class CalendarService { public function processReply(string $userId, VCalendar $replyVcal, string $fromEmail): bool { $replyEvent = $replyVcal->VEVENT ?? null; if ($replyEvent === null) { - $this->logger->debug('No VEVENT in REPLY'); return false; } $eventUid = (string)($replyEvent->UID ?? ''); if ($eventUid === '') { - $this->logger->debug('No UID in REPLY VEVENT'); return false; } $summary = (string)($replyEvent->SUMMARY ?? 'Unknown event'); - // Find the existing event in the user's calendar $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', + $userId, $eventUid, 'SKIPPED', $summary, null, $fromEmail, + "Event not found in any calendar — REPLY ignored", ); - $this->logger->info('Event UID not found in calendar, skipping REPLY', [ - 'uid' => $eventUid, - 'user' => $userId, - ]); return false; } @@ -145,7 +167,6 @@ class CalendarService { $existingVcal = $existing['vcalendar']; $existingEvent = $existingVcal->VEVENT; - // Extract attendee status from the reply $updated = false; if (isset($replyEvent->ATTENDEE)) { foreach ($replyEvent->ATTENDEE as $replyAttendee) { @@ -156,7 +177,6 @@ class CalendarService { continue; } - // Find and update the matching attendee in the existing event if (isset($existingEvent->ATTENDEE)) { foreach ($existingEvent->ATTENDEE as $existingAttendee) { $existingEmail = $this->extractEmail((string)$existingAttendee->getValue()); @@ -165,21 +185,10 @@ class CalendarService { $updated = true; $this->logMapper->log( - $userId, - $eventUid, - $partstat, - $summary, - $replyEmail, - $fromEmail, + $userId, $eventUid, $partstat, $summary, + $replyEmail, $fromEmail, "Attendee {$replyEmail} responded {$partstat}", ); - - $this->logger->info('Updated attendee status', [ - 'uid' => $eventUid, - 'attendee' => $replyEmail, - 'partstat' => $partstat, - 'user' => $userId, - ]); } } } @@ -188,52 +197,31 @@ class CalendarService { if (!$updated) { $this->logMapper->log( - $userId, - $eventUid, - 'SKIPPED', - $summary, - null, - $fromEmail, + $userId, $eventUid, 'SKIPPED', $summary, null, $fromEmail, 'No matching attendee found in existing event', ); return false; } - // Write the updated event back to the calendar + // UPDATE existing object (not create) try { - /** @var ICreateFromString $calendar */ - $calendar = $existing['calendar']; - $icsFilename = $eventUid . '.ics'; - $calendar->createFromString($icsFilename, $existingVcal->serialize()); - - $this->logger->info('Successfully updated calendar event from REPLY', [ - 'uid' => $eventUid, - 'user' => $userId, - ]); - + $this->updateExistingEvent( + $existing['calendarId'], + $existing['objectUri'], + $this->prepareForStorage($existingVcal), + ); return true; } catch (\Throwable $e) { $this->logMapper->log( - $userId, - $eventUid, - 'ERROR', - $summary, - null, - $fromEmail, + $userId, $eventUid, 'ERROR', $summary, null, $fromEmail, 'Failed to write updated event: ' . $e->getMessage(), ); - $this->logger->error('Failed to write updated event to calendar', [ - 'uid' => $eventUid, - 'user' => $userId, - 'exception' => $e, - ]); return false; } } /** - * Process an iMIP REQUEST - add new event or update existing one in the user's calendar. - * Only processes if auto_accept is enabled or the event already exists. + * Process an iMIP REQUEST - add new event or update existing one. */ public function processRequest( string $userId, @@ -254,104 +242,72 @@ class CalendarService { $summary = (string)($requestEvent->SUMMARY ?? 'Unknown event'); - // Check if this event already exists $existing = $this->findEventByUid($userId, $eventUid, $targetCalendarUri); if ($existing !== null) { - // Event exists — update it with the new data (e.g. time change) + // UPDATE existing event try { - /** @var ICreateFromString $calendar */ - $calendar = $existing['calendar']; - $icsFilename = $eventUid . '.ics'; - $calendar->createFromString($icsFilename, $requestVcal->serialize()); - - $this->logMapper->log( - $userId, - $eventUid, - 'UPDATED', - $summary, - null, - $fromEmail, - 'Existing event updated from incoming REQUEST', + $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, + $userId, $eventUid, 'ERROR', $summary, null, $fromEmail, 'Failed to update existing event: ' . $e->getMessage(), ); return false; } } - // Event doesn't exist yet + // CREATE new event if ($autoAccept && $targetCalendarUri !== null) { - // Create the event in the target calendar $calendar = $this->getWritableCalendarByUri($userId, $targetCalendarUri); if ($calendar === null) { $this->logMapper->log( - $userId, - $eventUid, - 'ERROR', - $summary, - null, - $fromEmail, + $userId, $eventUid, 'ERROR', $summary, null, $fromEmail, "Target calendar '{$targetCalendarUri}' not found or not writable", ); return false; } try { - $icsFilename = $eventUid . '.ics'; - $calendar->createFromString($icsFilename, $requestVcal->serialize()); - - $this->logMapper->log( - $userId, + $this->createNewEvent( + $calendar, $eventUid, - 'CREATED', - $summary, - null, - $fromEmail, - 'New event auto-added to calendar from REQUEST', + $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, + $userId, $eventUid, 'ERROR', $summary, null, $fromEmail, 'Failed to create event: ' . $e->getMessage(), ); return false; } } - // Not auto-accepting and event doesn't exist — skip $this->logMapper->log( - $userId, - $eventUid, - 'SKIPPED', - $summary, - null, - $fromEmail, - 'New invitation REQUEST not auto-accepted (enable auto-accept to add new events)', + $userId, $eventUid, 'SKIPPED', $summary, null, $fromEmail, + 'New invitation not auto-accepted (enable auto-accept to add new events)', ); - return false; } /** - * Process an iMIP CANCEL - remove or cancel an existing event. + * Process an iMIP CANCEL - mark existing event as cancelled. */ public function processCancel(string $userId, VCalendar $cancelVcal, string $fromEmail): bool { $cancelEvent = $cancelVcal->VEVENT ?? null; @@ -369,57 +325,37 @@ class CalendarService { $existing = $this->findEventByUid($userId, $eventUid); if ($existing === null) { $this->logMapper->log( - $userId, - $eventUid, - 'SKIPPED', - $summary, - null, - $fromEmail, + $userId, $eventUid, 'SKIPPED', $summary, null, $fromEmail, 'CANCEL received but event not found in calendar', ); return false; } - // Mark the existing event as CANCELLED /** @var VCalendar $existingVcal */ $existingVcal = $existing['vcalendar']; - $existingEvent = $existingVcal->VEVENT; - $existingEvent->STATUS = 'CANCELLED'; + $existingVcal->VEVENT->STATUS = 'CANCELLED'; try { - /** @var ICreateFromString $calendar */ - $calendar = $existing['calendar']; - $icsFilename = $eventUid . '.ics'; - $calendar->createFromString($icsFilename, $existingVcal->serialize()); - - $this->logMapper->log( - $userId, - $eventUid, - 'CANCELLED', - $summary, - null, - $fromEmail, - 'Event marked as CANCELLED per organizer request', + $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, + $userId, $eventUid, 'ERROR', $summary, null, $fromEmail, 'Failed to cancel event: ' . $e->getMessage(), ); return false; } } - /** - * Extract email address from a mailto: URI or plain email. - */ private function extractEmail(string $value): string { $value = trim($value); if (str_starts_with(strtolower($value), 'mailto:')) { @@ -428,9 +364,6 @@ class CalendarService { return trim($value); } - /** - * Get a writable calendar by URI for a user. - */ private function getWritableCalendarByUri(string $userId, string $uri): ?ICreateFromString { $principal = 'principals/users/' . $userId; $calendars = $this->calendarManager->getCalendarsForPrincipal($principal); diff --git a/lib/Service/MailService.php b/lib/Service/MailService.php index c546164..9f7d73b 100644 --- a/lib/Service/MailService.php +++ b/lib/Service/MailService.php @@ -14,6 +14,7 @@ use Sabre\VObject\Reader; * to find and extract iCalendar (ICS) attachments from incoming mail. * * NOTE: Emails are NEVER deleted or modified. We only read them. + * Requires the Nextcloud Mail app to be installed (provides Horde libraries). */ class MailService { @@ -24,6 +25,13 @@ class MailService { ) { } + /** + * Check whether the Horde IMAP libraries are available (provided by Mail app). + */ + public function isImapAvailable(): bool { + return class_exists(\Horde_Imap_Client_Socket::class); + } + /** * Get mail accounts for a user from the Mail app's database. * @@ -70,9 +78,6 @@ class MailService { /** * Find recent messages with iMIP/calendar content in the INBOX. * - * The Mail app uses `imip_message` (boolean) column on oc_mail_messages - * to flag messages containing calendar data. - * * @return array */ public function findRecentImipMessages(string $userId, int $mailAccountId, int $lookbackDays = 7): array { @@ -111,7 +116,7 @@ class MailService { $cutoff->getTimestamp() ))) ->andWhere($qb->expr()->eq('m.imip_message', $qb->createNamedParameter( - true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL + 1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT ))) ->orderBy('m.sent_at', 'DESC') ->setMaxResults(100); @@ -140,22 +145,30 @@ class MailService { } /** - * Fetch the ICS data from a message via IMAP using the Horde library. - * - * This connects to IMAP using the credentials from the Mail app's database, - * fetches the message, and extracts any text/calendar MIME parts. + * Fetch ICS data from multiple messages using a SINGLE IMAP connection. * + * This is much more efficient than connecting per-message. * NOTE: This is READ-ONLY. Emails are never deleted or modified. * - * @return array Extracted ICS data + * @param array $messages Array of message arrays from findRecentImipMessages + * @return array> Keyed by message index */ - public function fetchIcsFromMessage(int $mailAccountId, string $userId, int $mailboxId, int $messageUid): array { + public function fetchIcsFromMessages(int $mailAccountId, string $userId, array $messages): array { + if (empty($messages)) { + return []; + } + + if (!$this->isImapAvailable()) { + throw new \RuntimeException( + 'Horde IMAP libraries not available. Is the Nextcloud Mail app installed?' + ); + } + $accountDetails = $this->getMailAccountDetails($mailAccountId, $userId); if ($accountDetails === null) { throw new \RuntimeException("Mail account {$mailAccountId} not found for user {$userId}"); } - // Decrypt the stored password $password = $this->decryptPassword($accountDetails['inbound_password'] ?? ''); if (empty($password)) { throw new \RuntimeException('Failed to decrypt IMAP password for account ' . $mailAccountId); @@ -170,92 +183,61 @@ class MailService { throw new \RuntimeException('No IMAP host configured for account ' . $mailAccountId); } - $this->logger->debug('Connecting to IMAP', [ - 'host' => $imapHost, - 'port' => $imapPort, - 'user' => $imapUser, - 'ssl' => $imapSsl, - ]); - - // Build IMAP connection using Horde (same library Nextcloud Mail uses) - $imapParams = [ + $client = new \Horde_Imap_Client_Socket([ 'username' => $imapUser, 'password' => $password, 'hostspec' => $imapHost, 'port' => $imapPort, 'secure' => $imapSsl, - ]; + ]); - $client = new \Horde_Imap_Client_Socket($imapParams); + $allResults = []; try { - // Get the mailbox name - $mailboxName = $this->getMailboxName($mailboxId); - if ($mailboxName === null) { - throw new \RuntimeException("Mailbox ID {$mailboxId} not found in database"); + // Group messages by mailbox for efficient fetching + $byMailbox = []; + foreach ($messages as $idx => $message) { + $mailboxId = $message['mailboxId']; + if (!isset($byMailbox[$mailboxId])) { + $byMailbox[$mailboxId] = []; + } + $byMailbox[$mailboxId][$idx] = $message; } - $mailbox = new \Horde_Imap_Client_Mailbox($mailboxName); - - // Fetch the full message body structure - $query = new \Horde_Imap_Client_Fetch_Query(); - $query->structure(); - $query->envelope(); - $query->fullText(['peek' => true]); - - $ids = new \Horde_Imap_Client_Ids([$messageUid]); - $fetchResults = $client->fetch($mailbox, $query, ['ids' => $ids]); - - $icsResults = []; - - foreach ($fetchResults as $fetchData) { - $envelope = $fetchData->getEnvelope(); - $fromAddresses = $envelope->from ?? []; - $from = ''; - if (count($fromAddresses) > 0) { - $firstFrom = $fromAddresses[0]; - $from = ($firstFrom->bare_address ?? ''); - } - - // Get the full message text and parse for ICS parts - $fullText = $fetchData->getFullMsg(); - if ($fullText === null) { + foreach ($byMailbox as $mailboxId => $mailboxMessages) { + $mailboxName = $this->getMailboxName($mailboxId); + if ($mailboxName === null) { + $this->logger->warning("Mailbox ID {$mailboxId} not found in database"); continue; } - // Parse MIME structure to find text/calendar parts - $mimeMessage = \Horde_Mime_Part::parseMessage($fullText); - $icsParts = $this->findCalendarParts($mimeMessage); + $mailbox = new \Horde_Imap_Client_Mailbox($mailboxName); - foreach ($icsParts as $icsPart) { - $icsData = $icsPart->getContents(); - if (empty($icsData)) { + // Fetch all messages in this mailbox at once + $uids = array_map(fn($m) => $m['uid'], $mailboxMessages); + $ids = new \Horde_Imap_Client_Ids($uids); + + $query = new \Horde_Imap_Client_Fetch_Query(); + $query->structure(); + $query->envelope(); + $query->fullText(['peek' => true]); + + $fetchResults = $client->fetch($mailbox, $query, ['ids' => $ids]); + + // Map results back to message indices + foreach ($mailboxMessages as $idx => $message) { + $uid = $message['uid']; + if (!isset($fetchResults[$uid])) { continue; } - // Determine the METHOD from the ICS data - $method = 'REQUEST'; // default - try { - $vcal = Reader::read($icsData); - if (isset($vcal->METHOD)) { - $method = strtoupper((string)$vcal->METHOD); - } - } catch (\Throwable $e) { - $this->logger->warning('Failed to parse ICS to determine method', [ - 'exception' => $e, - ]); + $fetchData = $fetchResults[$uid]; + $icsResults = $this->extractIcsFromFetchData($fetchData); + if (!empty($icsResults)) { + $allResults[$idx] = $icsResults; } - - $icsResults[] = [ - 'ics' => $icsData, - 'method' => $method, - 'from' => $from, - ]; } } - - return $icsResults; - } finally { try { $client->logout(); @@ -263,6 +245,77 @@ class MailService { // ignore logout errors } } + + return $allResults; + } + + /** + * Fetch ICS data from a single message via IMAP. + * Convenience wrapper around fetchIcsFromMessages for single-message use. + * + * @return array + */ + public function fetchIcsFromMessage(int $mailAccountId, string $userId, int $mailboxId, int $messageUid): array { + $messages = [[ + 'uid' => $messageUid, + 'mailboxId' => $mailboxId, + 'messageId' => '', + 'subject' => '', + ]]; + + $results = $this->fetchIcsFromMessages($mailAccountId, $userId, $messages); + return $results[0] ?? []; + } + + /** + * Extract ICS data from a Horde fetch result. + * + * @return array + */ + private function extractIcsFromFetchData($fetchData): array { + $envelope = $fetchData->getEnvelope(); + $fromAddresses = $envelope->from ?? []; + $from = ''; + if (count($fromAddresses) > 0) { + $firstFrom = $fromAddresses[0]; + $from = ($firstFrom->bare_address ?? ''); + } + + $fullText = $fetchData->getFullMsg(); + if ($fullText === null) { + return []; + } + + $mimeMessage = \Horde_Mime_Part::parseMessage($fullText); + $icsParts = $this->findCalendarParts($mimeMessage); + + $results = []; + foreach ($icsParts as $icsPart) { + $icsData = $icsPart->getContents(); + if (empty($icsData)) { + continue; + } + + $method = 'REQUEST'; + try { + $vcal = Reader::read($icsData); + if (isset($vcal->METHOD)) { + $method = strtoupper((string)$vcal->METHOD); + } + } catch (\Throwable $e) { + $this->logger->warning('Failed to parse ICS to determine method', [ + 'exception' => $e, + ]); + } + + $results[] = [ + 'ics' => $icsData, + 'method' => $method, + 'from' => $from, + ]; + } + + return $results; } /** diff --git a/lib/Service/SyncService.php b/lib/Service/SyncService.php index dcee590..3707c29 100644 --- a/lib/Service/SyncService.php +++ b/lib/Service/SyncService.php @@ -60,6 +60,13 @@ class SyncService { 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, @@ -80,30 +87,72 @@ class SyncService { return $stats; } - foreach ($messages as $message) { + 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 { - // Fetch ICS data from the actual email via IMAP (READ-ONLY) - $icsResults = $this->mailService->fetchIcsFromMessage( - $mailAccountId, - $userId, - $message['mailboxId'], - $message['uid'], - ); + $icsResults = $allIcsResults[$idx] ?? []; 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); - } + $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'], @@ -115,10 +164,7 @@ class SyncService { ); } - // Mark message as processed - if ($messageId !== '') { - $this->processedMapper->markProcessed($userId, $mailAccountId, $messageId); - } + $this->markProcessedSafe($userId, $mailAccountId, $messageId); } catch (\Throwable $e) { $stats['errors']++; @@ -139,6 +185,27 @@ class SyncService { 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. */ @@ -181,6 +248,22 @@ class SyncService { } } + /** + * 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. */