Skip to content

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

Open
SashankBhamidi wants to merge 37 commits intomasterfrom
feature/jmap
Open

feat(jmap): add caldav/jmap — JMAP calendar and task client#625
SashankBhamidi wants to merge 37 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.

Introduces caldav/jmap/ as a purely additive package providing JMAP
calendar support alongside the existing CalDAV client. No existing
files are modified.

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.

docs(jmap): JMAP usage documentation and autodoc stubs

This comment was marked as spam.

@SashankBhamidi SashankBhamidi marked this pull request as ready for review February 21, 2026 08:59
@SashankBhamidi SashankBhamidi changed the title feat(jmap): JMAP calendar support feat(jmap): add caldav/jmap — JMAP calendar and task client Feb 21, 2026
@tobixen
Copy link
Member

tobixen commented Feb 22, 2026

🤖 This comment is claude-generated.

Code Review: feature/jmap

Summary

Well-structured implementation. Clean layered design, passes ruff lint/format, 254 unit tests all green. A few things worth addressing before merging.


Architecture

caldav/jmap/
├── __init__.py          get_jmap_client / get_async_jmap_client factory
├── client.py            JMAPClient (sync)
├── async_client.py      AsyncJMAPClient (mirrors all public methods)
├── session.py           Session establishment + account discovery
├── error.py             Exception hierarchy
├── constants.py         Capability URNs
├── objects/             Dataclasses: JMAPCalendar, JMAPEvent, JMAPTask, JMAPTaskList
├── methods/             Pure functions: request builders + response parsers
└── convert/             iCalendar ↔ JSCalendar bidirectional conversion

Public API matches the CalDAV client pattern: get_calendars, create_event, get_event, update_event, delete_event, search_events, get_sync_token, get_objects_by_sync_token, task CRUD. Both sync and async variants.


Issues

Medium — potential crash in create_event (client.py:257):

if "new-0" in not_created:
    self._raise_set_error(session, not_created["new-0"])
return created["new-0"]["id"]   # KeyError if server omits it from both dicts

If the server returns a malformed response where "new-0" is absent from both created and not_created, you get an uncaught KeyError. Low probability but worth hardening — guard with if "new-0" not in created: or wrap in try/except KeyError.


Medium — non-deterministic participant UUIDs (ical_to_jscal.py):

Every conversion generates fresh uuid.uuid4() for participant IDs. If you fetch a JMAP event, convert to iCal, edit, and push back, participant IDs change on every call. Use uuid.uuid5(uuid.NAMESPACE_URL, email) to make them deterministic and stable across round-trips.


Medium — no SSL verification option (client.py):

DAVClient has ssl_verify_cert=False for testing against self-signed certs (Zimbra, local servers). JMAPClient has no equivalent — all requests use default requests.post() settings. Given the project's test infrastructure this will be needed.


Low — get_objects_by_sync_token makes two HTTP round-trips (client.py:410–436):

Issues CalendarEvent/changes then a separate CalendarEvent/get. search_events correctly batches query+get with a result reference in one HTTP call. The same pattern could apply here when hasMoreChanges is false.


Low — RELATED=END alarm trigger not handled (jscal_to_ical.py):

VALARM triggers with TRIGGER;RELATED=END:-PT15M (relative to event end) are silently treated as start-relative. The RELATED parameter is not checked.


Low — incomplete generic type hints on objects:

Dict fields in objects/event.py, objects/task.py etc. are typed as bare dict rather than dict[str, bool] or dict[str, dict]. Not a runtime bug, but reduces IDE support.


Low — async client has zero integration test coverage:

test_jmap_integration.py only exercises JMAPClient. AsyncJMAPClient mirrors all methods but has never been run against a real server. A smoke test (get_calendars) would at least catch obvious wiring issues.


Documentation

docs/source/jmap.rst (377 lines) is comprehensive — covers quick start, auth modes, CRUD, search, sync tokens, task API, and async usage. Good.

CHANGELOG entry has been added in the branch.

@SashankBhamidi
Copy link
Collaborator Author

Thanks for the review! Responses inline:

potential crash in create_event (client.py:257) — KeyError if server omits "new-0" from both dicts

Valid, will fix.

non-deterministic participant UUIDs — use uuid.uuid5 for stable IDs across round-trips

