One-way sync of Outlook meeting invitations into Apple Calendar, plus a small web dashboard so you can see everything at a glance. Reads your Outlook calendar via the Microsoft Graph API and mirrors events into an iCloud calendar over CalDAV. Reschedules, location changes, cancellations, and attendee updates flow through; file attachments are cached locally and served from the dashboard.
Designed to run on a NAS (or any always-on Linux box): one long-running process serves the dashboard, cron kicks off the sync every few minutes.
- Events in the window
[now - SYNC_WINDOW_PAST_DAYS, now + SYNC_WINDOW_FUTURE_DAYS] - Recurring events are expanded per occurrence (via Graph
calendarView) so a single rescheduled occurrence updates independently of the series - By default, only events where you are an invitee (not the organizer) —
set
SYNC_INVITES_ONLY=falseto mirror everything - Cancellations and events that fall out of the window are removed from the mirror calendar
It does not modify your Outlook calendar, and it only writes to the single
iCloud calendar you name (ICLOUD_CALENDAR_NAME). Keep that calendar
dedicated to the sync — anything you put in it by hand may be deleted.
- Go to https://entra.microsoft.com/ → App registrations → New registration.
- Name it anything. Supported account types: "Accounts in any organizational directory and personal Microsoft accounts" (unless you only need work).
- After creation, open Authentication → Add a platform → Mobile and
desktop applications → add redirect URI
http://localhostand enable "Allow public client flows". - API permissions → Microsoft Graph → Delegated → add:
Calendars.ReadMail.Read(optional; only needed if you later extend this to scan mail)User.Readoffline_access
- Copy the Application (client) ID into
MS_CLIENT_ID.
- https://appleid.apple.com/ → Sign-In and Security → App-Specific Passwords.
- Create one for "Outlook Sync" and put it in
ICLOUD_APP_PASSWORD. - In the Apple Calendar app, create a new calendar (e.g. Outlook) under your
iCloud account and set
ICLOUD_CALENDAR_NAMEto match.
git clone <this repo> /volume1/apps/outlook-apple-sync
cd /volume1/apps/outlook-apple-sync
cp .env.example .env # fill in credentials
docker compose build
# First-time login (interactive device-code flow). The `sync` service is a
# CLI helper under the "cli" compose profile.
docker compose --profile cli run --rm sync login
# Verify creds
docker compose --profile cli run --rm sync check
# Start the dashboard (binds 0.0.0.0:8765 in the container)
docker compose up -d webDashboard will be at http://<nas>:8765. Then install the cron line from
deploy/nas-cron.example so the sync runs every 10 minutes.
python3 -m venv /opt/outlook-apple-sync/.venv
/opt/outlook-apple-sync/.venv/bin/pip install -e /opt/outlook-apple-sync
/opt/outlook-apple-sync/.venv/bin/outlook-apple-sync login
sudo cp deploy/systemd/* /etc/systemd/system/
sudo systemctl enable --now outlook-apple-sync.timeroutlook-apple-sync login # one-time device-code login
outlook-apple-sync check # prove Outlook + iCloud creds work
outlook-apple-sync sync # run one sync pass
outlook-apple-sync status # show recent runs
outlook-apple-sync web # serve the dashboard (default 127.0.0.1:8765)
outlook-apple-sync web --host 0.0.0.0 --port 8765 starts a read-only
dashboard backed by the same SQLite database the sync writes. Features:
- Up next card with live countdown and Join button
- Today and Next 7 days lists
- Event detail pages with organizer, location, attendees, notes, and attachments
- File attachments are served from the local cache; reference attachments (OneDrive/SharePoint links) redirect out; nested items are listed but open in Outlook
- Sync now button triggers a background sync; the page auto-refreshes every minute
Auth: set WEB_USERNAME and WEB_PASSWORD in .env to enable HTTP basic
auth. Leave them unset if you're binding to 127.0.0.1 only or sitting
behind a reverse proxy / Tailscale / VPN.
GET /me/calendarViewreturns every event instance in the configured window, withiCalUIdincluded.- Each event is rendered to iCalendar text and hashed on semantic fields (subject, start/end, location, attendees, join URL, cancellation flag).
- SQLite (
state/sync.sqlite) maps Outlook event IDs to the CalDAV href and the last content hash. - For each Outlook event: insert if new, overwrite in iCloud if the hash
changed, otherwise just touch
last_seen_run. - Any row whose
last_seen_runwasn't updated this pass is considered removed and deleted from iCloud — but only if the Graph fetch completed successfully, so a transient network error can't wipe your calendar. - File attachments are downloaded lazily per event (only when
hasAttachments=true) and stored understate/attachments/<hash>/. SQLite is kept in WAL mode so the dashboard can read while the sync writes.
- One-way only: changes you make in Apple Calendar are not pushed back to Outlook and may be overwritten.
- No attendee RSVP: the sync doesn't respond to invitations on your behalf.
- Window-bound: events outside
[past, future]are not mirrored. Keep the window large enough to cover your planning horizon. - Throttling: if Graph returns 429, the run aborts and cron retries on the next tick; no backoff inside a single run.