Metadata-Version: 2.5
Name: vdirsyncer-graph
Version: 0.1.0
Summary: Microsoft Graph (Microsoft 365) backend for vdirsyncer plus the language-neutral graphpim mapping library (Graph JSON <-> iCalendar/vCard)
Project-URL: Homepage, https://github.com/mzilinski/vdirsyncer-graph
Project-URL: Repository, https://github.com/mzilinski/vdirsyncer-graph
Project-URL: Issues, https://github.com/mzilinski/vdirsyncer-graph/issues
Project-URL: KDE resource (C++ sibling), https://invent.kde.org/pim/kdepim-runtime/-/tree/master/resources/graph
Author-email: Malte Zilinski <malte@zilinski.eu>
License-Expression: LGPL-2.0-or-later
License-File: LICENSE
Keywords: caldav,carddav,exchange-online,icalendar,microsoft-365,microsoft-graph,nextcloud,vcard,vdirsyncer
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Office/Business :: Groupware
Classifier: Topic :: Office/Business :: Scheduling
Requires-Python: >=3.11
Requires-Dist: aiohttp>=3.9
Requires-Dist: icalendar>=6.0
Requires-Dist: tzlocal>=5.0
Requires-Dist: vdirsyncer>=0.19
Description-Content-Type: text/markdown

# vdirsyncer-graph

Microsoft 365 (Exchange Online) calendars, contacts and Microsoft To Do as
[vdirsyncer](https://vdirsyncer.pimutils.org/) 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/`](fixtures/README.md).
  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](https://invent.kde.org/pim/kdepim-runtime/-/tree/master/resources/graph)
(same author); the fixtures were extracted from its autotests.

## Install

```bash
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

```ini
[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

```bash
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

```bash
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.
