DEV Community

Cover image for Syncing Office 365 & Google Calendar Without OAuth: A GAS Solution
Yuto Takashi
Yuto Takashi

Posted on

Syncing Office 365 & Google Calendar Without OAuth: A GAS Solution

Why you should care

Working across multiple organizations means juggling multiple calendars. One uses Office 365, another uses Google Workspace. I was manually checking both before scheduling meetings—until I double-booked myself twice in one month.

This is how I fixed it using Google Apps Script, without needing OAuth permissions from IT.

The problem

I needed to:

  • See Office 365 events in Google Calendar
  • See Google events in Office 365
  • Prevent double-booking

Sounds simple, right?

Attempt 1: Existing tools

I tried OneCal, CalendarBridge, and SyncGene. All around $5-10/month. All promised bidirectional sync.

None of them worked.

Why? OAuth restrictions. My Office 365 organization blocks external app connections. The OAuth screen said "Administrator approval required."

Sure, I could file a ticket with IT asking them to approve a calendar sync tool. But let's be realistic—that's not happening.

Attempt 2: Build my own?

I considered building a full bidirectional sync tool. Then I realized what that would involve:

  • Infinite loop prevention (A→B→A→B...)
  • Event ID mapping (Google IDs ≠ Outlook IDs)
  • Conflict resolution (simultaneous edits)
  • Deletion handling (what if someone deletes on one side?)

That's basically rebuilding OneCal from scratch. Hard pass.

Let me reframe the requirement: I don't need perfect sync. I just need to avoid double-booking.

The solution: One direction at a time

Google → Outlook (5 minutes)

This was easy. Outlook Web has an "Add calendar from Internet" feature.

  1. Get your Google Calendar's ICS URL (Settings → Integrate → Secret address in iCal format)
  2. Paste it into Outlook's "Add calendar from Internet"
  3. Done

Outlook now shows my Google events and considers them for availability.

Time spent: 5 minutes.

Outlook → Google (5 days)

Same approach, right? Get Outlook's ICS URL, subscribe to it in Google Calendar.

It worked—sort of. I could see the Outlook events in Google Calendar.

But here's the catch: Google Calendar's URL subscriptions are read-only and don't count toward availability.

When I tried to create a new event in Google, it didn't warn me about conflicts with my subscribed Outlook calendar. The time showed as "available" even though I had a meeting.

This defeats the whole purpose.

The fix: Create actual events with GAS

If subscribed calendars don't count toward availability, I'll create real events.

The plan:

  • Read events from the Outlook ICS
  • Create "Block" events in a dedicated Google Calendar
  • Sync only time ranges (no titles, no details—security win)
  • These are real events, so Google respects them for availability

This approach:

  • ✅ Bypasses OAuth restrictions (ICS is a public URL)
  • ✅ Prevents double-booking (real events)
  • ✅ Protects sensitive info (titles aren't synced)

Implementation hell

Issue 1: Time format inconsistency

I assumed all timestamps in ICS would be YYYYMMDDTHHMMSS.

Nope. Outlook sometimes outputs YYYYMMDDTHHMM (no seconds).

DTSTART;TZID=Tokyo Standard Time:20260109T143000
Enter fullscreen mode Exit fullscreen mode

Fixed with dual regex patterns:

// Try YYYYMMDDTHHMM first
let m = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(Z)?$/);
if (m) { /* parse */ }

// Then try YYYYMMDDTHHMMSS
m = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/);
if (m) { /* parse */ }
Enter fullscreen mode Exit fullscreen mode

Issue 2: Recurring events share UIDs

Weekly meetings were only partially syncing. Turns out, Outlook reuses the same UID for all instances of a recurring event.

Example master event:

BEGIN:VEVENT
UID:040000008200E00074C5B7101A82E008...
DTSTART:20251223T130000
DTEND:20251223T140000
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TU
END:VEVENT
Enter fullscreen mode Exit fullscreen mode

If one instance is moved to a different time, there's an exception:

