No description
  • Python 98.2%
  • Nix 1.1%
  • Dockerfile 0.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
m4rti 8752892bcb
Some checks failed
ci / quality (push) Has been cancelled
ci / test (push) Has been cancelled
ci / image (push) Has been cancelled
ci / audit (push) Has been cancelled
ci / nix (push) Has been cancelled
feat: read-only Google side backed by the calendar's secret ICS feed
GOOGLE_ICS_URL is a second way to reach the Google calendar: the secret
iCalendar address every calendar publishes, which needs no Google Cloud
project, no service account key and no sharing step. It can only be read,
so SYNC_DIRECTION is google_to_caldav and the configuration refuses any
other.

GoogleIcsCalendar satisfies the same ports.GoogleSide protocol as the API
client, so the engine did not change. What did:

- ical.py grows ical_to_series_map, which groups a whole-calendar document
  into one series per UID with its modification stamp, and shares the
  assembly with ical_to_series rather than duplicating it.
- The reconciliation window is applied in the client, since a feed has no
  server-side time range. A rule set dateutil cannot expand keeps its
  series: one event too many is recoverable, one lost is not.
- master_exists also accepts a bare Google event id, so a state database
  written under service account auth is not read as a calendar's worth of
  deletions.
- With SYNC_REMINDERS off the feed's VALARMs are stripped on the way in, so
  the setting keeps meaning the same thing under either credential.

Verified against a real Radicale as well as the unit suite: the feed lands
whole, overrides and cancellations included, and the second run writes
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 21:00:41 +02:00
.github/workflows docs: README, installation, development, deployment, architecture, ADRs, AGENTS.md 2026-09-04 11:34:56 +02:00
docs feat: read-only Google side backed by the calendar's secret ICS feed 2026-09-13 21:00:41 +02:00
src/caldavsync feat: read-only Google side backed by the calendar's secret ICS feed 2026-09-13 21:00:41 +02:00
tests feat: read-only Google side backed by the calendar's secret ICS feed 2026-09-13 21:00:41 +02:00
tools feat: Nix flake, CI, pre-commit hooks; test the Google client and its request bodies 2026-09-04 11:28:21 +02:00
.dockerignore feat: Docker image, compose with test CalDAV server, content-hash change markers 2026-09-04 11:20:08 +02:00
.editorconfig chore: repo skeleton, license, tooling config 2026-09-04 10:48:27 +02:00
.env.example feat: read-only Google side backed by the calendar's secret ICS feed 2026-09-13 21:00:41 +02:00
.gitattributes chore: repo skeleton, license, tooling config 2026-09-04 10:48:27 +02:00
.gitignore chore: repo skeleton, license, tooling config 2026-09-04 10:48:27 +02:00
.pre-commit-config.yaml feat: Nix flake, CI, pre-commit hooks; test the Google client and its request bodies 2026-09-04 11:28:21 +02:00
.python-version chore: repo skeleton, license, tooling config 2026-09-04 10:48:27 +02:00
AGENTS.md feat: read-only Google side backed by the calendar's secret ICS feed 2026-09-13 21:00:41 +02:00
CLAUDE.md docs: README, installation, development, deployment, architecture, ADRs, AGENTS.md 2026-09-04 11:34:56 +02:00
compose.yaml feat: Nix flake, CI, pre-commit hooks; test the Google client and its request bodies 2026-09-04 11:28:21 +02:00
Dockerfile feat: Nix flake, CI, pre-commit hooks; test the Google client and its request bodies 2026-09-04 11:28:21 +02:00
flake.lock docs: README, installation, development, deployment, architecture, ADRs, AGENTS.md 2026-09-04 11:34:56 +02:00
flake.nix feat: Nix flake, CI, pre-commit hooks; test the Google client and its request bodies 2026-09-04 11:28:21 +02:00
LICENSE chore: repo skeleton, license, tooling config 2026-09-04 10:48:27 +02:00
NOTICE chore: repo skeleton, license, tooling config 2026-09-04 10:48:27 +02:00
pyproject.toml docs: README, installation, development, deployment, architecture, ADRs, AGENTS.md 2026-09-04 11:34:56 +02:00
README.md feat: read-only Google side backed by the calendar's secret ICS feed 2026-09-13 21:00:41 +02:00
uv.lock docs: README, installation, development, deployment, architecture, ADRs, AGENTS.md 2026-09-04 11:34:56 +02:00

caldavsync

Bidirectional sync between one Google Calendar and one CalDAV calendar (Nextcloud, Radicale, Baikal, SOGo), configured entirely by environment variables. Fill in a .env, run docker compose up -d, done: no config file, no interactive setup, no credential mounted into the container.