False positive. The UUID is a local ephemeral key within a single CalendarEvent/set call, the server assigns its own stable participant IDs. Stability across round-trips is a server responsibility in JMAP.

no SSL verification option

Noted, out of scope for this PR.

get_objects_by_sync_token makes two HTTP round-trips

Intentional. CalendarEvent/changes returns separate created and updated lists, and we need to preserve that distinction in the return value. A result reference would merge them, losing the added/modified split.

RELATED=END alarm trigger not handled

Valid, will fix.

bare dict type hints

Noted, low priority.

async client has zero integration test coverage

TestAsyncJMAPEventIntegration in test_jmap_integration.py covers create, get, update, delete, search, sync token, and incremental sync against live Cyrus, 6 tests total.

@tobixen
Copy link
Member

tobixen commented Feb 22, 2026

Sorry for not getting back with human-generated comments yet. My 15yo son was dragging me for a monster skiing trip yesterday, we managed to get home only after midnight, quite exhausted. Will have a calmer shorter skiing trip with my daughter today before getting back to this.

@tobixen
Copy link
Member

tobixen commented Feb 23, 2026

Sorry for the slow response on this. Crazy days, as always.

One of my ideas here was to keep the API as similar as possible. There is no client.get_event(uid), it's calendar.get_object_by_uid(uid) for one thing. There is no client_obj.search_events, it's calendar.search(event=True), etc. I would also like the get_davclient and get_calendar to return jmap-objects if it's given in the configuration that jmap should be used.

Is this possible?

@SashankBhamidi
Copy link
Collaborator Author

@tobixen,

I did think about this when designing the API. Since we talked about potentially having JMAP in a different repo, I thought it would be better to have its own client so it's easier to maintain as a separate project.

Having calendar.get_object_by_uid() and calendar.search() work across both protocols would be much cleaner for users. It's definitely possible but requires significant refactoring.

Here's what we'd need to do:

  • Add protocol detection in get_davclient() and get_calendar()
  • Wire JMAP transport through the existing operations layer
  • Modify domain objects to handle both backends

The protocol and conversion layers could mostly stay put. It's really about how we expose things at the API level.

Here's the thing though: should we prioritize this integration work, or does the separate repo idea still make more sense? If we're splitting it out anyway, the current approach might actually be better since it keeps things decoupled. Plus, if anyone contributes to that repo, they wouldn't need to understand CalDAV internals. Same for future maintainers - they could focus purely on JMAP without getting tangled up in CalDAV complexities.

What are you thinking? I'm happy to go either direction. Just want to avoid doing a bunch of integration work if we're going to separate them later.

@tobixen
Copy link
Member

tobixen commented Feb 24, 2026

Sorry for the late reply. While this is a part of the caldav library, I think it should be a bit integrated. At the very minimum, it should be possible to have JMAP and caldav configuration side-by-side in the same configuration file.

If we release 3.0 with a caldav.get_davclient(), then this cannot be undone easily (things should be backward compatible). So maybe my idea of letting caldav.get_davclient return a JMAPClient and caldav.get_calendar return a jmap calendar should be scratched. In the future I guess we will make a get_client in the calendaring-client library which can return either a DAVClient, JMAPClient or SomeOtherClient.

Anyway, the API should be as similar as possible (deviations acceptable only when the standard itself deviates too much for a common API to make sense), so I think this should be redone a bit.

@SashankBhamidi
Copy link
Collaborator Author

No problem.

Having unified config support and API alignment without breaking backward compatibility is a good middle ground.

So instead of trying to make get_davclient() return JMAP clients, we keep them separate but make the APIs mirror each other. Something like jmap_calendar.get_object_by_uid() instead of client.get_event() to match the CalDAV patterns.

For unified config, we could extend the existing config file format to handle both protocol settings in the same file. That would make it much easier for users who want to work with both.

I'll rework the API to align better with the CalDAV structure. The protocol and conversion layers can stay mostly as-is, it's really about reshaping the public interface.

Should I start with the API restructuring, or do you want to discuss the config approach first?

@tobixen
Copy link
Member

tobixen commented Feb 25, 2026

Just go ahead

@SashankBhamidi
Copy link
Collaborator Author

Sorry for the delay.

Reworked per your feedback, please review bcdc014

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