BEGIN:VEVENT
UID:040000008200E00074C5B7101A82E008... (same UID!)
RECURRENCE-ID;TZID=Tokyo Standard Time:20260106T130000
DTSTART;TZID=Tokyo Standard Time:20260109T143000
DTEND;TZID=Tokyo Standard Time:20260109T153000
END:VEVENT
Enter fullscreen mode Exit fullscreen mode

Using only UID as the key meant events kept overwriting each other.

Fix: Use UID + start time as the key.

const instanceId = e.recurrenceIdRaw || formatIcsLocalKey_(start);
const key = `${e.uid}|${instanceId}`;
Enter fullscreen mode Exit fullscreen mode

Issue 3: Duplicate events

Some events appeared twice—same time, same title.

Root cause: Outlook outputs both the master event (with RRULE) AND individual instances (with RECURRENCE-ID) for the same occurrence.

Fix: Normalize events. When multiple events have the same start time, prefer the one with RECURRENCE-ID.

function normalizeEvents_(events) {
  const map = new Map();
  for (const e of events) {
    const k = `${e.uid}|${formatIcsLocalKey_(e.dtstart)}`;
    if (!map.has(k)) {
      map.set(k, e);
      continue;
    }
    const existing = map.get(k);
    // Prefer RECURRENCE-ID version
    if (!existing.recurrenceIdRaw && e.recurrenceIdRaw) {
      map.set(k, e);
    }
  }
  return Array.from(map.values());
}
Enter fullscreen mode Exit fullscreen mode

Cutting scope

At this point, I stepped back.

The goal is avoiding double-booking, not perfect calendar replication.

I don't need:

  • Event titles
  • Event descriptions
  • Attendee lists

I just need: "This time slot is busy."

Final spec:

  • Events with the same time range → merge into one block
  • Title: always "Block"
  • Description: empty
  • If the block calendar gets corrupted, just delete and resync

This simplification made everything easier.

The key became simply: start_time + end_time

const eventKey = `${startTime.getTime()}_${endTime.getTime()}`;
Enter fullscreen mode Exit fullscreen mode

Same time = same key = one block. Done.

Final architecture

Source: Office 365 calendar (subscribed in Google Calendar via ICS)

Target: Dedicated "Block" calendar in Google Workspace

Sync frequency: Every 4 hours

Sync method: Differential (not full rebuild)

Why differential? Efficiency. If I have 100 events and do full delete + recreate every 4 hours, that's 1,200 API calls per day. Differential sync only touches events that changed.

The sync stores a mapping (event key → Google event ID) in Script Properties:

const map = JSON.parse(props.getProperty(PROP_MAP) || '{}');
Enter fullscreen mode Exit fullscreen mode

Sync logic:

  1. Fetch events from source calendar
  2. Calculate key (start + end time) for each event
  3. If key exists in map → update event
  4. If key is new → create event
  5. Keys not seen this run → delete event

Total code: ~200 lines.

Results

Deployed today. So far, so good.

When I create an event in Google Calendar, it correctly warns me if that time slot has an Outlook event. No more double-booking.

The 4-hour sync delay is fine for my use case. If I need tighter sync, I can adjust the trigger frequency.

What I learned

Outlook ICS quirks

  • Time formats inconsistent (seconds optional)
  • Recurring events share UIDs
  • Exception instances use RECURRENCE-ID
  • Master + exception dual representation

Google Calendar ICS subscriptions

  • Read-only, don't affect availability
  • Real events do affect availability

RFC 5545 (iCalendar spec)

  • Line folding exists
  • RECURRENCE-ID is crucial for recurring events

Takeaways

I couldn't use existing tools due to OAuth restrictions.

Building a full sync tool was overkill.

The sweet spot: Do the minimum that solves the problem.

Tradeoffs I accepted:

  • No event titles → actually good for security
  • 4-hour delay → fine for my workflow
  • Multiple events at same time → collapsed into one block

The key decision: "I just need to know when I'm busy."

That clarity made everything else fall into place.


If you're interested in more about decision-making in engineering and balancing technical constraints with practical needs, I write about it here:

👉 https://tielec.blog/

Top comments (0)