0%

Synchronizing Recurring Outlook Meetings with EspoCRM

🌐 Language: English Version | 中文版

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.

gantt
    title Window Sliding Over Time
    dateFormat  YYYY-MM-DD
    axisFormat  %m/%d
    section Window (90d)
    Initial window  :a1, 2026-01-01, 90d
    Slid window     :a2, 2026-02-01, 90d
    section Series
    Weekly instances :crit, 2026-01-01, 180d

Architecture Overview

flowchart TD
    Start["Start Sync"] --> GetDelta["Graph API: Delta Sync"]
    GetDelta --> Loop{"Process Changes"}

    Loop -- "Type=seriesMaster" --> MarkResync["Mark for Rebuild"]
    Loop -- "Type=occurrence" --> SyncItem["Sync Instance (No iCalUId Fallback)"]
    Loop -- "Type=singleInstance" --> SyncNormal["Standard Sync"]

    MarkResync --> TriggerResync["Trigger Series Rebuild"]

    subgraph SeriesResync ["Series Rebuild"]
        Fetch["Graph API: List Instances (Windowed)"]
        Upsert["Bulk Upsert Instances"]
        Cleanup["Clean Removed Instances"]
    end

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?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);
}
}
}

2. Series Rebuild Strategy

When seriesMaster changes, trigger instance rebuild:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?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);
}
}
}
}

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.

1
2
3
4
5
6
7
8
9
10
11
12
<?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
}

4. Complete Sync Sequence

sequenceDiagram
  participant S as Scheduler/Job
  participant SS as SyncService
  participant DB as Database
  participant P as Provider(Outlook/Google)
  participant W as WindowExpander

  S->>SS: Trigger user sync
  SS->>DB: Check bootstrapStatus

  alt Status is Pending (Cold Start)
    SS->>W: Force Windowed Scan (-7d..+90d)
    W->>P: List all instances in window
    W->>DB: Initialize Series & Instances
    SS->>DB: Update bootstrapStatus = Completed
  else Status is Completed (Incremental)
    SS->>P: Fetch delta (token/page)
    alt Series master changed
      SS->>W: Mark series for rebuild
      W->>P: List instances (window: -7d..+90d)
      W->>DB: Upsert created/updated
      W->>DB: Delete window-missing
    else Occurrence/Exception changed
      SS->>DB: Upsert occurrence (no iCalUId fallback)
    end
  end
  SS-->>S: Report metrics

5. Quota Management

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

Protect against API throttling:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?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++;
}
}

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

erDiagram
    OUTLOOK_CALENDAR_USER ||--o{ OUTLOOK_CALENDAR_SERIES : tracks
    OUTLOOK_CALENDAR_USER ||--o{ OUTLOOK_CALENDAR_EVENT : owns
    INTERNAL_EVENT ||--o{ OUTLOOK_CALENDAR_EVENT : linked-by

    OUTLOOK_CALENDAR_USER {
        string id PK
        string deltaToken
        string bootstrapStatus
    }

    OUTLOOK_CALENDAR_SERIES {
        string id PK
        string seriesMasterEventId
        boolean resyncRequested
    }

    OUTLOOK_CALENDAR_EVENT {
        string id PK
        string eventId "Immutable Outlook ID"
        string iCalUId "Shared by Series - NOT unique!"
        string eventType "occurrence/master/exception"
        string seriesMasterEventId
        datetime windowStart
        datetime windowEnd
    }

    INTERNAL_EVENT {
        string id PK
        string subject
        datetime start
        datetime end
    }

Resources