Working
This commit is contained in:
parent
560686040a
commit
a9023d29d9
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user