DEV Community

yhzhu
yhzhu

Posted on

Synchronizing Recurring Outlook Meetings with EspoCRM: A Production-Grade Solution

This article was originally published on here

TL;DR

If you're building Outlook Calendar integration with EspoCRM, recurring meetings will break your standard delta sync logic. This article presents a production-grade solution using:

  • Windowed Expansion: Only sync instances within [Today - 7d, Today + 90d]
  • Series Rebuild: When seriesMaster changes, fetch all instances via the Instances API
  • Bootstrap State Machine: Handle cold-start with explicit initialization scan
  • Critical Warning: Never use iCalUId as a unique key for recurring instances

The Problem: Why Delta Sync Fails for Recurring Meetings

Most developers assume Microsoft Graph's Delta Sync API will notify them of every calendar change. This works fine for single events. For recurring meetings, it breaks down.

Microsoft's Data Model

In Microsoft Graph API, a recurring meeting isn't a single record. It's split into three types:

Type Description Stored in Outlook
seriesMaster The template defining recurrence rules (e.g., "every Thursday at 2 PM") Physical record
occurrence A specific instance (e.g., "Jan 8, 2026 at 2 PM") Virtual, computed from Master
exception A modified instance (e.g., "Just this one, move to 3 PM") Physical record

The Delta Sync Trap

When you modify a seriesMaster (e.g., change "weekly Monday" to "weekly Tuesday"), the Delta API returns only the seriesMaster change. It does NOT return the 20+ occurrence changes for future instances.

Your CRM continues showing meetings on Mondays. Users complain. Debugging reveals no sync errors—the delta data simply never contained those changes.

Common Failure Patterns

Symptom Root Cause
Missing instances Only processed seriesMaster, never expanded occurrences
Duplicate instances Used iCalUId as dedupe key (same for entire series)
Orphaned instances Deleted series in Outlook, only Master removed from CRM
Time drift Expanded in UTC without proper timezone handling

The Solution: Windowed Expansion + Series Rebuild

Core Principle: The Window

Never sync infinite future. Define a "window of interest" such as [Today - 7d, Today + 90d].

This makes any recurring series—even "no end date" ones—produce a constant, bounded number of instances.

diagram

Architecture Overview

diagram


Implementation Details

1. The Bootstrap State Machine

Delta Sync is "change-driven," not "presence-driven." Meetings created years ago with no recent changes won't appear in delta results. This causes silent missing data on first sync.

Solution: Track bootstrap state per user calendar:

<?php
class OutlookCalendarService
{
    const STATUS_PENDING = 'Pending';
    const STATUS_COMPLETED = 'Completed';

    public function syncUserCalendar(string $userId): void
    {
        $calendar = $this->getCalendar($userId);

        if ($calendar->getBootstrapStatus() === self::STATUS_PENDING) {
            $this->runBootstrapScan($calendar);
            $calendar->setBootstrapStatus(self::STATUS_COMPLETED);
            $this->em->save($calendar);
        }

        $this->runDeltaSync($calendar);
    }

