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.
*/