Skip to content

feat(jmap): add caldav/jmap — JMAP calendar and task client#625

Merged
tobixen merged 43 commits intomasterfrom
feature/jmap
Mar 3, 2026
Merged

feat(jmap): add caldav/jmap — JMAP calendar and task client#625
tobixen merged 43 commits intomasterfrom
feature/jmap

Conversation

@SashankBhamidi
Copy link
Collaborator

@SashankBhamidi SashankBhamidi commented Feb 19, 2026

Adds caldav/jmap/, a new module providing JMAP calendar and task support alongside the existing CalDAV client. Zero modifications to any existing file.

The module follows the same layered sans-I/O design as the CalDAV side: pure method builders/parsers in methods/, dataclasses in objects/, bidirectional iCalendar ↔ JSCalendar conversion in convert/, HTTP + session logic in client.py and async_client.py.

Usage documentation in docs/source/jmap.rst covers auth, event CRUD, search, incremental sync, tasks, async API, and error handling.

Session bootstrap (session.py): GET /.well-known/jmap, resolve relative apiUrl via urljoin (Cyrus returns a relative path), select account via primaryAccounts[CALENDAR_CAPABILITY] with a fallback scan of all accounts. Raises JMAPCapabilityError if no calendar-capable account is found.

Auth (client.py): Basic when username is supplied, Bearer when only password is given, or a pre-built auth object via the auth kwarg. No 401-challenge-retry — a 401/403 from session or API endpoint raises JMAPAuthError immediately. JMAPError extends DAVError so existing CalDAV exception handlers catch JMAP errors too.

Calendar-scoped API (objects/calendar.py): JMAPCalendar objects returned by get_calendars() carry three methods that mirror caldav.collection.Calendar exactly: cal.add_event(ical_str), cal.get_object_by_uid(uid), and cal.search(event=True, start=, end=, text=). Both sync and async clients inject themselves into each calendar object so the same method works regardless of which client was used.

Client-level operations on both JMAPClient and AsyncJMAPClient: get_calendars, create_event, get_event, update_event, delete_event, search_events, get_sync_token, get_objects_by_sync_token. search_events uses a single batched request — CalendarEvent/query + a result reference into CalendarEvent/get — one HTTP round-trip regardless of result size. get_objects_by_sync_token raises JMAPMethodError(error_type="serverPartialFail") when the server truncates the change list (hasMoreChanges: true).

Task operations (urn:ietf:params:jmap:tasks): get_task_lists, create_task, get_task, update_task, delete_task. Task methods send _TASK_USING = [CORE_CAPABILITY, TASK_CAPABILITY]; servers without urn:ietf:params:jmap:tasks return an error methodResponse which _request converts to JMAPMethodError. The tasks specification is an expired IETF draft (draft-ietf-jmap-tasks, expired Sep 2023) with no RFC number — this is the most spec-unstable part of the implementation. Cyrus does not implement it, so task integration tests are deferred until a Stalwart Docker setup is added.

AsyncJMAPClient (async_client.py) mirrors every method as a coroutine. Each request opens its own niquests.AsyncSession — no long-lived connection is held.

iCalendar ↔ JSCalendar conversion (convert/): full bidirectional mapping covering DTSTART (all-day, floating, UTC, IANA-tz, non-IANA TZID passthrough), DTEND/DURATION, RRULE, EXRULE, EXDATE, RECURRENCE-ID overrides, ORGANIZER/ATTENDEE with roles and participation status, VALARM (relative and absolute triggers), CATEGORIES, LOCATION, CLASS, TRANSP, SEQUENCE, PRIORITY, COLOR. Shared duration/datetime primitives (_timedelta_to_duration, _duration_to_timedelta, _format_local_dt) live only in convert/_utils.py.

Entry points (__init__.py): get_jmap_client(**kwargs) and get_async_jmap_client(**kwargs) read from the same sources as get_davclient — explicit kwargs, env vars, config file — and return None when no configuration is found.

264 unit tests (zero network, all mocked). 17 integration tests against live Cyrus Docker (5 session/calendar checks, 6 sync event CRUD/search/sync, 6 async equivalents); auto-skipped if server unreachable.

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

@SashankBhamidi
Copy link
Collaborator Author

SashankBhamidi commented Feb 20, 2026

@tobixen Is this better now? I’ll pull it tomorrow and work on CI errors. I committed the changes as suggestions for now.

SashankBhamidi and others added 27 commits March 3, 2026 11:46
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@SashankBhamidi
Copy link
Collaborator Author

@tobixen Added Stalwart JMAP integration tests (7 tests pass). Two quirks: it rejects inCalendars in CalendarEvent/query, and hostname: localhost is required in docker-compose or it advertises the container ID in session URLs.

@tobixen tobixen merged commit 2a27e42 into master Mar 3, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants