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 infixtures/. 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 thevdirsyncer-graphwrapper 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)
- https://portal.azure.com → App registrations → New registration. Supported account types: whatever fits (personal + work accounts is fine).
- 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). - API permissions → Microsoft Graph → delegated:
User.Read,Calendars.ReadWrite,Contacts.ReadWrite,Tasks.ReadWrite,offline_access. - 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 lastdeltaLinkand 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
$batchfrom/photo/$valueand embedded asPHOTO;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
changeKeyonce 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
birthdayis 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-Matchon 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)
| File | Size | Uploaded | |
|---|---|---|---|
| vdirsyncer_graph-0.1.0.tar.gz | 44.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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}
|