This commit is contained in:
Thomas Faour 2026-02-10 23:31:24 -05:00
parent 560686040a
commit a9023d29d9
7 changed files with 411 additions and 308 deletions

View File

@ -4,8 +4,10 @@
<id>mail_calendar_sync</id>
<name>Mail Calendar Sync</name>
<summary>Automatically apply calendar invitation responses from Mail</summary>
<description><![CDATA[Connects your Nextcloud Mail to your Calendar so that iMIP responses (accept, decline, tentative) received via email are automatically applied to matching events.]]></description>
<version>1.1.0</version>
<description><![CDATA[Connects your Nextcloud Mail to your Calendar so that iMIP responses (accept, decline, tentative) received via email are automatically applied to matching events.
Requires the Nextcloud Mail app to be installed and configured.]]></description>
<version>1.2.0</version>
<licence>agpl</licence>
<author mail="dev@example.com">Mail Calendar Sync Contributors</author>
<namespace>MailCalendarSync</namespace>

View File

@ -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);

View File

@ -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,

View File

@ -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')) {

View File

@ -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<array{uri: string, name: string, color: string|null}>
*/
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);

View File

@ -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<array{messageId: string, subject: string, uid: int, mailboxId: int}>
*/
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<array{ics: string, method: string, from: string}> Extracted ICS data
* @param array $messages Array of message arrays from findRecentImipMessages
* @return array<int, array<array{ics: string, method: string, from: string}>> 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<array{ics: string, method: string, from: string}>
*/
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<array{ics: string, method: string, from: string}>
*/
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;
}
/**

View File

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