295 lines
10 KiB
PHP
295 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\MailCalendarSync\Service;
|
|
|
|
use OCA\MailCalendarSync\Db\Config;
|
|
use OCA\MailCalendarSync\Db\ConfigMapper;
|
|
use OCA\MailCalendarSync\Db\LogMapper;
|
|
use OCA\MailCalendarSync\Db\ProcessedMessageMapper;
|
|
use OCP\AppFramework\Db\DoesNotExistException;
|
|
use Psr\Log\LoggerInterface;
|
|
use Sabre\VObject\Reader;
|
|
|
|
/**
|
|
* Orchestrates the full mail → calendar sync process for a user.
|
|
*
|
|
* NOTE: This service is READ-ONLY on email. It never deletes or modifies
|
|
* any email messages. It only reads ICS attachments and applies them
|
|
* to the calendar.
|
|
*/
|
|
class SyncService {
|
|
|
|
public function __construct(
|
|
private ConfigMapper $configMapper,
|
|
private MailService $mailService,
|
|
private CalendarService $calendarService,
|
|
private ProcessedMessageMapper $processedMapper,
|
|
private LogMapper $logMapper,
|
|
private LoggerInterface $logger,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Run sync for a specific user.
|
|
*
|
|
* @return array{processed: int, updated: int, errors: int, messages: string[]}
|
|
*/
|
|
public function syncForUser(string $userId): array {
|
|
$stats = ['processed' => 0, 'updated' => 0, 'errors' => 0, 'messages' => []];
|
|
|
|
try {
|
|
$config = $this->configMapper->findByUserId($userId);
|
|
} catch (DoesNotExistException $e) {
|
|
$stats['messages'][] = 'No configuration found. Please save your settings first.';
|
|
return $stats;
|
|
}
|
|
|
|
if (!$config->isEnabled()) {
|
|
$stats['messages'][] = 'Sync is disabled.';
|
|
return $stats;
|
|
}
|
|
|
|
$mailAccountId = $config->getMailAccountId();
|
|
$calendarUri = $config->getCalendarUri();
|
|
$autoAccept = $config->isAutoAccept();
|
|
|
|
if ($mailAccountId === null || $calendarUri === null) {
|
|
$stats['messages'][] = 'Incomplete configuration — please select both a mail account and calendar.';
|
|
return $stats;
|
|
}
|
|
|
|
// Check that Horde IMAP libraries are available
|
|
if (!$this->mailService->isImapAvailable()) {
|
|
$stats['errors']++;
|
|
$stats['messages'][] = 'Horde IMAP libraries not found. Is the Nextcloud Mail app installed and enabled?';
|
|
return $stats;
|
|
}
|
|
|
|
$this->logger->info('Starting mail-calendar sync', [
|
|
'userId' => $userId,
|
|
'mailAccountId' => $mailAccountId,
|
|
'calendarUri' => $calendarUri,
|
|
]);
|
|
|
|
// Find recent iMip messages
|
|
try {
|
|
$messages = $this->mailService->findRecentImipMessages($userId, $mailAccountId);
|
|
$stats['messages'][] = 'Found ' . count($messages) . ' unprocessed iMIP message(s) in last 7 days.';
|
|
} catch (\Throwable $e) {
|
|
$stats['errors']++;
|
|
$stats['messages'][] = 'Failed to query mail: ' . $e->getMessage();
|
|
$this->logger->error('Failed to find iMIP messages', [
|
|
'userId' => $userId,
|
|
'exception' => $e,
|
|
]);
|
|
return $stats;
|
|
}
|
|
|
|
if (empty($messages)) {
|
|
return $stats;
|
|
}
|
|
|
|
// Fetch ICS data from ALL messages using a single IMAP connection
|
|
try {
|
|
$allIcsResults = $this->mailService->fetchIcsFromMessages($mailAccountId, $userId, $messages);
|
|
} catch (\Throwable $e) {
|
|
$stats['errors']++;
|
|
$stats['messages'][] = 'Failed to connect to IMAP: ' . $e->getMessage();
|
|
$this->logger->error('IMAP connection failed', [
|
|
'userId' => $userId,
|
|
'exception' => $e,
|
|
]);
|
|
return $stats;
|
|
}
|
|
|
|
// Track event UIDs across ALL messages in this batch to avoid
|
|
// processing the same event UID multiple times (e.g., duplicate
|
|
// REPLY emails for the same invitation)
|
|
$batchSeenUids = [];
|
|
|
|
// Process each message's ICS results
|
|
foreach ($messages as $idx => $message) {
|
|
$stats['processed']++;
|
|
$messageId = $message['messageId'];
|
|
// Track UIDs within this message to avoid duplicate ICS parts
|
|
// (emails often include the same ICS both inline and as attachment)
|
|
$seenUids = [];
|
|
|
|
try {
|
|
$icsResults = $allIcsResults[$idx] ?? [];
|
|
|
|
if (empty($icsResults)) {
|
|
$stats['messages'][] = "No ICS data found in message: {$message['subject']}";
|
|
$this->markProcessedSafe($userId, $mailAccountId, $messageId);
|
|
continue;
|
|
}
|
|
|
|
foreach ($icsResults as $icsResult) {
|
|
// Deduplicate: emails often contain the same ICS both
|
|
// inline and as an attachment. Skip if we already
|
|
// processed this UID from this message.
|
|
$icsUid = $this->extractUidFromIcs($icsResult['ics']);
|
|
if ($icsUid !== '' && isset($seenUids[$icsUid])) {
|
|
continue;
|
|
}
|
|
if ($icsUid !== '') {
|
|
$seenUids[$icsUid] = true;
|
|
}
|
|
|
|
// Batch-level deduplication: skip if we already processed
|
|
// this event UID from a different message in this batch.
|
|
// This prevents duplicate processing when multiple emails
|
|
// contain the same calendar invitation (common with Gmail).
|
|
if ($icsUid !== '' && isset($batchSeenUids[$icsUid])) {
|
|
$this->logger->debug('Skipping duplicate event UID from different message in batch', [
|
|
'uid' => $icsUid,
|
|
'messageId' => $messageId,
|
|
]);
|
|
continue;
|
|
}
|
|
if ($icsUid !== '') {
|
|
$batchSeenUids[$icsUid] = true;
|
|
}
|
|
|
|
$this->processIcs(
|
|
$userId,
|
|
$icsResult['ics'],
|
|
$icsResult['method'],
|
|
$icsResult['from'],
|
|
$calendarUri,
|
|
$autoAccept,
|
|
$stats,
|
|
);
|
|
}
|
|
|
|
$this->markProcessedSafe($userId, $mailAccountId, $messageId);
|
|
|
|
} catch (\Throwable $e) {
|
|
$stats['errors']++;
|
|
$stats['messages'][] = "Error processing '{$message['subject']}': " . $e->getMessage();
|
|
$this->logger->error('Error processing message', [
|
|
'messageId' => $messageId,
|
|
'userId' => $userId,
|
|
'exception' => $e,
|
|
]);
|
|
}
|
|
}
|
|
|
|
$this->logger->info('Mail-calendar sync completed', [
|
|
'userId' => $userId,
|
|
'stats' => $stats,
|
|
]);
|
|
|
|
return $stats;
|
|
}
|
|
|
|
/**
|
|
* Mark a message as processed, handling unique constraint violations
|
|
* gracefully (e.g. if processed by a concurrent run).
|
|
*/
|
|
private function markProcessedSafe(string $userId, int $mailAccountId, string $messageId): void {
|
|
if ($messageId === '') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->processedMapper->markProcessed($userId, $mailAccountId, $messageId);
|
|
} catch (\Throwable $e) {
|
|
// Likely a unique constraint violation from concurrent processing — safe to ignore
|
|
$this->logger->debug('Could not mark message as processed (possibly already processed)', [
|
|
'messageId' => $messageId,
|
|
'userId' => $userId,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process a single ICS payload.
|
|
*/
|
|
private function processIcs(
|
|
string $userId,
|
|
string $icsData,
|
|
string $method,
|
|
string $fromEmail,
|
|
string $calendarUri,
|
|
bool $autoAccept,
|
|
array &$stats,
|
|
): void {
|
|
try {
|
|
$vcalendar = Reader::read($icsData);
|
|
} catch (\Throwable $e) {
|
|
$this->logger->warning('Failed to parse ICS data', ['exception' => $e]);
|
|
$stats['errors']++;
|
|
$stats['messages'][] = 'Failed to parse ICS: ' . $e->getMessage();
|
|
return;
|
|
}
|
|
|
|
$vevent = $vcalendar->VEVENT ?? null;
|
|
$summary = $vevent ? (string)($vevent->SUMMARY ?? 'Unknown') : 'Unknown';
|
|
$uid = $vevent ? (string)($vevent->UID ?? '') : '';
|
|
|
|
$success = match ($method) {
|
|
'REPLY' => $this->calendarService->processReply($userId, $vcalendar, $fromEmail),
|
|
'REQUEST' => $this->calendarService->processRequest(
|
|
$userId, $vcalendar, $fromEmail, $calendarUri, $autoAccept
|
|
),
|
|
'CANCEL' => $this->calendarService->processCancel($userId, $vcalendar, $fromEmail),
|
|
default => false,
|
|
};
|
|
|
|
if ($success) {
|
|
$stats['updated']++;
|
|
$stats['messages'][] = "{$method}: Updated '{$summary}' from {$fromEmail}";
|
|
} else {
|
|
$stats['messages'][] = "{$method}: No action for '{$summary}' (event may not exist in calendar)";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract the UID from raw ICS data without full parsing.
|
|
*/
|
|
private function extractUidFromIcs(string $icsData): string {
|
|
try {
|
|
$vcal = Reader::read($icsData);
|
|
$vevent = $vcal->VEVENT ?? null;
|
|
if ($vevent !== null) {
|
|
return (string)($vevent->UID ?? '');
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Fall through
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Run sync for all enabled users.
|
|
*/
|
|
public function syncAll(): array {
|
|
$configs = $this->configMapper->findAllEnabled();
|
|
$results = [];
|
|
|
|
foreach ($configs as $config) {
|
|
$userId = $config->getUserId();
|
|
try {
|
|
$results[$userId] = $this->syncForUser($userId);
|
|
} catch (\Throwable $e) {
|
|
$this->logger->error('Sync failed for user', [
|
|
'userId' => $userId,
|
|
'exception' => $e,
|
|
]);
|
|
$results[$userId] = [
|
|
'processed' => 0,
|
|
'updated' => 0,
|
|
'errors' => 1,
|
|
'messages' => ['Sync failed: ' . $e->getMessage()],
|
|
];
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
}
|