Status: works, and covered by 351 tests including a suite against a real CalDAV server. Not yet run against a production calendar for a long stretch, so read Known limitations before pointing it at a calendar you care about, and take the DRY_RUN=true step in the quickstart seriously.

Stack

  • Python 3.13, uv
  • caldav and icalendar for the CalDAV side
  • google-api-python-client with service account credentials for Google, or the calendar's secret ICS feed for a read-only mirror
  • pydantic-settings for boot-time configuration validation
  • SQLite for sync state, structlog for JSON logs
  • Docker as the deploy target; Nix flake for development and a NixOS build

What it syncs

Direction
Create, edit, delete an event both ways
All-day and timed events, with timezones both ways
Recurring series (RRULE, RDATE) both ways
A single moved or edited occurrence (RECURRENCE-ID) both ways
A single deleted occurrence (EXDATE / cancelled instance) both ways
Attendees and their response status both ways
Organizer, location, description both ways
Colours (Google's 11 map to CSS names) both ways
Reminders (VALARM) preserved on CalDAV always; to Google with SYNC_REMINDERS=true
Categories CalDAV only; preserved, never clobbered

Conflicts, when both sides changed since the last run, resolve by newest_wins (default), google_wins or caldav_wins.

Two-way sync needs a Google service account, and therefore a Google Cloud project. If a read-only mirror of Google into CalDAV is all you want, point GOOGLE_ICS_URL at the calendar's secret iCalendar address instead and skip the project entirely - see Google, once.

Quickstart

1. Google, once

Pick one of two credentials. They are mutually exclusive, and the rest of the configuration is the same either way.

A. Service account - two-way sync. Google Calendar has no other credential that is both writable and non-interactive. An API key cannot work: API keys are read-only and reach only public calendars.

  1. Google Cloud console -> create or pick a project.
  2. APIs & Services -> Library -> enable Google Calendar API.
  3. IAM & Admin -> Service Accounts -> create one.
  4. On that account: Keys -> Add key -> Create new key -> JSON. Download it.
  5. Google Calendar -> Settings -> your calendar -> Share with specific people or groups -> add the service account's client_email address and grant it Make changes to events.

Step 5 is the one people miss. caldavsync check prints the exact address.

B. The secret ICS address - read-only, no Google Cloud project. In Google Calendar, Settings -> your calendar -> Integrate calendar -> Secret address in iCal format: copy the URL ending in .ics into GOOGLE_ICS_URL and leave GOOGLE_SERVICE_ACCOUNT_JSON and GOOGLE_CALENDAR_ID unset. That is the whole setup.

What you give up, and it is not small:

  • One way. Changes flow Google -> CalDAV only; SYNC_DIRECTION is fixed to google_to_caldav. An event deleted in the CalDAV client is not restored until Google next changes it.
  • Freshness. Google serves a cached copy of that feed, so the mirror can lag the calendar by minutes to hours no matter how short SYNC_INTERVAL_SECONDS is.
  • That URL is a password. Anyone holding it can read the whole calendar, and revoking it means resetting the address for every consumer of it.

docs/decisions/0005-ics-feed-as-a-read-only-google-side.md has the rest of the trade.

2. Configure

cp .env.example .env
$EDITOR .env

Six values are required: GOOGLE_SERVICE_ACCOUNT_JSON, GOOGLE_CALENDAR_ID, CALDAV_URL, CALDAV_USER, CALDAV_PASS, CALDAV_CALENDAR_ID. Everything else has a default. With the ICS feed it is five: GOOGLE_ICS_URL replaces the first two.

The key goes in on one line, most easily base64-encoded:

echo "GOOGLE_SERVICE_ACCOUNT_JSON=$(base64 -w0 service-account.json)" >> .env

3. Docker

docker compose run --rm caldavsync check     # verifies both sides, explains failures
DRY_RUN=true docker compose up               # reports intended writes, changes nothing
docker compose up -d                         # for real
docker compose logs -f
curl localhost:8080/status

4. Local tooling, without Docker

Needs uv and nothing else - it fetches its own Python:

uv sync --frozen
STATE_DB=./data/state.db LOG_FORMAT=console uv run caldavsync check
STATE_DB=./data/state.db LOG_FORMAT=console uv run caldavsync once

5. Nix

nix develop            # toolchain, and STATE_DB/LOG_FORMAT preset for a dev host
uv sync --frozen
uv run caldavsync once

nix build .#caldavsync # the application
nix build .#container  # an image, no Docker daemon needed: docker load < result

Environment

Variable Default What it is
GOOGLE_SERVICE_ACCOUNT_JSON required* Service account key: raw JSON or base64
GOOGLE_CALENDAR_ID required* Real calendar id, e.g. your email. Not primary
GOOGLE_ICS_URL required* Secret iCalendar address instead of the two above: read-only, one-way
CALDAV_URL required DAV root, or the calendar collection URL itself
CALDAV_USER required CalDAV username
CALDAV_PASS required App password, not an account password
CALDAV_CALENDAR_ID required Collection id. Created if absent
CALDAV_CALENDAR_NAME = calendar id Display name, used only at creation
SYNC_DIRECTION bidirectional google_to_caldav, caldav_to_google. Fixed to the first with GOOGLE_ICS_URL
SYNC_CONFLICT_RESOLUTION newest_wins google_wins, caldav_wins
SYNC_INTERVAL_SECONDS 300 Daemon interval
SYNC_PAST_DAYS / SYNC_FUTURE_DAYS 30 / 365 Reconciliation window
SYNC_DELETE_PROPAGATION true Mirror deletions
SYNC_SEND_INVITATIONS false Must stay false under service account auth
SYNC_REMINDERS false Map reminders to Google too. Off by default; see Known limitations
DRY_RUN false Report intended writes, change nothing
STATE_DB /data/state.db Sync state. Needs a volume
HEALTH_HOST / HEALTH_PORT 0.0.0.0 / 8080 Health server, inside the container
CALDAVSYNC_HOST_PORT 8080 Where compose publishes it on the host
HEALTH_FAILURE_THRESHOLD 3 Failed runs before /readyz goes unready
LOG_LEVEL / LOG_FORMAT INFO / json console for humans
TZ UTC Container timezone

* Either GOOGLE_ICS_URL, or both GOOGLE_SERVICE_ACCOUNT_JSON and GOOGLE_CALENDAR_ID. Setting both credentials is refused at boot.

Every secret also accepts a <NAME>_FILE variant naming a file that holds the value, for sops-nix, agenix or Docker secrets. See .env.example, which documents all of it with the setup steps inline.

Commands

Command What it does
caldavsync run Sync every SYNC_INTERVAL_SECONDS. The container default
caldavsync once Sync once, exit non-zero on any failure
caldavsync once --dry-run Report intended writes, change nothing
caldavsync check Verify config and both calendars, explain what is wrong

Exit codes: 0 success, 1 runtime failure, 2 the environment does not describe a runnable configuration (retrying will not help).

Task Command
Test uv run pytest
Unit tests only uv run pytest -m "not integration"
Lint uv run ruff check
Format uv run ruff format .
Typecheck uv run mypy
Real CalDAV server docker compose --profile test up -d radicale

Endpoints

Route Meaning
/healthz The process is up and serving. Liveness
/readyz Both calendars authenticated and one sync succeeded; unready after HEALTH_FAILURE_THRESHOLD consecutive failures
/status Last run: when, what changed, what failed, how many series are linked

Layout

src/caldavsync/     the application; see docs/architecture.md
tests/              unit tests, plus integration tests against real Radicale
tests/radicale/     the throwaway CalDAV server those use
docs/               installation, development, deployment, architecture, decisions
tools/              repo checks not worth a dependency

Known limitations

  • Reminders do not reach Google by default. They are modelled, preserved and round-tripped on the CalDAV side, so a Google-side edit no longer destroys them. But Google reports an event's reminders for the authenticated user, which under service account auth is the service account and not you - so what it reports is not your reminders, and what it accepts would notify nobody. SYNC_REMINDERS=true turns the mapping on anyway, with the lossy edges documented in docs/decisions/0004-reminders.md.
  • Attachments and X- properties are still dropped on rewrite. They are not in the model, so a Google-side edit rewrites the CalDAV resource without them. Reminders, categories and unmappable colours are explicitly preserved; these are not.
  • One calendar pair per container. Run a second container for a second pair; the state databases must not be shared.
  • The reconciliation window is not the whole calendar. Events outside SYNC_PAST_DAYS/SYNC_FUTURE_DAYS are left alone, not deleted.
  • A service account cannot email invitations on your behalf. Attendees sync; the notifications do not.
  • The ICS feed is read-only and cached by Google. With GOOGLE_ICS_URL the sync is one-way, and the mirror trails the calendar by however long Google's cache holds - minutes to hours.
  • An occurrence moved outside the window cannot be synced as an override, and says so in the logs.

Docs

Credits

Reconciliation design and the colour/PARTSTAT tables derive from photoevents/caldavsync (MIT). See NOTICE for what is shared and what differs.

License

MIT. See LICENSE.