Skip to main content

vdirsyncer-graph

Microsoft 365 (Exchange Online) calendars, contacts and Microsoft To Do as vdirsyncer storages, talking Microsoft Graph directly — no DavMail, no EWS (which Microsoft is switching off in Exchange Online).

Two packages in one distribution:

  • graphpim — pure mapping Graph JSON ↔ iCalendar (VEVENT/VTODO) / vCard 3.0, no network, pinned by the conformance fixtures in fixtures/. Usable on its own by any Python project.
  • vdirsyncer_graph — three storages (graph_calendar, graph_contacts, graph_tasks) with OAuth2 (device code or browser + PKCE), delta sync and a local cache, plus the vdirsyncer-graph wrapper command.

The semantics follow the Microsoft 365 resource in kdepim-runtime (same author); the fixtures were extracted from its autotests.

Install

uv tool install vdirsyncer-graph      # or: pipx install vdirsyncer-graph

Requires Python ≥ 3.11 and vdirsyncer ≥ 0.19.

Azure app registration (once per user or organisation)

  1. https://portal.azure.com → App registrations → New registration. Supported account types: whatever fits (personal + work accounts is fine).
  2. Authentication → Add a platform → Mobile and desktop applications, redirect URI http://localhost:53682/callback. Under Advanced settings set Allow public client flows: Yes (needed for the device-code login used on headless machines).
  3. API permissions → Microsoft Graph → delegated: User.Read, Calendars.ReadWrite, Contacts.ReadWrite, Tasks.ReadWrite, offline_access.
  4. Copy the Application (client) ID. No client secret is needed (public client).

Configure

[general]
status_path = "~/.local/state/vdirsyncer/status/"

# --- Microsoft 365 ----------------------------------------------------------
[storage m365_cal]
type = "graph_calendar"
client_id = "00000000-0000-0000-0000-000000000000"
token_file = "~/.config/vdirsyncer/m365-token.json"   # shared by all three storages
#tenant = "common"          # or your tenant id / "organizations" / "consumers"
#auth_flow = "device"       # "device" (default, works over SSH) or "browser"
#client_secret = "…"        # only for a confidential ("Web" platform) registration; implies auth_flow = "browser"
#redirect_uri = "http://localhost:53682/callback"   # must match the registration
#cache_dir = "~/.cache/vdirsyncer-graph"

[storage m365_contacts]
type = "graph_contacts"
client_id = "00000000-0000-0000-0000-000000000000"
token_file = "~/.config/vdirsyncer/m365-token.json"

[storage m365_tasks]
type = "graph_tasks"
client_id = "00000000-0000-0000-0000-000000000000"
token_file = "~/.config/vdirsyncer/m365-token.json"

# --- Nextcloud (or any CalDAV/CardDAV server) --------------------------------
[storage nc_cal]
type = "caldav"
url = "https://nextcloud.example.org/remote.php/dav/"
username = "me"
password.fetch = ["command", "pass", "nextcloud"]

[pair work_calendar]
a = "m365_cal"
b = "nc_cal"
collections = [["work", "Kalender", "work"]]     # [alias, Graph calendar name, Nextcloud calendar]
metadata = ["displayname"]
conflict_resolution = "b wins"

Collections are addressed by name (calendar name, contact folder name, task list name). The default contacts folder is contacts; collections = null on a storage means its default calendar / contacts folder / task list.

Run

vdirsyncer-graph graph-login          # one-time OAuth (prints a device code or opens the browser)
vdirsyncer-graph discover
vdirsyncer-graph sync

vdirsyncer-graph is vdirsyncer's own CLI with the Graph storages registered (vdirsyncer has no plugin hook for storages). After graph-login the token file holds a refresh token; sync runs are non-interactive and cron-safe. A dead refresh token (revoked, 90 days idle) triggers a new login on the next run.

How it syncs

  • list() runs a Graph delta query from the last deltaLink and maintains a JSON cache per collection (cache_dir), so a run only transfers server-side changes. Expired delta tokens (HTTP 410) reset the cache transparently.
  • href = Graph id, etag = changeKey (tasks: lastModifiedDateTime), so vdirsyncer's conflict detection works as with DAV.
  • Items uploaded from the other side keep their UID: Graph assigns its own ids and iCalUId, the original UID is remembered per href in the cache.
  • Contact photos are fetched via $batch from /photo/$value and embedded as PHOTO;ENCODING=b; a vCard photo is uploaded after create/update. A vCard without photo never deletes a server-side one.
  • Read-only calendars (Birthdays, Holidays, shared read-only) are discovered as read_only = true.
  • After an upload to Microsoft 365, Exchange bumps the changeKey once more while post-processing the item. The next run therefore fetches it again and writes the Graph-normalised form (UTC times, text body) back to the other side once. This is a one-time echo, not a conflict.

Verified 2026-10-10 against a real Microsoft 365 tenant paired with Nextcloud (calendar ↔ CalDAV, contact folder ↔ CardDAV, To Do list ↔ CalDAV VTODO): creates, updates and deletes propagate in both directions, subsequent runs are delta no-ops.

Limitations

  • Series exceptions/occurrences are skipped; the master event carries the RRULE. Modified single occurrences on the Exchange side are not reflected.
  • Contact birthday is never written back (Exchange would create a Birthdays calendar event); To Do checklist items (subtasks) are not mapped.
  • Microsoft To Do keeps only the date of start/due (the time is dropped server-side, stored as midnight in the mailbox zone). Completing a recurring To Do task makes the server spawn the next occurrence as a new task, which then appears on the other side too. Both are To Do semantics, not sync bugs.
  • Only the signed-in user's own mailbox (/me): no shared mailboxes, no GAL.
  • Renaming or creating collections through vdirsyncer is not supported; create the calendar/list in Outlook first.
  • No If-Match on PATCH (vdirsyncer's etag check happens against the local cache).

Development

uv sync
uv run pytest

Fixtures live in fixtures/ and are the contract; add a case there before changing the mapping. tests/test_storage.py runs the storages against an in-memory fake Graph.

License

LGPL-2.0-or-later, like the kdepim-runtime resource it mirrors.

Metadata

Release files for vdirsyncer-graph 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for vdirsyncer-graph 0.1.0
File Size Uploaded
vdirsyncer_graph-0.1.0.tar.gz 44.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for vdirsyncer-graph 0.1.0
File Interpreter ABI Platform
vdirsyncer_graph-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 83.3 kB

Release files / vdirsyncer_graph-0.1.0.tar.gz

Download URL vdirsyncer_graph-0.1.0.tar.gz
Size 44.2 kB
Tags Source
SHA-256 checksum
How to use checksums
3e2c82642ca7d24139049400ffb6515d03b2fef8145289e366177a3843cdb6f1
BLAKE2b-256 checksum
How to use checksums
671ab80375a60a61d8a2be07ea769ab7383c61b269a0b7f0891ed154016847b8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.23 {"installer":{"name":"uv","version":"0.12.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / vdirsyncer_graph-0.1.0-py3-none-any.whl

Download URL vdirsyncer_graph-0.1.0-py3-none-any.whl
Size 39.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
62c95c12f5bd549f6f7624a477710b10333332f5f9559a540adfcaa964aee1ec
BLAKE2b-256 checksum
How to use checksums
23f2224e977d1eb66d779b3419fe458e4eb3f25f0aed0e7e3e780f57c90b4992
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.23 {"installer":{"name":"uv","version":"0.12.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page