Microsoft 365 → CalDAV/CardDAV. A Graph-backed gateway that exposes your Exchange Online calendar, contacts and tasks over the open CalDAV / CardDAV protocols, so any standards-based DAV client can read and write them — Thunderbird, GNOME, KDE, Apple Calendar & Contacts, Android via DAVx5, or anything else that speaks CalDAV/CardDAV. No Outlook, no Gmail, no Exchange ActiveSync client ID.
┌──────────────────────┐── CalDAV/CardDAV ──┌────────────────────────┐── Microsoft Graph ──┌──────────────┐
│ any CalDAV/CardDAV │◄──────────────────►│ excense: Radicale DAV │◄───────────────────►│ Exchange │
│ client: Thunderbird, │ (HTTPS) │ store + Graph bridge │ (OAuth, your │ Online │
│ GNOME, KDE, Apple, │ └────────────────────────┘ own client ID) └──────────────┘
│ Android (DAVx5), … │
└──────────────────────┘
- Basic auth on Exchange Online is fully retired — EAS/POP/IMAP/EWS are OAuth-only now, and EAS OAuth requires an approved client ID per app.
- Reusing another product's client ID (e.g. TB-Sync's) fails Entra's
redirect_urivalidation, still needs admin consent in business tenants, and breaks their ToS. - EWS (what most bridge projects used) is being disabled October 2026 → April 2027. So this project is built on Microsoft Graph, the only forward-looking API.
- OAuth sign-in: Entra device-code flow (
auth) or browser paste-back (auth-web), tokens persisted and refreshed. - Two-way sync of all calendar events, all contacts, and tasks from all your Todo lists into a Radicale store, and back.
- CalDAV/CardDAV server (Radicale) with per-user basic auth, ready for any DAV client. Email (IMAP/SMTP gateway) is the next milestone, not included yet.
You can normally do this entirely yourself: any user can register an app, every delegated permission below is user-consentable, and Entra's default consent policy lets users consent to apps registered in their own tenant. Admin approval only comes into play if the tenant has disabled user consent — you'd see a hard "Need admin approval" screen at the first sign-in.
- Azure portal → App registrations → New registration:
- Name:
excense - Supported account types: Accounts in this organizational directory (or personal Microsoft accounts if you're on an MSA — but you need a work/school mailbox for Exchange Online).
- Name:
- Authentication → Allow public client flows: Yes, and under
the same tab add redirect URI
http://localhost(platform Mobile and desktop applications) — that is whatexcense auth-webhands the authorization code back through. - API permissions → add these delegated Graph permissions:
Calendars.ReadWrite,Contacts.ReadWrite,Tasks.ReadWrite,User.Read, plus (already present)offline_access. None of these require admin consent by default. - Copy the Application (client) ID into
EXCENSE_CLIENT_ID.
Single-tenant gotcha: if you chose Accounts in this organizational directory, also set
EXCENSE_TENANTto your tenant ID orcontoso.onmicrosoft.com— thecommondefault fails with "not configured as a multi-tenant application".
If the first sign-in does say Need admin approval, your tenant blocks user consent. Two ways out:
- Ask an admin for the one-click Grant admin consent button (Entra → app registrations → your app → API permissions → Grant admin consent for ). After that, tokens are issued without further prompts.
- Or skip registration and borrow a first-party client ID:
EXCENSE_CLIENT_ID=14d82eec-204b-4c2f-b7e8-296a70dab67e(Microsoft Graph PowerShell) withEXCENSE_TENANT=common. First-party apps are pre-authorized for Graph scopes and typically stay consentable even in locked-down tenants. Handy for personal use, but it rides on Microsoft's app identity rather than your own registration — treat it as a workaround, not a setup.
A different blocker entirely: error 53003 ("Your sign-in was successful but does not meet the criteria to access this resource"). That is not a consent problem — it is Conditional Access restricting the device-code flow, which many tenants ban outright. Ordinary browser sign-ins usually still work:
excense auth-web # prints a URL; sign in on any device with a browser,
# then paste the failed http://localhost redirect URL backTokens are cached and refreshed silently afterwards, so this stays a one-time interactive step.
Prerequisite: Python 3.11+ with venv/pip. Debian/Ubuntu:
sudo apt install python3 python3-venv python3-pip; Fedora, Arch and most
other distros ship venv/pip with the base python3 package.
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
cp .env.example .env # set EXCENSE_CLIENT_ID (+ EXCENSE_TENANT if single-tenant)
.venv/bin/excense auth # headless: opens device-code URL
.venv/bin/excense user-add me # DAV account password
.venv/bin/excense serve # CalDAV/CardDAV on :5232
.venv/bin/excense sync-loop # Graph bridge refreshes every 5 minOn NixOS, nix-shell drops you into a shell with Python and the venv
tooling, then run the same commands unchanged.
docker compose up -d --build
docker compose exec excense excense auth # device-code sign-in (or auth-web if CA blocks it)
docker compose exec excense excense user-add me # or set EXCENSE_PASSWORD on first bootUntil the first user-add, the server runs with an empty htpasswd: Radicale
is up, but DAV logins return 401. The container runs the Graph sync loop
alongside the server — it retries every 30s until excense auth has
produced a token.
Put it behind TLS (Caddy/Traefik) — DAV clients generally refuse plain HTTP, DAVx5 included:
cal.example.com {
# Host-level proxy: pin the upstream to IPv4. Plain "localhost" can
# resolve to ::1, and Docker's IPv6 publishing does not forward on
# every setup (symptom: 502 with "dial tcp [::1]:5232" in Caddy logs).
reverse_proxy 127.0.0.1:5232
}A proxy running inside the compose network can use the service name
instead: reverse_proxy excense:5232.
Every CalDAV/CardDAV client needs the same three details:
- Base URL:
https://cal.example.com/me/(the per-user folder) - Username / password: the
excense user-addaccount - Discovery then offers a calendar collection, a contacts address
book, and one task list collection per Microsoft To Do list
(
todo/<id>).
Client notes:
- Thunderbird (Linux/macOS/Windows): New Calendar → On the Network → CalDAV with the base URL; the same URL as a CardDAV remote address book. Task lists appear as VTODO calendars.
- GNOME / KDE: add a CalDAV/CardDAV account via GNOME Online Accounts or KAccounts — Evolution, GNOME Calendar/Contacts and KOrganizer pick it up from there.
- Apple (macOS/iOS): Internet Accounts → Other → CalDAV / CardDAV.
- Android: DAVx5 syncs the account into the system; any calendar/contacts/tasks app can then use it (e.g. Tasks.org for the VTODO lists).
Two-way, last-write-wins per item:
- Remote changes are pulled down, unless the local copy has unsynced edits (then local wins and is pushed up).
- Local creates/edits/deletes are pushed via Graph (POST/PATCH/DELETE).
- Graph↔local mapping rides in
X-EXCENSE-GRAPHID+ an sqlite state file (data/state/excense.db).
- Graph auth + Radicale store + DAV server
- Graph delta queries + webhook (today: full re-list each cycle, with a time filter for events)
- Email: IMAP/SMTP gateway over Graph mail
- Tasks: creation of new Todo lists from DAV
- Encrypted token-at-rest, read-only mode, multi-user DAV accounts
excense doesn't implement the DAV protocols — it stands on Radicale's shoulders:
- Radicale is the CalDAV/CardDAV server doing all
of the actual protocol work. excense generates its configuration
(
radicale.conf: bcrypt htpasswd auth,owner_onlyrights, multifilesystem storage), syncs Microsoft Graph data into that store, and launches Radicale aspython -m radicale. Collections on the client side are plain Radicale storage. Radicale is GPL-3.0 software and runs here as a separate process — keep its license terms in mind if you redistribute the combined bundle.
Also used, with thanks:
- MSAL Python — Entra device-code flow and token caching.
- vobject — iCalendar / vCard conversion between Graph and DAV.
- DavMail and the EAS-bridge projects — prior art this design learned from.
- EAS certificate-based auth from third-party clients is being retired by Microsoft end of 2026 — this project deliberately never used it.
- All-day event filtering on the date window has edge cases; the filter is skipped automatically if Graph rejects it.
- Calendar fetch is bounded by
EXCENSE_CALENDAR_PAST_DAYS/EXCENSE_CALENDAR_FUTURE_DAYS.