    private function runBootstrapScan(OutlookCalendar $calendar): void
    {
        $windowStart = new \DateTime('-7 days');
        $windowEnd = new \DateTime('+90 days');

        // Get ALL seriesMasters in window, not just changed ones
        $masters = $this->graphClient->getCalendarView(
            $calendar->getExternalAccountId(),
            $windowStart,
            $windowEnd,
            ['$filter' => "type eq 'seriesMaster'"]
        );

        foreach ($masters as $master) {
            $this->rebuildSeries($master['id'], $windowStart, $windowEnd);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Series Rebuild Strategy

When seriesMaster changes, trigger instance rebuild:

<?php
private function rebuildSeries(string $masterId, \DateTime $start, \DateTime $end): void
{
    // Fetch all instances in window
    $instances = $this->graphClient->getInstances($masterId, $start, $end);

    $apiInstanceIds = array_column($instances, 'id');

    // Get existing CRM instances for this series
    $existing = $this->em->getRepository(OutlookEvent::class)
        ->findBy(['seriesMasterId' => $masterId]);

    $existingIds = array_map(fn($e) => $e->getEventId(), $existing);

    // Diff: Create new, Update existing, Delete missing
    foreach ($instances as $instance) {
        $this->syncInstance($instance);
    }

    // Delete instances that disappeared from API (but within window)
    foreach ($existing as $event) {
        if (!in_array($event->getEventId(), $apiInstanceIds)) {
            if ($event->getStart()->between($start, $end)) {
                $this->em->remove($event);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

3. The iCalUId Trap (CRITICAL!)

RFC 5545 specifies that ALL occurrences in a recurring series share the same UID.

This means iCalUId is NOT unique for recurring instances. Using it for deduplication will merge all your weekly meetings into one record.

<?php
// WRONG: This will break recurring meetings
$meeting = $this->findByEventId($eventId)
    ?? $this->findByICalUId($iCalUId);  // ❌ DO NOT DO THIS

// CORRECT: Never use iCalUId fallback for recurring instances
if ($eventType === 'occurrence' || $eventType === 'exception') {
    $meeting = $this->findByEventId($eventId);  // ✅ Use eventId only
} else {
    $meeting = $this->findByEventId($eventId)
        ?? $this->findByICalUId($iCalUId);  // ✅ OK for single events
}
Enter fullscreen mode Exit fullscreen mode

4. Complete Sync Sequence

diagram

5. Quota Management

Recurring series generate massive record counts. One user with 10 weekly meetings = ~120 instances per quarter.

Protect against API throttling:

<?php
class SyncLimits
{
    private const MAX_INSTANCES_PER_RUN = 200;
    private const MAX_SERIES_REBUILD_PER_RUN = 5;

    private $instancesProcessed = 0;
    private $seriesRebuilt = 0;

    public function shouldProcessMoreInstances(): bool
    {
        return $this->instancesProcessed < self::MAX_INSTANCES_PER_RUN;
    }

    public function shouldRebuildMoreSeries(): bool
    {
        return $this->seriesRebuilt < self::MAX_SERIES_REBUILD_PER_RUN;
    }

    public function recordInstance(): void
    {
        $this->instancesProcessed++;
    }

    public function recordSeriesRebuild(): void
    {
        $this->seriesRebuilt++;
    }
}
Enter fullscreen mode Exit fullscreen mode

Production Checklist

Before deploying to production, verify:

  • [ ] DST Handling: Create a weekly meeting spanning DST transition (March/November). Verify times don't drift
  • [ ] Exception Preservation: Modify one instance's time, then change series subject. Verify the modified instance keeps its time
  • [ ] Deletion Sync: Delete one future instance in Outlook. Confirm CRM removes it. Delete entire series. Confirm all instances are removed
  • [ ] Long-running Series: Create a monthly meeting for 2 years. Verify only ~90 days are synced
  • [ ] All-day Events: Create all-day recurring event. Verify it doesn't span two days due to timezone conversion
  • [ ] Token Recovery: Simulate delta token expiration. Verify system falls back to full sync
  • [ ] Logging: All sync operations log (seriesMasterId, instanceId, operation) for debugging

Comparison: Outlook vs Google Calendar

Aspect Microsoft Graph (Outlook) Google Calendar API
Recurring Model Master + Exception (occurrences are virtual) Event + Recurrence (can expand with singleEvents=true)
Delta Behavior Master change returns only Master Master change can return all affected instances
ID Reference seriesMasterId recurringEventId
Deletion Physical delete or status change Often status: cancelled
Complexity ⭐⭐⭐⭐⭐ ⭐⭐⭐

Google's singleEvents=true parameter makes expansion easier, but the windowed approach remains necessary for performance.


Key Takeaways

  1. Never trust Delta Sync alone for recurring meetings. Use delta as a signal, not as truth.
  2. Windowed expansion is essential. Infinite series must be bounded to a constant number of instances.
  3. iCalUId is half-truth. Same for all occurrences—dangerous as a unique key.
  4. Bootstrap state matters. Explicit initialization scan prevents "cold start" data gaps.
  5. Log everything. When debugging sync issues, you'll need the history.

Data Model Reference

diagram


Resources


Thanks for reading! You can find the original post on here

If you found this helpful, feel free to connect with me

Top comments (0)